feat: add superfig paper-figure toolkit
Standalone LaTeX/TikZ skill for non-tensor paper figures: node/edge macros, lint-on-warning build, golden examples, and negative fixtures.
This commit is contained in:
Executable
+65
@@ -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
|
||||
Executable
+159
@@ -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"(?<!\\)%.*$", "", 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*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())
|
||||
Executable
+47
@@ -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
|
||||
Executable
+46
@@ -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"
|
||||
Reference in New Issue
Block a user