feat: add superfig paper-figure toolkit

Standalone LaTeX/TikZ skill for non-tensor paper figures: node/edge
macros, lint-on-warning build, golden examples, and negative fixtures.
This commit is contained in:
dela
2026-08-17 09:40:12 +08:00
commit db5598fbf7
39 changed files with 1732 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
%% 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={<coord>} 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={<coord>} 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