%% superfig.sty -- general paper-figure toolkit %% A smaller sibling of supertensor.sty. It keeps the house style, role/color %% discipline, cursor layout, build-time warning discipline and audit mindset, %% but its primitives are generic nodes and edges, not tensor faces. %% %% Options: cjk load ctex with the portable fandol fontset (XeLaTeX) %% en English rail labels in the meaning box (default: zh) \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{superfig}[2026/08/16 v1.0 general paper figure toolkit] \newif\iffig@cjk\fig@cjkfalse \newif\iffig@en\fig@enfalse \DeclareOption{cjk}{\fig@cjktrue} \DeclareOption{en}{\fig@entrue} \DeclareOption{zh}{\fig@enfalse} \ProcessOptions\relax \RequirePackage{amsmath} \RequirePackage{amssymb} \RequirePackage{xcolor} \RequirePackage{etoolbox} \RequirePackage{array} \RequirePackage{tikz} \usetikzlibrary{calc,positioning,arrows.meta,backgrounds,fit,decorations.pathreplacing} \iffig@cjk \RequirePackage[UTF8,fontset=fandol]{ctex} \fi % ---------------------------------------------------------------- layers ---- \pgfdeclarelayer{sfbg} \pgfdeclarelayer{sffg} \pgfsetlayers{sfbg,main,sffg} % ---------------------------------------------------------------- palette --- \definecolor{sfTeal}{HTML}{4F8FA5} \definecolor{sfOrange}{HTML}{EE995B} \definecolor{sfCoral}{HTML}{C95B5B} \definecolor{sfViolet}{HTML}{8A74B5} \definecolor{sfGray}{HTML}{85898F} \definecolor{sfInk}{HTML}{1A1A1A} % Role registry: drawing macros take a ROLE, never a color. \newcommand{\sfsetrole}[2]{% \edef\sf@newrole{#2}% \ifcsname sf@role@#1\endcsname \edef\sf@oldrole{\csname sf@role@#1\endcsname}% \ifx\sf@oldrole\sf@newrole\else \PackageWarning{superfig}{Role `#1' was already mapped to `\sf@oldrole' and cannot be remapped to `\sf@newrole'}% \fi \else \expandafter\gdef\csname sf@role@#1\endcsname{#2}% \fi} \newcommand{\sfrole}[1]{% \ifcsname sf@role@#1\endcsname\csname sf@role@#1\endcsname\else sfGray\fi} \newcommand{\sfcheckrole}[1]{% \ifcsname sf@role@#1\endcsname\else \PackageWarning{superfig}{Undeclared role `#1' -- drawn in neutral gray. Declare it with \string\sfsetrole\space so the hue budget stays visible}% \fi} \sfsetrole{neutral}{sfGray} \newcommand{\sflevelpct}[1]{\ifcase#1 0\or30\or55\or80\else55\fi} % --------------------------------------------------------- type hierarchy --- \tikzset{ sf sym/.style = {font=\small, text=sfInk, inner sep=1pt}, sf shape/.style = {font=\scriptsize, text=black!55, inner sep=1pt}, sf stage/.style = {font=\small\bfseries, text=black!55, inner sep=2pt}, sf op/.style = {font=\Large, text=sfInk, inner sep=2pt}, sf note/.style = {font=\scriptsize, text=black!55, inner sep=2pt}, sf arrow/.style = {-{Stealth[length=2.2mm,width=1.6mm]}, draw=black!45, line width=0.5pt}, sf box/.style = {draw=black!60, line width=0.5pt, rounded corners=1.5pt, inner sep=5pt, font=\small, align=center, text=sfInk}, } % --------------------------------------------------------- flow layout ------ \newlength{\sfgutter}\setlength{\sfgutter}{6mm} \newlength{\sfrowgap}\setlength{\sfrowgap}{3.5mm} \newlength{\sfblockgap}\setlength{\sfblockgap}{9mm} \newlength{\sflinklen}\setlength{\sflinklen}{10mm} \newlength{\sf@railx}\newlength{\sf@cx}\newlength{\sf@cy} \newlength{\sf@ycur}\newlength{\sf@bandh}\newlength{\sf@tmpx}\newlength{\sf@tmpy} \newif\iffig@inrow \newif\iffig@first \newif\iffig@bracket \newcount\sf@nmem \newcount\sf@ncallouts \newcommand{\sflayoutreset}{% \global\sf@railx=0pt \global\sf@ycur=0pt \global\sf@cx=0pt \global\sf@cy=0pt \global\sf@bandh=0pt \global\fig@inrowfalse \global\sf@ncallouts=0 \gdef\sf@rowlist{}\gdef\sf@alllist{}\gdef\sf@rowname{}% \gdef\sf@lastnode{}\gdef\sf@linklabel{}\gdef\sf@linkprev{}} \sflayoutreset \newcommand{\sfleftrail}[1]{\global\sf@railx=\dimexpr#1\relax} \newcommand{\sfvgap}[1]{\global\advance\sf@ycur by -\dimexpr#1\relax} \newcommand{\sf@lower}[1]{% \pgfextracty{\sf@tmpy}{\pgfpointanchor{#1}{south}}% \ifdim\sf@tmpy<\sf@ycur \global\sf@ycur=\sf@tmpy \fi} \newcommand{\sf@regall}[1]{\xdef\sf@alllist{\sf@alllist(#1)}} \newcommand{\sf@regrow}[1]{% \xdef\sf@rowlist{\sf@rowlist(#1)}\sf@regall{#1}% \sf@drawpendinglink{#1}% \gdef\sf@lastnode{#1}} \newcommand{\sf@needrow}[1]{% \iffig@inrow\else \PackageError{superfig}{\string#1\space needs an open \string\sfrow}% {Flow placement only works between \string\sfrow\space and \string\sfrowend. Pass at={} instead.}% \fi} \newcommand{\sf@leadgap}[1]{% \iffig@first \global\fig@firstfalse \else \global\advance\sf@cx by \dimexpr#1\relax \fi} \newcommand{\sf@flow}[2]{% \dimen0=\dimexpr#1\relax \dimen2=\sf@cx \advance\dimen2 by 0.5\dimen0 \edef\sf@pos{(\the\dimen2,\the\sf@cy)}% \global\advance\sf@cx by \dimen0} \newcommand{\sf@checkh}[2]{% \dimen0=\dimexpr#2\relax \advance\dimen0 by -\sf@bandh \ifdim\dimen0>4mm \PackageWarning{superfig}{Object `#1' overflows its band by \the\dimen0. Raise the height declared in \string\sfrow\space or give it its own band}% \fi} \newcommand{\sfstage}[2]{% \dimen0=\sf@ycur \advance\dimen0 by -\sfblockgap \node[sf stage, anchor=north west] (#1) at (\the\sf@railx,\the\dimen0) {#2}; \sf@regall{#1}\sf@lower{#1}} \newcommand{\sfrow}[2]{% \gdef\sf@rowlist{}\gdef\sf@rowname{#1}\gdef\sf@lastnode{}% \gdef\sf@linklabel{}\gdef\sf@linkprev{}% \expandafter\gdef\csname sf@isrow@#1\endcsname{1}% \global\fig@inrowtrue\global\fig@firsttrue \pgfmathsetlengthmacro{\sf@bh}{#2}% \global\sf@bandh=\sf@bh \global\sf@cx=\sf@railx \dimen0=\sf@ycur \advance\dimen0 by -\sfrowgap \advance\dimen0 by -0.5\sf@bandh \global\sf@cy=\dimen0} \newcommand{\sfrowend}{% \ifdefempty{\sf@rowlist}% {\PackageWarning{superfig}{Empty \string\sfrow\space `\sf@rowname'}}% {\edef\sf@do{\noexpand\node[inner sep=0pt, outer sep=0pt, fit={\sf@rowlist}] (\sf@rowname) {};}\sf@do \sf@regall{\sf@rowname}\sf@lower{\sf@rowname}}% \global\fig@inrowfalse} % --------------------------------------------------------------- nodes ------ \pgfkeys{ /sf/node/.cd, role/.store in=\sf@role, level/.store in=\sf@level, gap/.store in=\sf@gap, bracket/.is if=fig@bracket, at/.store in=\sf@at, role=neutral, level=2, gap=\sfgutter, bracket=false, at={}, } % \sfnode[keys]{name}{label}{width}{height} % A semantic object: one node, one fill, one role. In an open \sfrow an empty % at places it at the cursor; at={} bypasses the cursor for branchy % architecture figures. Width/height are real lengths or unit expressions. \newcommand{\sfnode}[5][]{% \begingroup \pgfkeys{/sf/node/.cd,#1}% \pgfmathsetlengthmacro{\sf@w}{#4}% \pgfmathsetlengthmacro{\sf@h}{#5}% \sfcheckrole{\sf@role}% \edef\sf@col{\sfrole{\sf@role}}% \edef\sf@fill{\sf@col!\sflevelpct{\sf@level}}% \ifdefempty{\sf@at}{% \sf@needrow{\sfnode}% \sf@checkh{#2}{#5}% \sf@leadgap{\sf@gap}% \iffig@bracket\pgfmathsetlengthmacro{\sf@tw}{\sf@w+6.8mm}\else\let\sf@tw\sf@w\fi \sf@flow{\sf@tw}{\sf@h}% }{% \edef\sf@pos{\sf@at}% }% \expandafter\node[sf box, fill=\sf@fill, minimum width=\sf@w, minimum height=\sf@h, at={\sf@pos}] (#2) {#3};% \iffig@bracket \draw[black!55, line width=0.5pt] ($(#2.north west)+(-1.1mm,0.6mm)$) -- ++(-1.1mm,0) -- ($(#2.south west)+(-2.2mm,-0.6mm)$) -- ++(1.1mm,0); \draw[black!55, line width=0.5pt] ($(#2.north east)+(1.1mm,0.6mm)$) -- ++(1.1mm,0) -- ($(#2.south east)+(2.2mm,-0.6mm)$) -- ++(-1.1mm,0); \fi \ifdefempty{\sf@at}{% \sf@regrow{#2}% }{% \sf@regall{#2}\sf@lower{#2}% }% \endgroup} % Operator or small annotation in a flow row. \newcommand{\sfop}[2]{% \iffig@inrow\else \PackageError{superfig}{\string\sfop\space needs an open \string\sfrow}% {Operators are flow objects. Use \string\sfarrow\space for a fixed edge.}% \fi \sf@leadgap{\sfgutter}% \node[sf op, anchor=west] (#1) at (\the\sf@cx,\the\sf@cy) {#2};% \pgfextractx{\sf@tmpx}{\pgfpointanchor{#1}{east}}% \global\sf@cx=\sf@tmpx \sf@regrow{#1}} \newcommand{\sfgap}[1]{\global\advance\sf@cx by \dimexpr#1\relax} % ------------------------------------------------------------ connectors ---- \newcommand{\sf@drawpendinglink}[1]{% \ifdefempty{\sf@linkprev}{}{% \begin{pgfonlayer}{sfbg} \ifdefempty{\sf@linklabel}% {\draw[sf arrow] (\sf@linkprev.east) -- (#1.west);}% {\draw[sf arrow] (\sf@linkprev.east) -- (\sf@linklabel.west); \draw[sf arrow] (\sf@linklabel.east) -- (#1.west);}% \end{pgfonlayer} \gdef\sf@linkprev{}\gdef\sf@linklabel{}}} % \sfconn{name}{label} -- horizontal edge through the cursor. The label reserves % its own width, so it cannot be wider than the edge it labels. \newcommand{\sfconn}[2]{% \sf@needrow{\sfconn}% \xdef\sf@linkprev{\sf@lastnode}% \ifblank{#2}% {\gdef\sf@linklabel{}\sfgap{\sflinklen}}% {\sf@leadgap{\sfgutter}% \node[sf note, anchor=west, inner sep=1pt] (#1) at (\the\sf@cx,\the\sf@cy) {#2}; \pgfextractx{\sf@tmpx}{\pgfpointanchor{#1}{east}}% \global\sf@cx=\sf@tmpx \gdef\sf@linklabel{#1}\sf@regall{#1}}} % Fixed directed edge between two already-placed objects. \newcommand{\sfarrow}[3][]{% \begin{pgfonlayer}{sfbg} \draw[sf arrow] (#2) to[#1] (#3); \end{pgfonlayer}} \newcommand{\sfarrowlabel}[4][]{% \begin{pgfonlayer}{sfbg} \draw[sf arrow] (#2) to[#1] node[sf note, auto, fill=white, inner sep=1pt] {#4} (#3); \end{pgfonlayer}} % ----------------------------------------------------------- bounding box --- \newcommand{\sfbbox}[1]{% \edef\sf@do{\noexpand\node[inner sep=0pt, outer sep=0pt, fit={\sf@alllist}] (#1) {};}\sf@do} \newcommand{\sftrack}[1]{\sf@regall{#1}\sf@lower{#1}} \newcommand{\sftopformula}[2]{% \sfbbox{sf@bbt}% \node[above=6mm of sf@bbt, anchor=south] (#1) {#2}; \sf@regall{#1}} % -------------------------------------------------------------- captions ---- \def\sf@lane{} \newcommand{\sflane}[1]{\def\sf@lane{#1}} \newcommand{\sfnolane}{\def\sf@lane{}} \newcommand{\sfcaption}[3]{% \ifdefempty{\sf@lane}% {\node[sf sym, below=1.6mm of #1] (#1-sym) {#2};}% {\node[sf sym, anchor=north] at ($(#1.center |- \sf@lane.south)+(0,-1.6mm)$) (#1-sym) {#2};}% \node[sf shape, below=0.6mm of #1-sym] (#1-shape) {#3};% \sf@regall{#1-shape}\sf@lower{#1-shape}} \newcommand{\sfcaptiontop}[2]{% \node[sf sym, above=1.6mm of #1] (#1-top) {#2};% \sf@regall{#1-top}} % --------------------------------------------------------------- grouping --- \pgfkeys{ /sf/group/.cd, role/.store in=\sf@role, role=neutral, } % Count an explicit fit list like {(a)(b)}. A trailing empty () is the sentinel. \def\sf@countmembers#1{\sf@nmem=0 \sf@eatmem#1()\relax} \def\sf@eatmem(#1)#2\relax{% \if\relax\detokenize{#1}\relax \else \advance\sf@nmem by 1 \sf@eatmem#2\relax \fi} % \sfgroup[role=...]{name}{(member1)(member2)}{caption} % The fit list is explicit so the group can wrap any placed nodes. The outline % names one composite object, never decoration. An overlay caption is optional; % prefer \sfcaption{name}{...}{...} so the label sits on the caption lane. \newcommand{\sfgroup}[4][]{% \begingroup \pgfkeys{/sf/group/.cd,#1}% \sfcheckrole{\sf@role}% \sf@countmembers{#3}% \ifnum\sf@nmem<2 \PackageWarning{superfig}{Group `#2' wraps a single object. Bind at least two members; an outline around one node is decoration}% \fi \edef\sf@col{\sfrole{\sf@role}}% \node[draw=\sf@col!65, line width=0.6pt, rounded corners=3pt, inner sep=6pt, fit={#3}] (#2) {};% \ifblank{#4}{}{% \node[anchor=north west, font=\scriptsize\bfseries, text=\sf@col!85!black, inner sep=1pt] at ([yshift=2pt]#2.north west) {#4};}% \sf@regall{#2}\sf@lower{#2}% % Expand the last row's fit so \sflane{row} hangs captions below the outline. \ifdefempty{\sf@rowname}{}{% \edef\sf@do{\noexpand\node[inner sep=0pt, outer sep=0pt, fit={(\sf@rowname)(#2)}] (\sf@rowname) {};}\sf@do \sf@lower{\sf@rowname}}% \endgroup} % --------------------------------------------------------------- callout --- \newcommand{\sfcallout}[5]{% \iffig@inrow \PackageError{superfig}{\string\sfcallout\space inside an open \string\sfrow}% {A callout is an aside, not an object. Close the band first.}% \fi \ifcsname sf@isrow@#3\endcsname\else \PackageWarning{superfig}{Callout `#1' is anchored to `#3', which is not a \string\sfrow\space band. A card hanging off a single object reads as a step}% \fi \global\advance\sf@ncallouts by 1 \ifnum\sf@ncallouts>1 \PackageWarning{superfig}{Callout `#1' is the second card in the figure. The budget is one callout per figure}% \fi \node[anchor=north west, draw=black!18, fill=black!3, rounded corners=1.5pt, inner xsep=7pt, inner ysep=6pt, text width=#2] (#1) at ([xshift=\sfgutter]#3.north east) {% \scriptsize\raggedright \ifblank{#4}{}{\textbf{#4}\par\vspace{1.5pt}}% #5\par}; \sftrack{#1}} % ------------------------------------------------------------ meaning box --- \iffig@en \def\sf@lblaxes{Concept}\def\sf@lblobj{Objects}\def\sf@lblmech{Mechanism} \else \def\sf@lblaxes{概念}\def\sf@lblobj{对象}\def\sf@lblmech{机制} \fi \newcommand{\sfsetlabels}[3]{\def\sf@lblaxes{#1}\def\sf@lblobj{#2}\def\sf@lblmech{#3}} \newlength{\sf@raillen} \iffig@en \setlength{\sf@raillen}{5.4em} \else \setlength{\sf@raillen}{3.2em} \fi \newcommand{\sfsetrail}[1]{\sf@raillen=\dimexpr#1\relax} % \sfmeaningbox{name}{total width}{anchor}{idea}{objects}{mechanism} % One low-contrast reading box. Pass {} to drop a row. \newcommand{\sfmeaningbox}[6]{% \node[below=4mm of #3, anchor=north, draw=black!18, fill=black!3, rounded corners=1.5pt, inner xsep=9pt, inner ysep=8pt, text width=#2] (#1) {% \small \setlength{\tabcolsep}{0pt}% \renewcommand{\arraystretch}{1.25}% \begin{tabular}{@{}p{\sf@raillen}@{\hspace{7pt}}p{\dimexpr#2-\sf@raillen-7pt\relax}@{}} \ifblank{#4}{}{\textbf{\sf@lblaxes} & \raggedright\arraybackslash #4 \\} \ifblank{#5}{}{\textbf{\sf@lblobj} & \raggedright\arraybackslash #5 \\} \ifblank{#6}{}{\textbf{\sf@lblmech} & \raggedright\arraybackslash #6 \\} \end{tabular}};} % ------------------------------------------------------------- signature ---- \newcommand{\sfsignature}[2]{% \node[below=2.2mm of #2, font=\scriptsize, text=black!45] (#2-sig) {#1};} \endinput