From db5598fbf77d284de7ff92a072ca7506e2296407 Mon Sep 17 00:00:00 2001 From: dela Date: Mon, 17 Aug 2026 09:40:12 +0800 Subject: [PATCH] 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. --- .gitignore | 8 + README.md | 86 +++++ SKILL.md | 82 +++++ agents/openai.yaml | 4 + assets/superfig.sty | 385 +++++++++++++++++++++++ examples/antipatterns.tex | 82 +++++ examples/branch-architecture.tex | 48 +++ examples/dependency-graph.tex | 56 ++++ examples/pipeline.tex | 47 +++ examples/state-flow.tex | 46 +++ references/antipatterns.md | 21 ++ references/api.md | 122 +++++++ references/checklist.md | 40 +++ references/fallback.md | 17 + references/grammar.md | 48 +++ references/layout.md | 52 +++ references/style.md | 52 +++ scripts/build.sh | 65 ++++ scripts/lint.py | 159 ++++++++++ scripts/preflight.sh | 47 +++ scripts/test.sh | 46 +++ tests/flow.tex | 26 ++ tests/group-callout.tex | 32 ++ tests/invalid/callout-anchor.tex | 10 + tests/invalid/callout-budget.tex | 14 + tests/invalid/callout-inrow.tex | 10 + tests/invalid/empty-row.tex | 7 + tests/invalid/group-single.tex | 8 + tests/invalid/overflow-band.tex | 9 + tests/invalid/role-redefinition.tex | 8 + tests/invalid/undeclared-role.tex | 6 + tests/lint-invalid/callout-anchor.tex | 9 + tests/lint-invalid/callout-duplicate.tex | 13 + tests/lint-invalid/formula-order.tex | 9 + tests/lint-invalid/group-single.tex | 7 + tests/lint-invalid/hue-budget.tex | 14 + tests/lint-invalid/raw-tikz.tex | 5 + tests/lint-invalid/role-before.tex | 5 + tests/smoke.tex | 27 ++ 39 files changed, 1732 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 agents/openai.yaml create mode 100644 assets/superfig.sty create mode 100644 examples/antipatterns.tex create mode 100644 examples/branch-architecture.tex create mode 100644 examples/dependency-graph.tex create mode 100644 examples/pipeline.tex create mode 100644 examples/state-flow.tex create mode 100644 references/antipatterns.md create mode 100644 references/api.md create mode 100644 references/checklist.md create mode 100644 references/fallback.md create mode 100644 references/grammar.md create mode 100644 references/layout.md create mode 100644 references/style.md create mode 100755 scripts/build.sh create mode 100755 scripts/lint.py create mode 100755 scripts/preflight.sh create mode 100755 scripts/test.sh create mode 100644 tests/flow.tex create mode 100644 tests/group-callout.tex create mode 100644 tests/invalid/callout-anchor.tex create mode 100644 tests/invalid/callout-budget.tex create mode 100644 tests/invalid/callout-inrow.tex create mode 100644 tests/invalid/empty-row.tex create mode 100644 tests/invalid/group-single.tex create mode 100644 tests/invalid/overflow-band.tex create mode 100644 tests/invalid/role-redefinition.tex create mode 100644 tests/invalid/undeclared-role.tex create mode 100644 tests/lint-invalid/callout-anchor.tex create mode 100644 tests/lint-invalid/callout-duplicate.tex create mode 100644 tests/lint-invalid/formula-order.tex create mode 100644 tests/lint-invalid/group-single.tex create mode 100644 tests/lint-invalid/hue-budget.tex create mode 100644 tests/lint-invalid/raw-tikz.tex create mode 100644 tests/lint-invalid/role-before.tex create mode 100644 tests/smoke.tex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ea29e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +out/ +*.aux +*.log +*.out +*.fls +*.fdb_latexmk +*.synctex.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcc62c7 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# superfig + +A paper-figure toolkit for non-tensor diagrams: a LaTeX/TikZ macro package, a +build pipeline that fails on silent corruption, worked examples, and an agent +skill that ties them together. + +It is the generic-node sibling of `supertensor`. Same house style, same +role/cursor/warning discipline. The primitives are nodes, edges and groups, +not tensor faces. Use `supertensor` when axis lengths and shape identities +are the claim. + +This repository is a **child** of SuperPaper, the family parent that +routes paper notes to the right figure toolkit. Clone SuperPaper with +`--recurse-submodules` for the whole family; use this directory alone +when you only need node/edge figures. + +``` +SKILL.md the skill entry point (lean; loads references on demand) +references/ grammar, layout, style, api, checklist, antipatterns +assets/superfig.sty the macro package +scripts/preflight.sh is the TikZ + CJK path available? +scripts/lint.py reject source-level invariant escapes +scripts/build.sh lint, compile, audit the log, export pdf/svg/png/thumb +scripts/test.sh positive examples plus negative package/lint fixtures +examples/ four golden examples + an anti-pattern gallery +``` + +## Quick start + +```bash +./scripts/preflight.sh # 0 = full path, 1 = degraded, 2 = no LaTeX +./scripts/build.sh examples/pipeline.tex # -> examples/build/pipeline.{pdf,svg,png} +./scripts/test.sh +``` + +A minimal figure: + +```tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{input}{sfTeal} +\sfsetrole{model}{sfOrange} + +\begin{document}\begin{tikzpicture} + \sfstage{S}{one band, placed by cursor} + \sfrow{R1}{14mm} + \sfnode[role=input]{x}{输入 $x$}{16mm}{12mm} + \sfconn{e1}{预处理} + \sfnode[role=model]{f}{模型 $f_\theta$}{18mm}{12mm} + \sfrowend + \sflane{R1} + \sfcaption{x}{$x$}{原始输入} + \sfcaption{f}{$f_\theta$}{可学习参数} +\end{tikzpicture}\end{document} +``` + +See `references/api.md` for the full macro list. + +## Examples + +| file | shows | +|---|---| +| `pipeline.tex` | cursor flow; loss as a side object, not a station on the forward path | +| `branch-architecture.tex` | residual skip; a group captioned on the shared lane | +| `state-flow.tex` | time/state step; one `\sfcallout` on a finished band | +| `dependency-graph.tex` | dual-encoder join, fusion placed from existing anchors | +| `antipatterns.tex` | four figures that compile cleanly and still teach something false | + +## Using it as an agent skill + +`SKILL.md` is the entry point; the `references/` files are loaded on demand. +Point your agent runtime at this directory. The skill assumes `scripts/` and +`assets/` sit beside it. + +## Why the build script fails on warnings + +`Missing character` drops a CJK glyph silently. `Overfull \hbox` lets a label +escape its lane. `Package superfig Warning` is an undeclared role, a +single-member group, a band overflow, a second callout, or a callout hanging +off a node. `build.sh` greps for all of them and exits non-zero. + +A clean build also proves the source avoided raw TikZ drawing, a formula +placed before the last row, and more than four active hues. It still cannot +prove that the relationships are right; that is what `references/checklist.md` +is for. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4f45495 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,82 @@ +--- +name: superfig +description: Create or refine clean paper-style figures for non-tensor concepts — architecture and block diagrams, pipelines and data flow, state/time flows, dependency graphs, and conceptual mechanism diagrams. Use whenever a paper idea should be explained with clear nodes, edges, grouping, captions, and a short meaning box in a muted slide-ready style. Not for tensor/matrix/shape semantics (use supertensor) or plotting numeric data. +--- + +# superfig + +Turn one paper claim or mechanism into one dense, slide-ready figure with three zones: + +1. **Top — idea.** The compact claim or formula. Optional; call it after the drawing. +2. **Middle — structure.** Semantic nodes, directed edges, grouped composites, and captions. +3. **Bottom — meaning.** What the idea is, what the objects are, what the mechanism does. + +`assets/superfig.sty` enforces the house style and the common cursor/role discipline. +Draw with `\sfnode` / `\sfconn` / `\sfarrow` / `\sfgroup`; do not hand-roll TikZ rectangles +and arrows unless the linter explicitly allows it. + +## When to use + +- Architecture, block diagram, pipeline, data flow, dependency graph, state or time flow. +- A conceptual mechanism that is not a tensor shape: nodes, arrows, containment, lanes. +- A figure that should look like `supertensor` in palette and text hierarchy. + +Do **not** use for tensor faces, axes, contractions, sharding or broadcasting; route those to +`supertensor`. Do not use for loss curves, benchmark bars, scatter plots, or dashboards. + +## Workflow + +1. **Preflight.** `./scripts/preflight.sh`. Exit 0 = TikZ+CJK path. Exit 1 = degraded + (say so). Exit 2 = no LaTeX; read `references/fallback.md` and name the lost guarantees. +2. **Reduce** the paper to one primary claim. Drop parallel objectives, optional modules, + diagnostics and secondary paths unless the user asks for them. +3. **Build a semantics ledger** before drawing: + - *nodes* — each block is one semantic object: kind, domain/range, owner, lifecycle. + - *edges* — each arrow is data flow, control flow, dependency, or causality; never decoration. + - *groups* — an outline only when it names a real composite object. + See `references/grammar.md`. +4. **Choose the smallest grammar** that exposes the mechanism: + - one object = `\sfnode[role=..., level=...]{name}{label}{width}{height}` + - horizontal flow = `\sfstage` + `\sfrow` … `\sfrowend` + - horizontal connector with label = `\sfconn{name}{label}` + - fixed/branching edge = `\sfarrow[options]{from}{to}` or `\sfarrowlabel` + - composite = `\sfgroup[role=...]{name}{(member1)(member2)}{}` then `\sfcaption` + - a whole-band aside = `\sfcallout` + - bottom explanation = `\sfmeaningbox` + Full macro list: `references/api.md`. Worked figures: `examples/`. +5. **Build and audit.** `./scripts/build.sh fig.tex` runs lint, TeX checks, and exports. + Then run `references/checklist.md` against the PNG full-size and thumbnail. Redraw on + any mandatory violation. + +## Non-negotiables + +- **Semantics** — one node, one object. An edge says one relationship. A group is a real + composite, not a decorative border. +- **Layout** — use the cursor in flow rows; use `at={}` only when the topology is + genuinely non-linear. Never hand-tune a neighbor against a magic offset. +- **Style** — muted palette, one hue per role, at most four active hues plus gray, + three separated lightness levels, no saturated primaries, no dashboard clutter. +- **Build** — `Missing character`, overfull/underfull boxes, and `Package superfig Warning` + are build failures, not cosmetic warnings. + +`references/antipatterns.md` shows the common ways a clean compile still tells the reader +something false. + +## Output + +Default to editable TikZ. `scripts/build.sh` emits PDF, SVG, white-background PNG, +transparent PNG and a 360 px thumbnail. Deliver the PNG preview, a one-paragraph mechanism +explanation, and the `.tex` source plus vector artifact. + +- **Chinese figures:** `\usepackage[cjk]{superfig}` (XeLaTeX + portable Fandol). Keep + standard English terms where natural (`softmax`, `logits`, `gather`). +- **English figures:** `\usepackage[en]{superfig}` — same geometry, English meaning-box rails. +- Keep math in LaTeX, not raw Unicode. +- Add `\sfsignature{}{}` only when the user or house template asks for it. + +## Iterating + +When the user asks for a change, do not restart the figure. Edit the role/ledger or the one +macro call that owns the offending object, rebuild, and re-audit. If a fix requires shrinking +type, closing the gutter, or covering another object, move the stage to another row instead. +Ask before dropping an object or edge the user named. diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..39981a3 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Superfig" + short_description: "Create clean paper-style architecture and flow figures" + default_prompt: "Use $superfig to turn this paper idea into an editable figure with nodes, edges and a meaning box." diff --git a/assets/superfig.sty b/assets/superfig.sty new file mode 100644 index 0000000..a197546 --- /dev/null +++ b/assets/superfig.sty @@ -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={} 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 diff --git a/examples/antipatterns.tex b/examples/antipatterns.tex new file mode 100644 index 0000000..2879029 --- /dev/null +++ b/examples/antipatterns.tex @@ -0,0 +1,82 @@ +% Anti-pattern gallery -- four figures that compile cleanly and still teach +% the reader something false. Each pair is wrong / right. +% ../scripts/build.sh antipatterns.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{a}{sfTeal} +\sfsetrole{b}{sfOrange} +\sfsetrole{c}{sfCoral} +\sfsetrole{d}{sfViolet} + +\newcommand{\bad}[1]{{\color{sfCoral}$\times$}\;#1} +\newcommand{\good}[1]{{\color{sfTeal}$\checkmark$}\;#1} + +\begin{document} +\begin{tikzpicture} + +% (1) A decorative arrow vs a data-flow edge. +\sfnode[role=a, at={(0,0)}]{A1}{编码器}{16mm}{10mm} +\sfnode[role=b, at={(32mm,0)}]{A2}{指标卡}{16mm}{10mm} +\sfarrow{A1}{A2} +\sfnode[role=a, at={(64mm,0)}]{A3}{编码器}{16mm}{10mm} +\sfnode[role=b, at={(96mm,0)}]{A4}{解码器}{16mm}{10mm} +\sfarrowlabel{A3.east}{A4.west}{隐状态} + +\node[sf stage, anchor=south west] at ($(A1.north west)+(0,6mm)$) + {(1) 装饰箭头}; +\node[sf shape, anchor=north] (A1c) at ($(A1.south)!0.5!(A2.south)+(0,-3mm)$) + {\bad{箭头不承载关系}}; +\node[sf shape, anchor=north] (A3c) at ($(A3.south)!0.5!(A4.south)+(0,-3mm)$) + {\good{隐状态从编码流向解码}}; + +% (2) A group that is only a visual border vs a real composite. +\sfnode[role=a, at={(0,-32mm)}]{B1}{输入}{14mm}{10mm} +\sfnode[role=d, at={(24mm,-32mm)}]{B2}{输出}{14mm}{10mm} +\sfgroup[role=a]{Bg}{(B1)(B2)}{} +\sfnode[role=b, at={(56mm,-32mm)}]{B3}{注意力}{16mm}{10mm} +\sfnode[role=b, at={(82mm,-32mm)}]{B4}{前馈}{14mm}{10mm} +\sfgroup[role=b]{Bb}{(B3)(B4)}{} +\sfarrow{B3.east}{B4.west} + +\node[sf stage, anchor=south west] at ($(B1.north west)+(0,8mm)$) + {(2) 组框只是装饰边}; +\node[sf shape, anchor=north] (B1c) at ($(B1.south)!0.5!(B2.south)+(0,-7mm)$) + {\bad{首尾框在一起,不是一个模块}}; +\node[sf shape, anchor=north] (B3c) at ($(B3.south)!0.5!(B4.south)+(0,-7mm)$) + {\good{主路模块是真实复合对象}}; + +% (3) One node doing two jobs vs a split. +\sfnode[role=b, at={(0,-68mm)}]{C1}{注意力 + LN + 残差}{38mm}{12mm} +\sfnode[role=b, at={(56mm,-68mm)}]{C2}{注意力}{16mm}{10mm} +\sfnode[role=c, at={(84mm,-68mm)}]{C3}{残差加}{16mm}{10mm} +\sfarrow{C2.east}{C3.west} + +\node[sf stage, anchor=south west] at ($(C1.north west)+(0,6mm)$) + {(3) 一个节点做两件事}; +\node[sf shape, anchor=north] (C1c) at ($(C1.south)+(0,-3mm)$) + {\bad{机制被一口吞掉}}; +\node[sf shape, anchor=north] (C2c) at ($(C2.south)!0.5!(C3.south)+(0,-3mm)$) + {\good{一步一个对象}}; + +% (4) A new hue for the same object vs one role, two levels. +\sfnode[role=a, level=2, at={(0,-100mm)}]{D1}{$x$}{14mm}{10mm} +\sfnode[role=c, level=2, at={(24mm,-100mm)}]{D2}{$x$ 归一化}{20mm}{10mm} +\sfnode[role=a, level=1, at={(64mm,-100mm)}]{D3}{$x$}{14mm}{10mm} +\sfnode[role=a, level=3, at={(90mm,-100mm)}]{D4}{$x$ 归一化}{20mm}{10mm} + +\node[sf stage, anchor=south west] at ($(D1.north west)+(0,6mm)$) + {(4) 同一对象换了色相}; +\node[sf shape, anchor=north] (D1c) at ($(D1.south)!0.5!(D2.south)+(0,-3mm)$) + {\bad{换色相像换了对象}}; +\node[sf shape, anchor=north] (D3c) at ($(D3.south)!0.5!(D4.south)+(0,-3mm)$) + {\good{同一角色,深浅分层}}; + +\node[inner sep=0pt, fit=(A1)(A4)(D1)(D4)(A1c)(D3c)(Bg)(Bb)] (all) {}; +\sfmeaningbox{mb}{118mm}{all} + {} + {四张错图都能干净编译。编译器不检查图讲的事情对不对} + {交付前按 \texttt{references/checklist.md} 做一次人眼审图} + +\end{tikzpicture} +\end{document} diff --git a/examples/branch-architecture.tex b/examples/branch-architecture.tex new file mode 100644 index 0000000..9b30903 --- /dev/null +++ b/examples/branch-architecture.tex @@ -0,0 +1,48 @@ +% superfig golden example 2 -- a small branchy architecture, not a tensor +% shape diagram. Main path uses the cursor; the residual is a skip edge; +% the group is captioned on the shared lane, not as an overlay. +% ../scripts/build.sh branch-architecture.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{enc}{sfTeal} +\sfsetrole{attn}{sfOrange} +\sfsetrole{ffn}{sfViolet} +\sfsetrole{out}{sfCoral} + +\begin{document} +\begin{tikzpicture} + +\sfstage{S}{双分支结构:主路 + 残差} +\sfvgap{7mm} +\sfrow{R1}{12mm} + \sfnode[role=enc]{in}{输入}{16mm}{10mm} + \sfconn{e1}{} + \sfnode[role=attn]{attn}{注意力}{18mm}{10mm} + \sfconn{e2}{特征} + \sfnode[role=ffn]{ffn}{前馈}{18mm}{10mm} + \sfgap{\sflinklen} + \sfnode[role=enc]{add}{融合}{14mm}{10mm} + \sfconn{e4}{} + \sfnode[role=out]{out}{输出}{14mm}{10mm} +\sfrowend + +% No overlay caption: it would sit on the residual. Caption the group below. +\sfgroup[role=attn]{block}{(attn)(ffn)}{} +\sfarrow{block.east}{add.west} +\sfarrowlabel[bend left=18]{in.north}{add.north}{残差} + +\sflane{R1} +\sfcaption{in}{输入}{源数据} +\sfcaption{block}{主路模块}{注意力 + 前馈} +\sfcaption{add}{融合}{残差相加} +\sfcaption{out}{输出}{目标表示} + +\sfbbox{all} +\sfmeaningbox{mb}{108mm}{all} + {主路逐层处理,残差边跳过主路} + {输入、主路模块、融合节点、输出} + {残差把输入直接送入融合,减轻深层优化负担} + +\end{tikzpicture} +\end{document} diff --git a/examples/dependency-graph.tex b/examples/dependency-graph.tex new file mode 100644 index 0000000..921c0e6 --- /dev/null +++ b/examples/dependency-graph.tex @@ -0,0 +1,56 @@ +% superfig golden example 4 -- a dual-encoder dependency, not a linear pipeline. +% Two encoder rows share a rail; fusion is placed from their east anchors. +% ../scripts/build.sh dependency-graph.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{text}{sfTeal} +\sfsetrole{img}{sfOrange} +\sfsetrole{fuse}{sfViolet} +\sfsetrole{out}{sfCoral} + +\begin{document} +\begin{tikzpicture} + +\sfstage{S1}{双塔编码} +\sfrow{R1}{12mm} + \sfnode[role=text]{txt}{文本}{14mm}{10mm} + \sfconn{e1}{} + \sfnode[role=text]{te}{文本编码}{18mm}{10mm} +\sfrowend +\sflane{R1} +\sfcaption{txt}{text}{token 序列} +\sfcaption{te}{$E_t$}{文本向量} + +\sfrow{R2}{12mm} + \sfnode[role=img]{img}{图像}{14mm}{10mm} + \sfconn{e2}{} + \sfnode[role=img]{ve}{视觉编码}{18mm}{10mm} +\sfrowend +\sflane{R2} +\sfcaption{img}{image}{视觉输入} +\sfcaption{ve}{$E_v$}{视觉向量} + +% Fusion sits to the right of the two encoders, on their shared east line. +\sfnode[role=fuse, at={($(te.east)!0.5!(ve.east)+(20mm,0)$)}]{fu}{融合}{16mm}{10mm} +\sfnode[role=out, at={($(fu.east)+(16mm,0)$)}]{dec}{解码}{16mm}{10mm} +\sfnode[role=out, at={($(dec.east)+(16mm,0)$)}]{out}{输出}{14mm}{10mm} +\sfarrow{te.east}{fu.west} +\sfarrow{ve.east}{fu.west} +\sfarrow{fu.east}{dec.west} +\sfarrow{dec.east}{out.west} +\sfnolane +\sfcaption{fu}{fuse}{晚融合} +\sfcaption{dec}{decode}{条件生成} +\sfcaption{out}{out}{目标文本} + +\sfbbox{all} +\sftopformula{F}{% + $y = \mathrm{Dec}\bigl(\mathrm{Fuse}(E_t(x),\, E_v(I))\bigr)$} +\sfmeaningbox{mb}{112mm}{all} + {两个编码器互不共享权重,只在融合节点会合} + {文本塔、视觉塔、融合、解码} + {各塔独立编码;融合后才进入解码,所以任一侧的依赖都经过融合节点} + +\end{tikzpicture} +\end{document} diff --git a/examples/pipeline.tex b/examples/pipeline.tex new file mode 100644 index 0000000..955b096 --- /dev/null +++ b/examples/pipeline.tex @@ -0,0 +1,47 @@ +% superfig golden example 1 -- one horizontal paper-figure pipeline. +% Main path is input -> model -> prediction. Loss is a side object, not a +% station on the forward path. +% ../scripts/build.sh pipeline.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{input}{sfTeal} +\sfsetrole{model}{sfOrange} +\sfsetrole{loss}{sfCoral} +\sfsetrole{output}{sfViolet} + +\begin{document} +\begin{tikzpicture} + +\sfstage{SA}{推理流程:一次前向} +\sfrow{R1}{16mm} + \sfnode[role=input]{x}{输入 $x$}{16mm}{12mm} + \sfconn{e1}{预处理} + \sfnode[role=model]{f}{模型 $f_\theta$}{18mm}{12mm} + \sfconn{e2}{logits} + \sfnode[role=output]{y}{预测 $\hat y$}{16mm}{12mm} +\sfrowend + +% Loss compares the prediction with the target; it is not on the main path. +% Hang it below ŷ with enough shaft that no caption sits on the arrow. +\sfnode[role=loss, at={($(y.south)+(0,-22mm)$)}]{s}{损失 $L$}{14mm}{12mm} +\sfarrowlabel{y.south}{s.north}{$L(\hat y,y)$} + +\sflane{R1} +\sfcaption{x}{$x$}{原始输入} +\sfcaption{f}{$f_\theta$}{可学习参数} +\sfnolane +\sfcaption{s}{$L$}{与真值比较} + +\sfbbox{all} +\sftopformula{F}{% + $x \;\xrightarrow{\;f_\theta\;}\; \hat y,\qquad + \min_\theta\; L\bigl(f_\theta(x),\,y\bigr)$} +\sfmeaningbox{mb}{96mm}{all} + {一次从输入到预测的前向;损失在预测之后单独计算} + {数据、模型参数、预测、损失} + {预处理后送入模型;模型产生 logits 得到预测;损失比较预测与真值,梯度再回到参数} +\sfsignature{推理流程示意}{mb} + +\end{tikzpicture} +\end{document} diff --git a/examples/state-flow.tex b/examples/state-flow.tex new file mode 100644 index 0000000..3ed00bd --- /dev/null +++ b/examples/state-flow.tex @@ -0,0 +1,46 @@ +% superfig golden example 3 -- a state/time flow. +% One decode step: previous KV plus a new token produce the next state +% and the emitted token. The callout is an aside on the finished state band. +% ../scripts/build.sh state-flow.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} + +\sfsetrole{state}{sfTeal} +\sfsetrole{step}{sfOrange} +\sfsetrole{tok}{sfViolet} + +\begin{document} +\begin{tikzpicture} + +\sfstage{S}{解码一步:旧状态 + 新 token} +\sfvgap{26mm} +\sfrow{R1}{14mm} + \sfnode[role=state]{kv}{KV$_t$}{16mm}{11mm} + \sfconn{c1}{读} + \sfnode[role=step]{dec}{解码}{16mm}{11mm} + \sfconn{c2}{追加} + \sfnode[role=state]{kv2}{KV$_{t+1}$}{18mm}{11mm} +\sfrowend +\sflane{R1} +\sfcaption{kv}{KV$_t$}{已缓存键值} +\sfcaption{dec}{一步}{读 KV,写新列} +\sfcaption{kv2}{KV$_{t+1}$}{状态推进} +\sfcallout{N1}{38mm}{R1}{为何保留 KV}{% + 下一步只读已有列、只追加 $x_t$ 这一列。重算前缀是另一条路径,这张图不画。} + +% Tokens enter from above so the caption lane below the state row stays clear. +\sfnode[role=tok, at={($(dec.north west)+(-4mm,14mm)$)}]{xt}{$x_t$}{14mm}{10mm} +\sfnode[role=tok, at={($(dec.north east)+(4mm,14mm)$)}]{yt}{$y_t$}{14mm}{10mm} +\sfarrow{xt.south}{dec.north} +\sfarrow{dec.north}{yt.south} + +\sfbbox{all} +\sftopformula{F}{% + $(\mathrm{KV}_{t+1},\, y_t) = \mathrm{Decode}(\mathrm{KV}_t,\, x_t)$} +\sfmeaningbox{mb}{108mm}{all} + {自回归一步:状态沿时间推进} + {缓存 KV、解码算子、本步 token} + {解码读 $\mathrm{KV}_t$ 与 $x_t$,写出 $y_t$,并把新列追加为 $\mathrm{KV}_{t+1}$} + +\end{tikzpicture} +\end{document} diff --git a/references/antipatterns.md b/references/antipatterns.md new file mode 100644 index 0000000..81d5d38 --- /dev/null +++ b/references/antipatterns.md @@ -0,0 +1,21 @@ +# Anti-patterns + +Each of these can compile cleanly and still teach the reader something false. +Rendered pairs: `examples/antipatterns.tex`. + +- **A decorative arrow.** The arrow has no data/control/dependency meaning. Remove it or + make the relationship explicit. +- **A group that is only a visual border.** The outline does not correspond to a real + module/composite in the paper. +- **One node doing two jobs.** "attention + layer norm + residual" in one block hides the + mechanism the figure was meant to explain. +- **A new hue for a slightly different view of the same object.** Reuse the role or use + lightness, not a fresh color family. +- **A caption repeated in the meaning box.** Captions name the objects; the meaning box + explains what they do and why. +- **Solving crowding by shrinking type.** The type hierarchy is a hard floor. Move the + stage to another row or split the figure. +- **Magic offsets.** Every hand-tuned coordinate is valid only for the current label. The + next edit will land a longer label on a neighbor. +- **A floating commentary card between objects.** If it is not an object or edge, it belongs + in the stage subtitle, meaning box, or one `\sfcallout` beside a finished band. diff --git a/references/api.md b/references/api.md new file mode 100644 index 0000000..8abc460 --- /dev/null +++ b/references/api.md @@ -0,0 +1,122 @@ +# superfig.sty API + +```tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superfig} % cjk: ctex + fandol (XeLaTeX). en: English rail labels. +``` + +Build with `./scripts/build.sh fig.tex` — it puts `assets/` on `TEXINPUTS`. + +The build first runs `scripts/lint.py`. It rejects ledger changes, more than four +active hues, raw `\draw`/`\fill`/`\path`, a formula placed before the last row, a +callout that is not an aside on a `\sfrow` band, a second callout, and a group +with fewer than two members. Intentional galleries may put +`% superfig-lint: allow-raw-tikz, allow-multiple-callouts` near the top; do not +add an exemption to a deliverable merely to make it pass. + +Rules for *when* to use each primitive live in `grammar.md` and `layout.md`. + +## Ledgers + +```tex +\sfsetrole{input}{sfTeal} % role -> color. Macros take a ROLE, never a color. +\sfsetlabels{A}{O}{M} % override the three meaning-box rail labels +\sfsetrail{5.4em} % width of the bold label rail (`[en]` defaults wider) +``` + +Colors: `sfTeal sfOrange sfCoral sfViolet sfGray sfInk`. An unknown role falls +back to gray **and emits a package warning**, which `build.sh` turns into a +failed build. Redeclaring a role with a different color warns and keeps the +original mapping. + +Lengths: `\sfgutter` (6 mm), `\sfrowgap` (3.5 mm), `\sfblockgap` (9 mm), +`\sflinklen` (10 mm). + +## Flow layout + +Empty `at` places the object at the cursor. Hand-written offsets are for +genuinely non-linear topology. + +```tex +\sfstage{SA}{stage heading} +\sfrow{R1}{16mm} + \sfnode[role=input]{x}{输入 $x$}{16mm}{12mm} + \sfconn{e1}{预处理} + \sfnode[role=model]{f}{模型 $f_\theta$}{18mm}{12mm} +\sfrowend +\sflane{R1} +\sfcaption{x}{$x$}{原始输入} +``` + +| macro | does | +|---|---| +| `\sfstage{name}{text}` | stage heading on the left rail, below all ink so far | +| `\sfrow{name}{height}` | open a band; `height` is a length | +| `\sfrowend` | `fit` the band into `name` | +| `\sfnode[keys]{name}{label}{w}{h}` | one semantic object | +| `\sfop{name}{glyph}` | operator in the flow; reserves its own width | +| `\sfconn{name}{label}` | connector; empty label reserves `\sflinklen` of bare arrow | +| `\sfgap{4mm}` / `\sfvgap{4mm}` | extra space, horizontal / vertical | +| `\sfleftrail{x}` | move the left rail | +| `\sfbbox{all}` | everything drawn so far, as one node | +| `\sftopformula{F}{math}` | claim/formula, centered on what was actually drawn | +| `\sftrack{node}` | fold a hand-placed TikZ node into the bbox and vertical cursor | +| `\sflayoutreset` | start over | + +`\sfnode` keys: `role=` (default `neutral`), `level=` (`1/2/3` → `!30/!55/!80`), +`gap=` (space *before* this object in a row), `bracket=` (matrix-style arms), +`at={}` (empty = cursor; a coordinate bypasses the row). + +Call `\sftopformula` **after** the last `\sfrowend`. A formula placed first is +centered on a figure whose width is not yet known. + +## Fixed topology + +```tex +\sfnode[role=enc, at={(0,-22mm)}]{in}{输入}{16mm}{10mm} +\sfarrow{in.east}{attn.west} +\sfarrowlabel[bend left=18]{in.north}{add.north}{残差} +``` + +`\sfarrow` / `\sfarrowlabel` take the same `to[]` options as TikZ (`bend left`, +`out=south, in=north`). The label uses `auto` so a vertical edge does not sit +the text on the shaft. Route is on the background layer. + +Keep `at=` on a coarse grid or derive it from existing anchors +(`at={($(te.east)!0.5!(ve.east)+(20mm,0)$)}`). + +## Groups, captions, callouts + +```tex +\sfgroup[role=attn]{block}{(attn)(ffn)}{} % empty overlay; caption the group +\sflane{R1} +\sfcaption{block}{主路模块}{注意力 + 前馈} +\sfcallout{N1}{38mm}{R1}{title}{body} +``` + +- `\sfgroup` needs at least two members in the fit list. A single-node outline + is a package warning. After a row, the group refits that row so `\sflane{R1}` + hangs captions below the outline. +- Prefer an empty overlay caption and `\sfcaption{group}{...}{...}`. An overlay + sits on the north-west corner and collides with skip edges. +- `\sfcallout{name}{width}{band}{title}{body}` hangs off a **finished** `\sfrow` + band, one per figure. Inside an open row it is a package error; a non-band + anchor or a second card is a warning. +- `\sfcaptiontop{name}{text}` is the occasional label above a node. +- `\sfnolane` turns the shared caption baseline off. + +## Bottom + +```tex +\sfbbox{all} +\sfmeaningbox{mb}{96mm}{all}{idea}{objects}{mechanism} +\sfsignature{subject}{mb} % optional; subject only +``` + +Arg 2 is the total box width. An empty `{}` row is dropped. `\usepackage[en]` +switches the rails to Concept / Objects / Mechanism. + +## Lint exemptions + +`% superfig-lint: allow-raw-tikz, allow-multiple-callouts, allow-single-group` +near the top of the source. Deliverables normally have none. diff --git a/references/checklist.md b/references/checklist.md new file mode 100644 index 0000000..64c9319 --- /dev/null +++ b/references/checklist.md @@ -0,0 +1,40 @@ +# Delivery checklist + +A clean build only proves TeX and package invariants. Inspect the figure before delivery. + +## Semantics + +- [ ] Every node is one object with one role. +- [ ] Every edge is data flow, control flow, dependency, or causality. +- [ ] Every group names a real composite, binds at least two members, and all members belong to it. +- [ ] At most one `\sfcallout`, hanging off a finished band, not off a single node. +- [ ] Captions explain objects or relationships, not decoration. + +## Layout + +- [ ] No node covers another node, caption, edge label, or group border. +- [ ] Flow rows start on the same left rail. +- [ ] Edge labels sit on their own edge and do not cover unrelated objects. +- [ ] Fixed coordinates are on a coarse grid, not tuned against one label width. +- [ ] Captions in a row share one baseline. +- [ ] Meaning box is one column, at most three rows, no overflow. + +## Style + +- [ ] One role keeps one hue across the whole figure. +- [ ] At most four active hue families plus gray. +- [ ] Three visibly separated lightness levels where objects need hierarchy; no uniform pastel. +- [ ] Borders are neutral; group outlines carry their members' role hue. +- [ ] No saturated primaries, shadows, banners, or decorative cards. + +## Thumbnail + +- [ ] Open `*-thumb.png` (360 px). The main claim is still legible. +- [ ] Hues remain distinct and muted; text has not collapsed into gray mush. + +## Delivery + +- [ ] PNG preview shown. +- [ ] One-paragraph mechanism explanation in prose. +- [ ] `.tex` source and PDF/SVG linked. +- [ ] Any degraded path or inferred relationship stated explicitly. diff --git a/references/fallback.md b/references/fallback.md new file mode 100644 index 0000000..eb467a7 --- /dev/null +++ b/references/fallback.md @@ -0,0 +1,17 @@ +# Fallback without LaTeX + +Use this path only when `scripts/preflight.sh` exits 2. State explicitly that the +`superfig.sty` guarantees — role registry, cursor layout, package warnings, and the build +pipeline — are unavailable. + +## Preserve manually + +1. Build the semantics ledger before drawing. +2. One hue per role; at most four hues plus gray; three lightness levels. +3. Every node is one object; every edge has one relationship; groups are real composites. +4. Derive placement from previous bounding boxes plus one gutter constant, not per-label + tuning. +5. Export SVG and PNG, then inspect full-size and at 360 px using `checklist.md`. + +Prefer SVG for editability. Do not imitate package compliance in the delivery: name the +fallback renderer and list any inferred relationship or reduced guarantee. diff --git a/references/grammar.md b/references/grammar.md new file mode 100644 index 0000000..0a2090a --- /dev/null +++ b/references/grammar.md @@ -0,0 +1,48 @@ +# Visual grammar + +Choose the smallest grammar that exposes the paper's mechanism. Adding a second grammar +must add information. + +| Fact to expose | Grammar | +|---|---| +| one semantic object | `\sfnode` | +| linear sequence inside one band | `\sfstage` + `\sfrow` … `\sfrowend` | +| an edge between two objects | `\sfconn` for flow, `\sfarrow` for fixed topology | +| an edge label | `\sfconn{name}{label}` or `\sfarrowlabel` | +| a real composite (module, subsystem, shared owner) | `\sfgroup` | +| an operator inside a flow row | `\sfop` | +| a side note about a finished band | `\sfcallout` | +| the bottom explanation | `\sfmeaningbox` | + +## Node semantics + +One block is one semantic object. If you need two verbs in one block, split it. + +- Label is the object's name or role, not a sentence. +- Fill color encodes a role, not importance. +- Width/height may differ to reflect a visual hierarchy, but do not use size to invent + quantitative meaning unless the figure says so. + +## Edge semantics + +Every arrow must be one of: + +- **data flow** — the output of A becomes the input of B. +- **control flow** — A decides whether or when B runs. +- **dependency** — B needs A to exist or to have run. +- **causality** — A causes B. + +A decorative arrow is an error. If an edge does not carry one of those meanings, remove it +or replace it with a grouping/caption. + +## Group semantics + +`\sfgroup` names a composite object. The members inside must actually belong to that +composite in the paper. Do not draw an outline around nearby nodes merely because it looks +balanced. The fit list is explicit: `{(node1)(node2)}`. + +## Captions and meaning box + +- `\sfcaption{name}{symbol}{detail}`: one short symbol line plus one muted detail line. +- `\sfmeaningbox`: at most three rows — idea, objects, mechanism. Prefer removing content + over shrinking type or adding columns. diff --git a/references/layout.md b/references/layout.md new file mode 100644 index 0000000..354bc16 --- /dev/null +++ b/references/layout.md @@ -0,0 +1,52 @@ +# Layout rules + +## Cursor flow + +Inside `\sfrow` … `\sfrowend`, objects are placed from left to right by the cursor. +The first object starts on the left rail; every later object reserves the standing gutter. + +- Declare the band height once in `\sfrow`. An object that overflows it is a build warning. +- Use `\sfconn` for a labelled edge in the flow; the label reserves its own width. +- Do not add magic-number `xshift`s between flow objects. + +## Fixed topology + +Use `\sfnode[at={(x,y)}]{...}` only when the diagram is genuinely non-linear: branches, +loops, vertical/horizontal stacks, skip edges. + +- Keep coordinates on a coarse grid; prefer multiples of `\sfgutter` or explicit anchors. +- Use `\sfarrow` between placed nodes. Route edges on the background layer. +- Put edge labels on the edge itself, not floating nearby. + +## Captions + +Use `\sflane{row}` after `\sfrowend` to give all captions in that row one shared baseline. +Symbol and detail lines are two reserved lanes; do not place other text between them. + +## Groups + +A group outline adds inner padding, so place it after its members. It must not cover unrelated +nodes. Members must be adjacent in the semantic sense; the explicit fit list prevents the +package from inventing a group around whatever is near. A group must bind at least two +members. + +After a `\sfrow`, `\sfgroup` refits that row so `\sflane{row}` hangs captions below the +outline. Prefer an empty overlay caption and `\sfcaption{group}{...}{...}`: an overlay at +the north-west corner lands on skip edges. A connector that should leave the composite +starts on the group node (`\sfarrow{block.east}{next.west}`), not on a member — otherwise +the shaft crosses an outline that is not its endpoint. + +## Formula + +Call `\sftopformula` after the last `\sfrowend`. The line is centered on `\sfbbox`, whose +width is only known once the bands exist. + +## Callouts + +`\sfcallout` hangs off a finished `\sfrow` band, one per figure. It is not an object in +the flow and must not be anchored to a single node. + +## Meaning box + +The meaning box hangs below the full figure bbox (`\sfbbox`). It is one column, at most +three rows. If it is too long, remove content; do not widen it past the figure or shrink type. diff --git a/references/style.md b/references/style.md new file mode 100644 index 0000000..94feedb --- /dev/null +++ b/references/style.md @@ -0,0 +1,52 @@ +# House style + +The package ships these defaults; this file explains what to preserve and what must still +be decided. + +## Canvas + +White background, natural `standalone` crop. No forced 16:9. No title by default. The top +holds at most two compact claim/formula lines. + +Each stage is one horizontal row with a shared baseline. Use the fewest stages that preserve +the primary claim. No unrelated branches, no dashboard panels. + +## Type hierarchy + +| element | size | macro / style | +|---|---|---| +| top formula/claim | `\large` | `\sftopformula` | +| stage label | `\small\bfseries`, muted | `\sfstage` | +| node label | `\small` | `\sfnode` | +| caption symbol | `\small` | `\sfcaption` | +| caption detail | `\scriptsize`, muted | `\sfcaption` | +| edge label | `\scriptsize`, muted | `\sfconn` / `\sfarrowlabel` | +| meaning box | `\small` | `\sfmeaningbox` | +| optional signature | `\scriptsize`, low contrast | `\sfsignature` | + +Never shrink below this hierarchy to make something fit. Move it to another row instead. + +## Color + +Palette: `sfTeal #4F8FA5`, `sfOrange #EE995B`, `sfCoral #C95B5B`, `sfViolet #8A74B5`, +`sfGray #85898F`. Muted, mid-chroma, paper-like. Do not add saturated primaries. + +- One semantic color per role, held everywhere: `\sfsetrole{input}{sfTeal}`. +- Drawing macros take a **role**, never a color. +- At most four active hue families per figure, plus gray. +- Contrast comes from lightness separation, not saturation. Use `level=1/2/3` for + `role!30 / role!55 / role!80`; do not use uniform pastel fills. +- If sign matters, use hue for sign and intensity for magnitude. + +## Edges and nodes + +- Nodes are rounded rectangles with thin neutral borders; no saturated colored outlines. +- Group outlines use `role!65` because the outline itself names an object. +- Edges are thin neutral arrows on the background layer; text stays on the foreground layer. +- Keep all text in LaTeX or CJK text, never rasterized labels. + +## Never + +Metric insets not present in the primary claim. Decorative pills, banners, shadows, +repeated separators, extra explanatory cards beyond one `\sfcallout`. Raw TikZ `\draw` / +`\fill` / `\path` unless the lint directive allows it. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..144afe5 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Lint, compile, and export superfig deliverables. +# ./scripts/build.sh figure.tex [outdir] +set -euo pipefail + +SRC="${1:?usage: build.sh figure.tex [outdir]}" +[[ -f "$SRC" ]] || { echo "no such file: $SRC" >&2; exit 2; } +SRCDIR="$(cd "$(dirname "$SRC")" && pwd)" +BASE="$(basename "$SRC" .tex)" +OUT="${2:-$SRCDIR/build}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mkdir -p "$OUT" + +if [[ "${SF_SKIP_LINT:-0}" != "1" ]]; then + echo "==> lint $BASE" + python3 "$ROOT/scripts/lint.py" "$SRC" +fi + +echo "==> xelatex $BASE" +TEXINPUTS="$ROOT/assets:$SRCDIR:" \ + xelatex -halt-on-error -interaction=nonstopmode \ + -output-directory="$OUT" "$SRC" >/dev/null 2>&1 \ + || { echo "!! xelatex failed; last errors:" >&2 + grep -n -A4 -m3 '^!' "$OUT/$BASE.log" >&2 || tail -30 "$OUT/$BASE.log" >&2 + exit 1; } + +LOG="$OUT/$BASE.log" +status=0 + +if grep -q "Missing character" "$LOG"; then + echo "!! missing glyphs:" >&2 + grep -m5 "Missing character" "$LOG" >&2 + status=1 +fi +if grep -qE "^(Overfull|Underfull) \\\\[hv]box" "$LOG"; then + echo "!! overfull/underfull boxes:" >&2 + grep -m5 -E "^(Overfull|Underfull) \\\\[hv]box" "$LOG" >&2 + status=1 +fi +if grep -q "Package superfig Warning" "$LOG"; then + echo "!! superfig warnings:" >&2 + grep -m5 -A2 "Package superfig Warning" "$LOG" >&2 + status=1 +fi + +if command -v pdftocairo >/dev/null 2>&1; then + echo "==> exporting svg / png" + pdftocairo -svg "$OUT/$BASE.pdf" "$OUT/$BASE.svg" + pdftocairo -png -r 300 -singlefile "$OUT/$BASE.pdf" "$OUT/$BASE" + pdftocairo -png -r 300 -singlefile -transp "$OUT/$BASE.pdf" "$OUT/$BASE-alpha" + pdftocairo -png -scale-to-x 360 -scale-to-y -1 -singlefile \ + "$OUT/$BASE.pdf" "$OUT/$BASE-thumb" +else + echo "!! pdftocairo missing: PDF only, no SVG/PNG" >&2 + status=1 +fi + +echo "==> artifacts in $OUT" +ls -1 "$OUT/$BASE"*.{pdf,svg,png} 2>/dev/null | sed 's/^/ /' +if [[ $status -ne 0 ]]; then + echo "==> BUILD DIRTY: fix the warnings above before delivering." >&2 +else + echo "==> clean. Now do the visual audit (references/checklist.md)." +fi +exit $status diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100755 index 0000000..63b0a24 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Static source checks for superfig figures. + +The package owns layout and colors at render time; this linter catches the +source-level escapes TeX cannot see: undeclared roles, too many active hue +families, hand-rolled TikZ drawing, a formula placed before the last row, +a callout that is not an aside on a band, and a group that wraps one node. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROLE_DECL_RE = re.compile(r"\\sfsetrole\{([^{}]+)\}\{([^{}]+)\}") +ROLE_KEY_RE = re.compile(r"role\s*=\s*([A-Za-z0-9_-]+)") +RAW_TIKZ_RE = re.compile(r"\\(?:draw|fill|path)\b") +ROW_NAME_RE = re.compile(r"\\sfrow\{([^{}]+)\}") +CALLOUT_RE = re.compile(r"\\sfcallout\{([^{}]+)\}\{[^{}]+\}\{([^{}]+)\}") +GROUP_RE = re.compile( + r"\\sfgroup(?:\[[^\]]*\])?\{([^{}]+)\}\{([^{}]*)\}\{([^{}]*)\}" +) + + +def strip_comments(source: str) -> str: + return re.sub(r"(? int: + return source.count("\n", 0, offset) + 1 + + +def directives(source: str) -> set[str]: + found: set[str] = set() + for match in re.finditer(r"^\s*%\s*superfig-lint:\s*(.+)$", source, re.MULTILINE): + found.update(item.strip() for item in match.group(1).split(",")) + return found + + +def lint(path: Path) -> list[str]: + raw = path.read_text(encoding="utf-8") + allowed = directives(raw) + source = strip_comments(raw) + errors: list[str] = [] + + declared: dict[str, str] = {"neutral": "sfGray"} + for match in ROLE_DECL_RE.finditer(source): + name, color = (part.strip() for part in match.groups()) + previous = declared.get(name) + if previous is not None and previous != color and name != "neutral": + errors.append( + f"line {line_of(source, match.start())}: role {name!r} changes " + f"from {previous!r} to {color!r}" + ) + else: + declared[name] = color + + used_colors = {"sfGray"} + for match in ROLE_KEY_RE.finditer(source): + role = match.group(1) + color = declared.get(role) + if color is None: + errors.append( + f"line {line_of(source, match.start())}: role {role!r} is used " + f"before \\sfsetrole" + ) + else: + used_colors.add(color) + + active = used_colors - {"sfGray"} + if len(active) > 4: + errors.append( + f"figure uses {len(active)} active hue families " + f"({', '.join(sorted(active))}); maximum is 4 plus gray" + ) + + if "allow-raw-tikz" not in allowed: + for match in RAW_TIKZ_RE.finditer(source): + errors.append( + f"line {line_of(source, match.start())}: hand-rolled TikZ drawing " + f"bypasses \\sfnode/\\sfarrow; add allow-raw-tikz only for a " + f"genuinely non-linear detail" + ) + + formula_positions = [m.start() for m in re.finditer(r"\\sftopformula\b", source)] + row_ends = [m.start() for m in re.finditer(r"\\sfrowend\b", source)] + if formula_positions and row_ends and formula_positions[-1] < row_ends[-1]: + errors.append( + f"line {line_of(source, formula_positions[-1])}: \\sftopformula " + f"must follow the last \\sfrowend" + ) + + band_names = set(ROW_NAME_RE.findall(source)) + callout_anchors: dict[str, str] = {} + first_callout: str | None = None + for match in CALLOUT_RE.finditer(source): + name, anchor = (part.strip() for part in match.groups()) + line = line_of(source, match.start()) + if anchor not in band_names: + errors.append( + f"line {line}: callout {name!r} is anchored to {anchor!r}, which " + f"is not a \\sfrow band; a card hanging off a single object " + f"reads as a step" + ) + elif anchor in callout_anchors: + errors.append( + f"line {line}: callout {name!r} is the second card on band " + f"{anchor!r} (after {callout_anchors[anchor]!r}); one aside " + f"per band" + ) + elif first_callout is not None and "allow-multiple-callouts" not in allowed: + errors.append( + f"line {line}: callout {name!r} is the second card in the " + f"figure (after {first_callout!r}); the budget is one callout " + f"per figure" + ) + callout_anchors.setdefault(anchor, name) + if first_callout is None: + first_callout = name + + if "allow-single-group" not in allowed: + for match in GROUP_RE.finditer(source): + name, fit, _caption = match.groups() + members = re.findall(r"\(([^()]+)\)", fit) + if len(members) < 2: + errors.append( + f"line {line_of(source, match.start())}: group {name!r} " + f"wraps {len(members)} member(s); bind at least two" + ) + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="lint a superfig .tex source") + parser.add_argument("sources", nargs="+", type=Path) + args = parser.parse_args() + + failed = False + for path in args.sources: + if not path.is_file(): + print(f"superfig-lint: no such file: {path}", file=sys.stderr) + failed = True + continue + errors = lint(path) + if errors: + failed = True + print(f"!! {path}", file=sys.stderr) + for error in errors: + print(f" {error}", file=sys.stderr) + else: + print(f" lint {path}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..42e8bd8 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Toolchain preflight for superfig. +# Exit codes: 0 full path, 1 degraded (no CJK/export), 2 no LaTeX. +set -uo pipefail + +QUIET=0 +[[ "${1:-}" == "--quiet" ]] && QUIET=1 +say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; } + +ok=0; warn=0; fail=0; cjk_warn=0; export_warn=0 +check() { + local name="$1"; shift + if "$@" >/dev/null 2>&1; then say " ok $name"; ok=$((ok+1)); return 0 + else say " MISS $name"; return 1; fi +} + +say "superfig preflight" +say "--- required ---" +check "xelatex" command -v xelatex || fail=$((fail+1)) +check "standalone.cls" kpsewhich standalone.cls || fail=$((fail+1)) +check "tikz.sty" kpsewhich tikz.sty || fail=$((fail+1)) +check "array.sty" kpsewhich array.sty || fail=$((fail+1)) +check "etoolbox.sty" kpsewhich etoolbox.sty || fail=$((fail+1)) + +say "--- chinese figures ---" +check "ctex.sty" kpsewhich ctex.sty || { warn=$((warn+1)); cjk_warn=1; } +check "fandol font" kpsewhich FandolSong-Regular.otf || { warn=$((warn+1)); cjk_warn=1; } + +say "--- raster / vector export ---" +check "pdftocairo" command -v pdftocairo || { warn=$((warn+1)); export_warn=1; } + +if [[ $fail -gt 0 ]]; then + say "" + say "RESULT: no usable LaTeX path." + say "Fall back to SVG/HTML and state that superfig package guarantees are unavailable." + exit 2 +fi +if [[ $warn -gt 0 ]]; then + say "" + say "RESULT: degraded." + [[ $cjk_warn -eq 1 ]] && say " - missing ctex/fandol -> English-label figures only." + [[ $export_warn -eq 1 ]] && say " - missing pdftocairo -> PDF only, no PNG/SVG." + exit 1 +fi +say "" +say "RESULT: full path available (TikZ + CJK + vector/raster export)." +exit 0 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..1eec4b3 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Build every valid example, then prove invalid TeX and lint fixtures fail. +# +# ./scripts/test.sh +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fail=0 + +for f in "$ROOT"/tests/*.tex "$ROOT"/examples/*.tex; do + [[ -e "$f" ]] || continue + name="$(basename "$f")" + if "$ROOT/scripts/build.sh" "$f" >/dev/null 2>&1; then + echo " ok $name" + else + echo " FAIL $name" + fail=$((fail+1)) + fi +done + +for f in "$ROOT"/tests/invalid/*.tex; do + [[ -e "$f" ]] || continue + name="$(basename "$f")" + if SF_SKIP_LINT=1 "$ROOT/scripts/build.sh" "$f" >/dev/null 2>&1; then + echo " FAIL $name (invalid fixture built cleanly)" + fail=$((fail+1)) + else + echo " ok $name (rejected by package/build)" + fi +done + +for f in "$ROOT"/tests/lint-invalid/*.tex; do + [[ -e "$f" ]] || continue + name="$(basename "$f")" + if python3 "$ROOT/scripts/lint.py" "$f" >/dev/null 2>&1; then + echo " FAIL $name (invalid fixture passed lint)" + fail=$((fail+1)) + else + echo " ok $name (rejected by lint)" + fi +done + +if [[ $fail -gt 0 ]]; then + echo "$fail failing check(s); rerun the reported build or lint command to inspect" >&2 + exit 1 +fi +echo "all positive and negative checks passed" diff --git a/tests/flow.tex b/tests/flow.tex new file mode 100644 index 0000000..c4653cf --- /dev/null +++ b/tests/flow.tex @@ -0,0 +1,26 @@ +% Cursor extras: gap, leftover rail, a tracked absolute node. +\documentclass[border=8pt]{standalone} +\usepackage[en]{superfig} + +\sfsetrole{x}{sfTeal} +\sfsetrole{y}{sfViolet} + +\begin{document} +\begin{tikzpicture} +\sfleftrail{4mm} +\sfrow{R1}{12mm} + \sfnode[role=x]{A}{$A$}{12mm}{10mm} + \sfgap{8mm} + \sfnode[role=y]{B}{$B$}{12mm}{10mm} +\sfrowend +\sfnode[role=x, at={($(B.south)+(0,-14mm)$)}]{C}{$C$}{12mm}{10mm} +\sfarrow{B.south}{C.north} +\sflane{R1} +\sfcaption{A}{$A$}{rail} +\sfnolane +\sfcaptiontop{B}{after gap} +\sfcaption{C}{$C$}{absolute, tracked} +\sfbbox{all} +\sftopformula{F}{$C \leftarrow B$} +\end{tikzpicture} +\end{document} diff --git a/tests/group-callout.tex b/tests/group-callout.tex new file mode 100644 index 0000000..2100a41 --- /dev/null +++ b/tests/group-callout.tex @@ -0,0 +1,32 @@ +% Group expands the last row so captions clear the outline. +% One callout hangs off the finished band. +\documentclass[border=8pt]{standalone} +\usepackage[en]{superfig} + +\sfsetrole{q}{sfTeal} +\sfsetrole{w}{sfOrange} + +\begin{document} +\begin{tikzpicture} +\sfstage{S}{composite plus aside} +\sfrow{R1}{12mm} + \sfnode[role=q]{A}{$A$}{14mm}{10mm} + \sfconn{e1}{} + \sfnode[role=q]{B}{$B$}{14mm}{10mm} + \sfgap{\sflinklen} + \sfnode[role=w]{C}{$C$}{14mm}{10mm} +\sfrowend +\sfgroup[role=q]{G}{(A)(B)}{} +\sfarrow{G.east}{C.west} +\sflane{R1} +\sfcaption{G}{$AB$}{one composite} +\sfcaption{C}{$C$}{consumer} +\sfcallout{N1}{32mm}{R1}{aside}{The card hangs off the band, not off $C$.} +\sfbbox{all} +\sftopformula{F}{$C = f(AB)$} +\sfmeaningbox{mb}{108mm}{all} + {a group is a real composite} + {pair $AB$, consumer $C$} + {captions sit below the outline because the group refits the row} +\end{tikzpicture} +\end{document} diff --git a/tests/invalid/callout-anchor.tex b/tests/invalid/callout-anchor.tex new file mode 100644 index 0000000..e3dbd64 --- /dev/null +++ b/tests/invalid/callout-anchor.tex @@ -0,0 +1,10 @@ +% A callout hanging off a node, not a band, must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{12mm}{8mm} +\sfrowend +\sfcallout{N}{3cm}{A}{aside}{anchored to a node} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/callout-budget.tex b/tests/invalid/callout-budget.tex new file mode 100644 index 0000000..b858d63 --- /dev/null +++ b/tests/invalid/callout-budget.tex @@ -0,0 +1,14 @@ +% A second callout in the figure must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{12mm}{8mm} +\sfrowend +\sfrow{R2}{10mm} + \sfnode[role=x]{B}{B}{12mm}{8mm} +\sfrowend +\sfcallout{N1}{3cm}{R1}{one}{first} +\sfcallout{N2}{3cm}{R2}{two}{second} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/callout-inrow.tex b/tests/invalid/callout-inrow.tex new file mode 100644 index 0000000..4271a28 --- /dev/null +++ b/tests/invalid/callout-inrow.tex @@ -0,0 +1,10 @@ +% A callout inside an open row is a package error. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{12mm}{8mm} + \sfcallout{N}{3cm}{R1}{t}{b} +\sfrowend +\end{tikzpicture}\end{document} diff --git a/tests/invalid/empty-row.tex b/tests/invalid/empty-row.tex new file mode 100644 index 0000000..068a0ae --- /dev/null +++ b/tests/invalid/empty-row.tex @@ -0,0 +1,7 @@ +% An empty band must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} +\sfrowend +\end{tikzpicture}\end{document} diff --git a/tests/invalid/group-single.tex b/tests/invalid/group-single.tex new file mode 100644 index 0000000..064d235 --- /dev/null +++ b/tests/invalid/group-single.tex @@ -0,0 +1,8 @@ +% A group around one node is decoration and must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfnode[role=x, at={(0,0)}]{A}{A}{12mm}{8mm} +\sfgroup[role=x]{G}{(A)}{} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/overflow-band.tex b/tests/invalid/overflow-band.tex new file mode 100644 index 0000000..0801e23 --- /dev/null +++ b/tests/invalid/overflow-band.tex @@ -0,0 +1,9 @@ +% A node taller than its band by more than 4mm must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{8mm} + \sfnode[role=x]{A}{tall}{16mm}{16mm} +\sfrowend +\end{tikzpicture}\end{document} diff --git a/tests/invalid/role-redefinition.tex b/tests/invalid/role-redefinition.tex new file mode 100644 index 0000000..73ed603 --- /dev/null +++ b/tests/invalid/role-redefinition.tex @@ -0,0 +1,8 @@ +% Remapping a role to a different color must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\sfsetrole{x}{sfOrange} +\begin{document}\begin{tikzpicture} +\sfnode[role=x, at={(0,0)}]{A}{A}{12mm}{8mm} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/undeclared-role.tex b/tests/invalid/undeclared-role.tex new file mode 100644 index 0000000..17724dd --- /dev/null +++ b/tests/invalid/undeclared-role.tex @@ -0,0 +1,6 @@ +% An undeclared role falls back to gray and must dirty the build. +\documentclass[border=2pt]{standalone} +\usepackage[en]{superfig} +\begin{document}\begin{tikzpicture} +\sfnode[role=ghost, at={(0,0)}]{A}{A}{12mm}{8mm} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/callout-anchor.tex b/tests/lint-invalid/callout-anchor.tex new file mode 100644 index 0000000..64ac37e --- /dev/null +++ b/tests/lint-invalid/callout-anchor.tex @@ -0,0 +1,9 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{8mm}{8mm} +\sfrowend +\sfcallout{N}{3cm}{A}{aside}{on a node} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/callout-duplicate.tex b/tests/lint-invalid/callout-duplicate.tex new file mode 100644 index 0000000..d5ab37c --- /dev/null +++ b/tests/lint-invalid/callout-duplicate.tex @@ -0,0 +1,13 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{8mm}{8mm} +\sfrowend +\sfrow{R2}{10mm} + \sfnode[role=x]{B}{B}{8mm}{8mm} +\sfrowend +\sfcallout{N1}{3cm}{R1}{one}{first} +\sfcallout{N2}{3cm}{R2}{two}{second} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/formula-order.tex b/tests/lint-invalid/formula-order.tex new file mode 100644 index 0000000..43ad316 --- /dev/null +++ b/tests/lint-invalid/formula-order.tex @@ -0,0 +1,9 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sftopformula{F}{$x$} +\sfrow{R1}{10mm} + \sfnode[role=x]{A}{A}{8mm}{8mm} +\sfrowend +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/group-single.tex b/tests/lint-invalid/group-single.tex new file mode 100644 index 0000000..f45cfec --- /dev/null +++ b/tests/lint-invalid/group-single.tex @@ -0,0 +1,7 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\sfsetrole{x}{sfTeal} +\begin{document}\begin{tikzpicture} +\sfnode[role=x, at={(0,0)}]{A}{A}{8mm}{8mm} +\sfgroup[role=x]{G}{(A)}{} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/hue-budget.tex b/tests/lint-invalid/hue-budget.tex new file mode 100644 index 0000000..544d7b0 --- /dev/null +++ b/tests/lint-invalid/hue-budget.tex @@ -0,0 +1,14 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\sfsetrole{a}{sfTeal} +\sfsetrole{b}{sfOrange} +\sfsetrole{c}{sfCoral} +\sfsetrole{d}{sfViolet} +\sfsetrole{e}{sfInk} +\begin{document}\begin{tikzpicture} +\sfnode[role=a, at={(0,0)}]{A}{A}{8mm}{8mm} +\sfnode[role=b, at={(12mm,0)}]{B}{B}{8mm}{8mm} +\sfnode[role=c, at={(24mm,0)}]{C}{C}{8mm}{8mm} +\sfnode[role=d, at={(36mm,0)}]{D}{D}{8mm}{8mm} +\sfnode[role=e, at={(48mm,0)}]{E}{E}{8mm}{8mm} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/raw-tikz.tex b/tests/lint-invalid/raw-tikz.tex new file mode 100644 index 0000000..7d989ca --- /dev/null +++ b/tests/lint-invalid/raw-tikz.tex @@ -0,0 +1,5 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\begin{document}\begin{tikzpicture} +\draw (0,0) -- (1,0); +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/role-before.tex b/tests/lint-invalid/role-before.tex new file mode 100644 index 0000000..a7274b9 --- /dev/null +++ b/tests/lint-invalid/role-before.tex @@ -0,0 +1,5 @@ +\documentclass{standalone} +\usepackage[en]{superfig} +\begin{document}\begin{tikzpicture} +\sfnode[role=ghost, at={(0,0)}]{A}{A}{8mm}{8mm} +\end{tikzpicture}\end{document} diff --git a/tests/smoke.tex b/tests/smoke.tex new file mode 100644 index 0000000..7020009 --- /dev/null +++ b/tests/smoke.tex @@ -0,0 +1,27 @@ +% English-label smoke: cursor row, operator, meaning box, formula last. +\documentclass[border=8pt]{standalone} +\usepackage[en]{superfig} + +\sfsetrole{x}{sfTeal} +\sfsetrole{y}{sfOrange} + +\begin{document} +\begin{tikzpicture} +\sfstage{S}{one band} +\sfrow{R1}{12mm} + \sfnode[role=x]{A}{$A$}{14mm}{10mm} + \sfop{op}{$\circ$} + \sfnode[role=y]{B}{$B$}{14mm}{10mm} +\sfrowend +\sflane{R1} +\sfcaption{A}{$A$}{left} +\sfcaption{B}{$B$}{right} +\sfbbox{all} +\sftopformula{F}{$B = A \circ f$} +\sfmeaningbox{mb}{92mm}{all} + {one composition} + {two objects} + {the operator sits in the flow and reserves its own width} +\sfsignature{Smoke test}{mb} +\end{tikzpicture} +\end{document}