From 9f635d358b694c8bafda960a8d495e96f220cda5 Mon Sep 17 00:00:00 2001 From: dela Date: Mon, 17 Aug 2026 10:05:03 +0800 Subject: [PATCH] feat: add superderive stepwise derivation figures A third sibling toolkit: rewrite steps, justification rails, cancel/substitute highlights, and lint-on-warning builds. --- .gitignore | 7 + README.md | 25 +++ SKILL.md | 44 ++++ agents/openai.yaml | 6 + assets/superderive.sty | 297 +++++++++++++++++++++++++++ examples/rewrite-cancel.tex | 20 ++ references/antipatterns.md | 6 + references/api.md | 7 + references/checklist.md | 7 + references/fallback.md | 3 + references/grammar.md | 13 ++ references/layout.md | 3 + references/style.md | 3 + scripts/build.sh | 56 +++++ scripts/lint.py | 120 +++++++++++ scripts/preflight.sh | 27 +++ scripts/test.sh | 29 +++ tests/lint-invalid/formula-order.tex | 7 + tests/lint-invalid/hue-budget.tex | 15 ++ tests/lint-invalid/raw-tikz.tex | 7 + tests/lint-invalid/two-contrast.tex | 7 + tests/smoke.tex | 15 ++ 22 files changed, 724 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/superderive.sty create mode 100644 examples/rewrite-cancel.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/lint-invalid/formula-order.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/two-contrast.tex create mode 100644 tests/smoke.tex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d2eb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +*.aux +*.log +*.out +*.fls +*.fdb_latexmk +*.synctex.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c92195 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# superderive + +Stepwise algebraic derivation figures. Third sibling of `superfig` / +`supertensor`. Same muted palette and lint-on-warning build. The +primitives are rewrite steps, not nodes or tensor faces. + +This repository is a **child** of SuperPaper. Clone SuperPaper with +`--recurse-submodules` for the family; use this directory alone when +you only need a derivation figure. + +``` +SKILL.md +assets/superderive.sty +scripts/{preflight,lint,build,test} +examples/rewrite-cancel.tex +``` + +```bash +./scripts/preflight.sh +./scripts/build.sh examples/rewrite-cancel.tex +./scripts/test.sh +``` + +Provenance: specified in SuperPaper `DESIGN.md` Layer 2. Not grown +out of `superfig.sty`. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..c7670b0 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,44 @@ +--- +name: superderive +description: >- + Create or refine stepwise algebraic derivation figures — rewrite + steps, justification rails, cancel and substitute highlights. + Use when a rewrite must be seen (cancel-visual, subst-visual, or + more than four steps), or the user asks for $superderive. Not for + architecture (superfig), tensor faces (supertensor), or ordinary + note-side align (superpaper v1). +--- + +# superderive + +Turn one rewrite sequence into one slide-ready figure with three zones: + +1. **Top — claim.** The identity being justified. +2. **Middle — steps.** `\sdstep` / `\sdcancel` / `\sdsubst` / `\sdreason`. +3. **Bottom — meaning.** Idea, rewrite, caveat. + +Draw with the package. Do not hand-roll TikZ unless the linter allows it. + +## When to use + +- A cancel or substitution that is the claim. +- More than four rewrite steps that must stay aligned as a picture. +- An illegal vs legal pair (`\sdcol`, one per figure). + +If `expand: true` and steps ≤ 4 and there is no cancel/subst visual, +superpaper still uses `align`. This package is for when the rewrite +must be *seen*. + +## Workflow + +1. `./scripts/preflight.sh` +2. One rewrite path. Drop equivalent expansions. +3. Roles: `keep` / `rewrite` / `cancel` / `intro` (already declared). +4. Smallest grammar: `\sdstep` + `\sdreason`; add `\sdcancel` / `\sdsubst` only when they carry information. +5. `./scripts/build.sh fig.tex` then audit `references/checklist.md`. + +## Output + +Editable TikZ. Build emits PDF, SVG, PNG, transparent PNG, thumbnail. + +Chinese: `\usepackage[cjk]{superderive}`. English: `[en]`. diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..0ecdc95 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Superderive" + short_description: "Turn a rewrite sequence into a step/justification figure" + default_prompt: >- + Use $superderive to turn a rewrite sequence into a step/justification + figure. Not for architecture (superfig) or tensor faces (supertensor). diff --git a/assets/superderive.sty b/assets/superderive.sty new file mode 100644 index 0000000..3408db9 --- /dev/null +++ b/assets/superderive.sty @@ -0,0 +1,297 @@ +%% superderive.sty -- stepwise algebraic derivation figures +%% Third sibling of superfig / supertensor. Same house style and +%% role/cursor/warning discipline. Primitives are rewrite steps, +%% not nodes or tensor faces. +%% +%% Options: cjk XeLaTeX + fandol +%% en English meaning-box rails +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{superderive}[2026/08/17 v1.0 stepwise derivation figures] + +\newif\ifder@cjk\der@cjkfalse +\newif\ifder@en\der@enfalse +\DeclareOption{cjk}{\der@cjktrue} +\DeclareOption{en}{\der@entrue} +\DeclareOption{zh}{\der@enfalse} +\ProcessOptions\relax + +\RequirePackage{amsmath} +\RequirePackage{amssymb} +\RequirePackage{xcolor} +\RequirePackage{etoolbox} +\RequirePackage{array} +\RequirePackage{tikz} +\usetikzlibrary{calc,positioning,arrows.meta,fit} + +\ifder@cjk + \RequirePackage[UTF8,fontset=fandol]{ctex} +\fi + +\pgfdeclarelayer{sdbg} +\pgfdeclarelayer{sdfg} +\pgfsetlayers{sdbg,main,sdfg} + +\definecolor{sdTeal}{HTML}{4F8FA5} +\definecolor{sdOrange}{HTML}{EE995B} +\definecolor{sdCoral}{HTML}{C95B5B} +\definecolor{sdViolet}{HTML}{8A74B5} +\definecolor{sdGray}{HTML}{85898F} +\definecolor{sdInk}{HTML}{1A1A1A} + +\newcommand{\sdsetrole}[2]{% + \edef\sd@newrole{#2}% + \ifcsname sd@role@#1\endcsname + \edef\sd@oldrole{\csname sd@role@#1\endcsname}% + \ifx\sd@oldrole\sd@newrole\else + \PackageWarning{superderive}{Role `#1' was already mapped to + `\sd@oldrole' and cannot be remapped to `\sd@newrole'}% + \fi + \else + \expandafter\gdef\csname sd@role@#1\endcsname{#2}% + \fi} +\newcommand{\sdrole}[1]{% + \ifcsname sd@role@#1\endcsname\csname sd@role@#1\endcsname\else sdGray\fi} +\newcommand{\sdcheckrole}[1]{% + \ifcsname sd@role@#1\endcsname\else + \PackageWarning{superderive}{Undeclared role `#1' -- drawn in neutral gray. + Declare it with \string\sdsetrole}% + \fi} +\sdsetrole{neutral}{sdGray} +\sdsetrole{keep}{sdTeal} +\sdsetrole{rewrite}{sdOrange} +\sdsetrole{cancel}{sdCoral} +\sdsetrole{intro}{sdViolet} +\sdsetrole{warn}{sdCoral} + +\newcommand{\sdlevelpct}[1]{\ifcase#1 0\or30\or55\or80\else55\fi} + +\tikzset{ + sd stage/.style = {font=\small\bfseries, text=black!55, inner sep=2pt}, + sd note/.style = {font=\scriptsize, text=black!55, inner sep=2pt, align=left}, + sd arrow/.style = {-{Stealth[length=2.2mm,width=1.6mm]}, draw=black!45, line width=0.5pt}, + sd term/.style = {draw=black!60, line width=0.5pt, rounded corners=1.5pt, + inner sep=6pt, font=\small, text=sdInk, align=center}, + sd reason/.style= {font=\scriptsize, text=black!55, align=left, + draw=black!18, fill=black!3, rounded corners=1.5pt, + inner xsep=6pt, inner ysep=5pt}, +} + +\newlength{\sdgutter}\setlength{\sdgutter}{6mm} +\newlength{\sdrowgap}\setlength{\sdrowgap}{5mm} +\newlength{\sdblockgap}\setlength{\sdblockgap}{9mm} +\newlength{\sd@railx}\newlength{\sd@cx}\newlength{\sd@cy} +\newlength{\sd@ycur}\newlength{\sd@bandh}\newlength{\sd@tmpx}\newlength{\sd@tmpy} +\newif\ifder@inrow +\newif\ifder@first +\newcount\sd@ncols + +\newcommand{\sdlayoutreset}{% + \global\sd@railx=0pt \global\sd@ycur=0pt + \global\sd@cx=0pt \global\sd@cy=0pt \global\sd@bandh=0pt + \global\der@inrowfalse + \global\sd@ncols=0 + \gdef\sd@rowlist{}\gdef\sd@alllist{}\gdef\sd@rowname{}% + \gdef\sd@lastnode{}} +\sdlayoutreset +\newcommand{\sdleftrail}[1]{\global\sd@railx=\dimexpr#1\relax} +\newcommand{\sdvgap}[1]{\global\advance\sd@ycur by -\dimexpr#1\relax} +\newcommand{\sdgap}[1]{\global\advance\sd@cx by \dimexpr#1\relax} + +\newcommand{\sd@lower}[1]{% + \pgfextracty{\sd@tmpy}{\pgfpointanchor{#1}{south}}% + \ifdim\sd@tmpy<\sd@ycur \global\sd@ycur=\sd@tmpy \fi} +\newcommand{\sd@regall}[1]{\xdef\sd@alllist{\sd@alllist(#1)}} +\newcommand{\sd@regrow}[1]{% + \xdef\sd@rowlist{\sd@rowlist(#1)}\sd@regall{#1}% + \gdef\sd@lastnode{#1}} +\newcommand{\sd@needrow}[1]{% + \ifder@inrow\else + \PackageError{superderive}{\string#1\space needs an open \string\sdrow}% + {Close placement only works between \string\sdrow\space and \string\sdrowend.}% + \fi} +\newcommand{\sd@leadgap}[1]{% + \ifder@first + \global\der@firstfalse + \else + \global\advance\sd@cx by \dimexpr#1\relax + \fi} + +\newcommand{\sdstage}[2]{% + \dimen0=\sd@ycur \advance\dimen0 by -\sdblockgap + \node[sd stage, anchor=north west] (#1) at (\the\sd@railx,\the\dimen0) {#2}; + \sd@regall{#1}\sd@lower{#1}} + +% \sdrow{name} or \sdrow[height]{name} +\newcommand{\sdrow}[2][16mm]{% + \gdef\sd@rowlist{}\gdef\sd@rowname{#2}\gdef\sd@lastnode{}% + \global\der@inrowtrue\global\der@firsttrue + \pgfmathsetlengthmacro{\sd@bh}{#1}% + \global\sd@bandh=\sd@bh + \global\sd@cx=\sd@railx + \dimen0=\sd@ycur \advance\dimen0 by -\sdrowgap \advance\dimen0 by -0.5\sd@bandh + \global\sd@cy=\dimen0} + +\newcommand{\sdrowend}{% + \ifdefempty{\sd@rowlist}% + {\PackageWarning{superderive}{Empty \string\sdrow\space `\sd@rowname'}}% + {\edef\sd@do{\noexpand\node[inner sep=0pt, outer sep=0pt, + fit={\sd@rowlist}] (\sd@rowname) {};}\sd@do + \sd@regall{\sd@rowname}\sd@lower{\sd@rowname}}% + \global\der@inrowfalse} + +\newcommand{\sdbbox}[1]{% + \edef\sd@do{\noexpand\node[inner sep=0pt, outer sep=0pt, + fit={\sd@alllist}] (#1) {};}\sd@do} +\newcommand{\sdtrack}[1]{\sd@regall{#1}\sd@lower{#1}} +\newcommand{\sdtopformula}[2]{% + \sdbbox{sd@bbt}% + \node[above=6mm of sd@bbt, anchor=south, font=\large] (#1) {#2}; + \sd@regall{#1}} + +\pgfkeys{ + /sd/term/.cd, + role/.store in=\sd@role, + level/.store in=\sd@level, + gap/.store in=\sd@gap, + role=rewrite, level=2, gap=\sdgutter, +} + +% \sdstep[keys]{name}{lhs}{rhs} +\newcommand{\sdstep}[4][]{% + \sd@needrow{\sdstep}% + \begingroup + \pgfkeys{/sd/term/.cd,#1}% + \sdcheckrole{\sd@role}% + \edef\sd@col{\sdrole{\sd@role}}% + \sd@leadgap{\sd@gap}% + \node[anchor=west, inner sep=0pt] (#2) at (\the\sd@cx,\the\sd@cy) {% + \begin{tikzpicture}[baseline=(sd@mid)] + \node[sd term, fill=\sd@col!\sdlevelpct{\sd@level}] (sd@L) {$\displaystyle #3$}; + \node[sd term, fill=\sd@col!\sdlevelpct{\sd@level}, + right=\sdgutter of sd@L] (sd@R) {$\displaystyle #4$}; + \coordinate (sd@mid) at ($(sd@L.east)!0.5!(sd@R.west)$); + \begin{pgfonlayer}{sdbg} + \draw[sd arrow] (sd@L.east) -- (sd@R.west); + \end{pgfonlayer} + \end{tikzpicture}};% + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#2}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#2}% + \endgroup} + +% \sdreason{step-name}{text} -- hangs off the named step, or the last step. +\newcommand{\sdreason}[2]{% + \sd@needrow{\sdreason}% + \sd@leadgap{\sdgutter}% + \node[sd reason, anchor=west, text width=38mm] (#1-why) + at (\the\sd@cx,\the\sd@cy) {#2};% + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#1-why}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#1-why}} + +% \sdcancel[keys]{name}{math} +\newcommand{\sdcancel}[3][]{% + \sd@needrow{\sdcancel}% + \begingroup + \pgfkeys{/sd/term/.cd,role=cancel,#1}% + \sdcheckrole{\sd@role}% + \edef\sd@col{\sdrole{\sd@role}}% + \sd@leadgap{\sd@gap}% + \node[sd term, fill=\sd@col!\sdlevelpct{\sd@level}, anchor=west] (#2) + at (\the\sd@cx,\the\sd@cy) {$\displaystyle #3$};% + \draw[\sd@col, line width=0.8pt] + ($(#2.west)+(1.2mm,0)$) -- ($(#2.east)+(-1.2mm,0)$); + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#2}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#2}% + \endgroup} + +% \sdsubst[keys]{name}{from}{to} +\newcommand{\sdsubst}[4][]{% + \sd@needrow{\sdsubst}% + \begingroup + \pgfkeys{/sd/term/.cd,role=rewrite,#1}% + \sdcheckrole{\sd@role}% + \edef\sd@col{\sdrole{\sd@role}}% + \sd@leadgap{\sd@gap}% + \node[anchor=west, inner sep=0pt] (#2) at (\the\sd@cx,\the\sd@cy) {% + \begin{tikzpicture}[baseline=(sd@mid)] + \node[sd term, fill=sdCoral!30] (sd@F) {$\displaystyle #3$}; + \draw[sdCoral, line width=0.7pt] + ($(sd@F.west)+(1mm,0)$) -- ($(sd@F.east)+(-1mm,0)$); + \node[sd term, fill=\sd@col!\sdlevelpct{\sd@level}, + right=\sdgutter of sd@F] (sd@T) {$\displaystyle #4$}; + \coordinate (sd@mid) at ($(sd@F.east)!0.5!(sd@T.west)$); + \draw[sd arrow] (sd@F.east) -- (sd@T.west); + \end{tikzpicture}};% + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#2}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#2}% + \endgroup} + +% \sdbox[keys]{name}{math} +\newcommand{\sdbox}[3][]{% + \sd@needrow{\sdbox}% + \begingroup + \pgfkeys{/sd/term/.cd,role=keep,#1}% + \sdcheckrole{\sd@role}% + \edef\sd@col{\sdrole{\sd@role}}% + \sd@leadgap{\sd@gap}% + \node[sd term, fill=\sd@col!\sdlevelpct{\sd@level}, anchor=west] (#2) + at (\the\sd@cx,\the\sd@cy) {$\displaystyle #3$};% + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#2}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#2}% + \endgroup} + +% \sdcol{name}{illegal}{legal} -- one contrast pair per figure. +\newcommand{\sdcol}[3]{% + \sd@needrow{\sdcol}% + \global\advance\sd@ncols by 1 + \ifnum\sd@ncols>1 + \PackageWarning{superderive}{Contrast column `#1' is the second pair + in the figure. The budget is one illegal/legal pair}% + \fi + \sd@leadgap{\sdgutter}% + \node[anchor=west, inner sep=0pt] (#1) at (\the\sd@cx,\the\sd@cy) {% + \begin{tikzpicture}[baseline=(sd@mid)] + \node[sd term, fill=sdCoral!30] (sd@B) {$\displaystyle #2$}; + \node[font=\scriptsize, text=sdCoral, above=1pt of sd@B] {非法}; + \node[sd term, fill=sdTeal!30, right=8mm of sd@B] (sd@G) {$\displaystyle #3$}; + \node[font=\scriptsize, text=sdTeal, above=1pt of sd@G] {合法}; + \coordinate (sd@mid) at ($(sd@B.east)!0.5!(sd@G.west)$); + \end{tikzpicture}};% + \pgfextractx{\sd@tmpx}{\pgfpointanchor{#1}{east}}% + \global\sd@cx=\sd@tmpx + \sd@regrow{#1}} + +\ifder@en + \def\sd@lblidea{Idea}\def\sd@lblrw{Rewrite}\def\sd@lblcaveat{Caveat} +\else + \def\sd@lblidea{思路}\def\sd@lblrw{改写}\def\sd@lblcaveat{注意} +\fi +\newcommand{\sdsetlabels}[3]{\def\sd@lblidea{#1}\def\sd@lblrw{#2}\def\sd@lblcaveat{#3}} +\newlength{\sd@raillen} +\ifder@en + \setlength{\sd@raillen}{5.4em} +\else + \setlength{\sd@raillen}{3.2em} +\fi + +\newcommand{\sdmeaningbox}[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{\sd@raillen}@{\hspace{7pt}}p{\dimexpr#2-\sd@raillen-7pt\relax}@{}} + \ifblank{#4}{}{\textbf{\sd@lblidea} & \raggedright\arraybackslash #4 \\} + \ifblank{#5}{}{\textbf{\sd@lblrw} & \raggedright\arraybackslash #5 \\} + \ifblank{#6}{}{\textbf{\sd@lblcaveat} & \raggedright\arraybackslash #6 \\} + \end{tabular}};} + +\newcommand{\sdsignature}[2]{% + \node[below=2.2mm of #2, font=\scriptsize, text=black!45] (#2-sig) {#1};} + +\endinput diff --git a/examples/rewrite-cancel.tex b/examples/rewrite-cancel.tex new file mode 100644 index 0000000..2f5c551 --- /dev/null +++ b/examples/rewrite-cancel.tex @@ -0,0 +1,20 @@ +% Golden example -- a scale rewrite that must be seen, not just aligned. +% ../scripts/build.sh rewrite-cancel.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{superderive} + +\begin{document} +\begin{tikzpicture} +\sdstage{D1}{缩放来自方差,不是装饰} +\sdrow{R1} + \sdstep[role=rewrite]{s1}{QK^{\top}}{QK^{\top}/\sqrt{d_k}} + \sdreason{s1}{§3.2.1,避免 softmax 饱和} +\sdrowend +\sdbbox{all} +\sdtopformula{F}{$\mathrm{Attention}(Q,K,V)=\mathrm{softmax}(QK^{\top}/\sqrt{d_k})V$} +\sdmeaningbox{mb}{120mm}{all} + {点积方差随 $d_k$ 线性涨} + {缩放是改写,不是新算子} + {没有缩放,softmax 进饱和区,梯度消失} +\end{tikzpicture} +\end{document} diff --git a/references/antipatterns.md b/references/antipatterns.md new file mode 100644 index 0000000..6601aa9 --- /dev/null +++ b/references/antipatterns.md @@ -0,0 +1,6 @@ +# Antipatterns + +- Drawing a rewrite as `\sfnode` boxes. +- A new hue per step. +- Two illegal/legal pairs in one figure. +- Formula placed before the last row so it centers on an unknown width. diff --git a/references/api.md b/references/api.md new file mode 100644 index 0000000..9fe0b2a --- /dev/null +++ b/references/api.md @@ -0,0 +1,7 @@ +# API + +`\usepackage[cjk]{superderive}` or `[en]`. + +Layout: `\sdstage{n}{text}` `\sdrow[height]{name}` … `\sdrowend` `\sdbbox{all}` `\sdtopformula{F}{math}`. + +Call `\sdtopformula` after the last `\sdrowend`. diff --git a/references/checklist.md b/references/checklist.md new file mode 100644 index 0000000..e77e3b2 --- /dev/null +++ b/references/checklist.md @@ -0,0 +1,7 @@ +# Checklist + +- Each step is one rewrite. Reasons sit on the rail, not on the shaft. +- Cancel/subst encode the actual cancelled or replaced term. +- At most one `\sdcol`. At most four active hues plus gray. +- No raw `\draw`. Formula after the last row. +- Clean `build.sh` (missing glyphs, overfull, package warnings). diff --git a/references/fallback.md b/references/fallback.md new file mode 100644 index 0000000..bc423a0 --- /dev/null +++ b/references/fallback.md @@ -0,0 +1,3 @@ +# Fallback + +`preflight.sh` 0/1/2 matches superfig. Exit 2: no XeLaTeX. Exit 1: no CJK or no pdftocairo. diff --git a/references/grammar.md b/references/grammar.md new file mode 100644 index 0000000..cefcc0a --- /dev/null +++ b/references/grammar.md @@ -0,0 +1,13 @@ +# Grammar + +| Fact | Macro | +|---|---| +| one legal rewrite | `\sdstep[role=rewrite]{name}{lhs}{rhs}` | +| why it is legal | `\sdreason{name}{text}` | +| a cancelled term | `\sdcancel{name}{math}` | +| substitution | `\sdsubst{name}{from}{to}` | +| focus box | `\sdbox[role=keep]{name}{math}` | +| illegal vs legal | `\sdcol{name}{bad}{good}` (one per figure) | +| bottom | `\sdmeaningbox` (idea / rewrite / caveat) | + +Built-in roles: `keep` `rewrite` `cancel` `intro` `warn` `neutral`. diff --git a/references/layout.md b/references/layout.md new file mode 100644 index 0000000..81224b5 --- /dev/null +++ b/references/layout.md @@ -0,0 +1,3 @@ +# Layout + +Place by cursor inside `\sdrow` … `\sdrowend`. Never hand-tune a neighbour with a magic offset. If a reason does not fit, move the step to the next row. diff --git a/references/style.md b/references/style.md new file mode 100644 index 0000000..64faca5 --- /dev/null +++ b/references/style.md @@ -0,0 +1,3 @@ +# Style + +Palette `sdTeal #4F8FA5` `sdOrange #EE995B` `sdCoral #C95B5B` `sdViolet #8A74B5` `sdGray #85898F`. One hue per role. ≤4 active hues plus gray. Lightness `30/55/80`. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..651c0d1 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ./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 [[ "${SD_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 superderive Warning" "$LOG"; then + echo "!! superderive warnings:" >&2 + grep -m5 -A2 "Package superderive 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" >&2 + status=1 +fi +echo "==> artifacts in $OUT" +ls -1 "$OUT/$BASE"*.{pdf,svg,png} 2>/dev/null | sed 's/^/ /' +exit $status diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100755 index 0000000..399bb3d --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Source lint for superderive figures.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROLE_DECL_RE = re.compile(r"\\sdsetrole\{([^{}]+)\}\{([^{}]+)\}") +ROLE_KEY_RE = re.compile(r"role\s*=\s*([A-Za-z0-9_-]+)") +RAW_TIKZ_RE = re.compile(r"\\(?:draw|fill|path)\b") + + +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*superderive-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 = { + "neutral": "sdGray", + "keep": "sdTeal", + "rewrite": "sdOrange", + "cancel": "sdCoral", + "intro": "sdViolet", + "warn": "sdCoral", + } + 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}" + ) + declared[name] = color + + used_colors = {"sdGray"} + 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 \\sdsetrole" + ) + else: + used_colors.add(color) + + active = used_colors - {"sdGray"} + 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 " + f"drawing; use \\sdstep/\\sdcancel/\\sdsubst" + ) + + formula_positions = [m.start() for m in re.finditer(r"\\sdtopformula\b", source)] + row_ends = [m.start() for m in re.finditer(r"\\sdrowend\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])}: \\sdtopformula " + f"must follow the last \\sdrowend" + ) + + cols = list(re.finditer(r"\\sdcol\b", source)) + if len(cols) > 1 and "allow-multiple-contrast" not in allowed: + errors.append( + f"line {line_of(source, cols[1].start())}: second \\sdcol; " + f"budget is one illegal/legal pair per figure" + ) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="lint a superderive .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"superderive-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..dfaf8ba --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Exit 0 full, 1 degraded, 2 no LaTeX. +set -uo pipefail +QUIET=0 +[[ "${1:-}" == "--quiet" ]] && QUIET=1 +say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; } +ok=0; warn=0; fail=0 +check() { + local name="$1"; shift + if "$@" >/dev/null 2>&1; then say " ok $name"; return 0 + else say " MISS $name"; return 1; fi +} +say "superderive 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 "etoolbox.sty" kpsewhich etoolbox.sty || fail=$((fail+1)) +say "--- chinese figures ---" +check "ctex.sty" kpsewhich ctex.sty || warn=$((warn+1)) +check "fandol" kpsewhich FandolSong-Regular.otf || warn=$((warn+1)) +say "--- export ---" +check "pdftocairo" command -v pdftocairo || warn=$((warn+1)) +if [[ $fail -gt 0 ]]; then say "RESULT: no LaTeX."; exit 2; fi +if [[ $warn -gt 0 ]]; then say "RESULT: degraded."; exit 1; fi +say "RESULT: full path." +exit 0 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..572d3f7 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +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/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)" >&2 + exit 1 +fi +echo "all superderive checks passed" diff --git a/tests/lint-invalid/formula-order.tex b/tests/lint-invalid/formula-order.tex new file mode 100644 index 0000000..2c8ce2c --- /dev/null +++ b/tests/lint-invalid/formula-order.tex @@ -0,0 +1,7 @@ +\usepackage[en]{superderive} +\begin{document}\begin{tikzpicture} +\sdtopformula{F}{$x$} +\sdrow{R1} + \sdbox{a}{x} +\sdrowend +\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..2c0628c --- /dev/null +++ b/tests/lint-invalid/hue-budget.tex @@ -0,0 +1,15 @@ +\usepackage[en]{superderive} +\sdsetrole{a}{sdTeal} +\sdsetrole{b}{sdOrange} +\sdsetrole{c}{sdCoral} +\sdsetrole{d}{sdViolet} +\sdsetrole{e}{sdInk} +\begin{document}\begin{tikzpicture} +\sdrow{R1} + \sdbox[role=a]{a}{1} + \sdbox[role=b]{b}{2} + \sdbox[role=c]{c}{3} + \sdbox[role=d]{d}{4} + \sdbox[role=e]{e}{5} +\sdrowend +\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..54bae5d --- /dev/null +++ b/tests/lint-invalid/raw-tikz.tex @@ -0,0 +1,7 @@ +\usepackage[en]{superderive} +\begin{document}\begin{tikzpicture} +\sdrow{R1} + \sdbox{a}{x} + \draw (0,0) -- (1,0); +\sdrowend +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/two-contrast.tex b/tests/lint-invalid/two-contrast.tex new file mode 100644 index 0000000..d6767e8 --- /dev/null +++ b/tests/lint-invalid/two-contrast.tex @@ -0,0 +1,7 @@ +\usepackage[en]{superderive} +\begin{document}\begin{tikzpicture} +\sdrow{R1} + \sdcol{c1}{a}{b} + \sdcol{c2}{c}{d} +\sdrowend +\end{tikzpicture}\end{document} diff --git a/tests/smoke.tex b/tests/smoke.tex new file mode 100644 index 0000000..270cd68 --- /dev/null +++ b/tests/smoke.tex @@ -0,0 +1,15 @@ +\documentclass[border=10pt]{standalone} +\usepackage[en]{superderive} +\begin{document} +\begin{tikzpicture} +\sdstage{S}{smoke} +\sdrow{R1} + \sdbox{a}{x} + \sdsubst{b}{x}{y} + \sdcancel{c}{z} +\sdrowend +\sdbbox{all} +\sdtopformula{F}{$x\to y$} +\sdmeaningbox{mb}{80mm}{all}{idea}{rewrite}{caveat} +\end{tikzpicture} +\end{document}