feat: add superderive stepwise derivation figures

A third sibling toolkit: rewrite steps, justification rails,
cancel/substitute highlights, and lint-on-warning builds.
This commit is contained in:
dela
2026-08-17 10:05:03 +08:00
commit 9f635d358b
22 changed files with 724 additions and 0 deletions
+56
View File
@@ -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
+120
View File
@@ -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"(?<!\\)%.*$", "", source, flags=re.MULTILINE)
def line_of(source: str, offset: int) -> 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())
+27
View File
@@ -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
+29
View File
@@ -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"