From 9b13b498e42ea1850084d060dcd8a850daee7885 Mon Sep 17 00:00:00 2001 From: Weksa Date: Wed, 11 Nov 2026 20:15:40 +0000 Subject: [PATCH] bloom-sheet: refuse a split marker, fill markers in one pass, document the byte-equal caveat Co-Authored-By: Claude Sonnet 5.5 --- tools/INDEX.md | 2 +- tools/bloom-sheet/bloom_sheet.py | 15 +++++++++++++-- tools/bloom-sheet/test_theme.py | 4 +++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tools/INDEX.md b/tools/INDEX.md index 9962f63..9ff2770 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -16,7 +16,7 @@ gets a Soul pass before merge. | `tools/testparse.sh` | Sourced library: failing test names and pass/fail/skip counts from cargo, dotnet, vitest, jest and pytest logs, and test files that failed to load (`LOADFAIL`). Shared by the summary and the kill table. | `tools/stopgap/ygg-verify.sh`, `tools/mutation-table.sh` | not named by a brief; it lives and dies with them | | `tools/test-tools.sh` | Local fixture check of the parsers and the summary over `tools/fixtures/logs` (real cargo and mutation logs, plus hand-written dotnet, vitest, jest, pytest, exit-0-but-failed and no-verdict logs), and of the mutation runners' host drivers and container halves (`tools/mutation-table.sh`, `tools/cargo-mutants.sh`) under a mock docker, a fake `cargo mutants` and real slot locks. Run it before merging a change to the scripts above. | whoever changes those scripts | `SKILL.md` (Shared tools) | | `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only \|r[,...]] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable. The spec is the document `huginn view --raw ` prints, saved to a file or piped to `-`; context-pack never fetches it, and an Unavailable from huginn is the caller's one retry. `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | -| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. `--theme NAME` swaps the page file for `themes/NAME.tex` (`calligraphic`: ink on parchment). PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be, `test_theme.py` what a theme must be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | +| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. `--theme NAME` swaps the page file for `themes/NAME.tex` (`calligraphic`: ink on parchment); byte-equal rebuilds need `SOURCE_DATE_EPOCH=1 FORCE_SOURCE_DATE=1`, and a PDF's `/ID` differs every run regardless. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be, `test_theme.py` what a theme must be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH | ## From a Self seat on Yggdrasil diff --git a/tools/bloom-sheet/bloom_sheet.py b/tools/bloom-sheet/bloom_sheet.py index 6ebd5e7..433b10d 100755 --- a/tools/bloom-sheet/bloom_sheet.py +++ b/tools/bloom-sheet/bloom_sheet.py @@ -18,6 +18,8 @@ opacity is ignored. All of it must touch, reading as one thing; a closed outline counts as covering its inside. Text, images and gradients are refused. --theme NAME swaps the page file for themes/NAME.tex (`calligraphic`: ink on parchment, Zapf Chancery). +Builds are byte-equal across runs only with SOURCE_DATE_EPOCH=1 FORCE_SOURCE_DATE=1 set, and then only once the +PDF's /ID is blanked: LaTeX writes a fresh /ID every run, even for unchanged code. PNGs compare equal with both set. Needs python3-numpy, python3-markdown-it, LuaLaTeX with tikz and tcolorbox, pdftoppm for PNG, the Ubuntu, IBM Plex Mono and Montserrat fonts, and for @@ -356,6 +358,16 @@ def check_markers(text, name): if n > 1 or (n == 0 and mark != "@@FONTS@@"): sys.exit(f"bloom-sheet: {name} has {mark} {n} times, not once " "(a marker inside a TeX comment is replaced too)") + rest = re.sub("|".join(map(re.escape, MARKS)), "", text) + for mark in MARKS: + if mark in rest: + sys.exit(f"bloom-sheet: {name} has {mark} split by another marker " + "(removing the markers leaves it whole)") + + +def fill(page, values): + """Substitute every marker in one pass, so a replacement's text is never searched for markers.""" + return re.sub("|".join(map(re.escape, values)), lambda m: values[m.group(0)], page) def build(args, source): @@ -400,8 +412,7 @@ def build(args, source): (d / "spec.json").write_text(json.dumps( {"seed": seed, "panels": panels, "title_box": title_box, "swash": swash, "visitors": visitors}), encoding="utf8") page = page_source(args.theme) - for mark, value in (("@@FONTS@@", FONTS), ("@@CONTENTS@@", "\n".join(contents)), ("@@TITLE@@", title_tex)): - page = page.replace(mark, value) + page = fill(page, {"@@FONTS@@": FONTS, "@@CONTENTS@@": "\n".join(contents), "@@TITLE@@": title_tex}) (d / "sheet.tex").write_text(page, encoding="utf8") grown = run([sys.executable, str(HERE / "grow.py"), "spec.json", "layer.tex"], d, args.verbose) for line in grown.splitlines(): diff --git a/tools/bloom-sheet/test_theme.py b/tools/bloom-sheet/test_theme.py index 37082c3..7a3a946 100644 --- a/tools/bloom-sheet/test_theme.py +++ b/tools/bloom-sheet/test_theme.py @@ -6,7 +6,7 @@ from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) -from bloom_sheet import FONTS, MARKS, check_markers, page_source +from bloom_sheet import FONTS, MARKS, check_markers, fill, page_source fails = 0 @@ -45,6 +45,8 @@ good = "@@CONTENTS@@ @@TITLE@@" check_markers(good, "good.tex") refused("marker in a comment", lambda: check_markers(good + "\n% fills @@TITLE@@ here", "bad.tex"), "@@TITLE@@ 2 times") refused("marker missing", lambda: check_markers("@@CONTENTS@@", "bad.tex"), "@@TITLE@@ 0 times") +refused("split marker", lambda: check_markers("@@CONTENTS@@ @@CONT@@TITLE@@ENTS@@", "bad.tex"), "@@CONTENTS@@ split by") +check("fill is one pass", fill("@@CONTENTS@@ @@TITLE@@", {"@@CONTENTS@@": "x@@TITLE@@", "@@TITLE@@": "T"}) == "x@@TITLE@@ T") refused("unknown theme", lambda: page_source("nope"), "no theme 'nope'") refused("path as theme", lambda: page_source("../page"), "no theme") sys.exit(1 if fails else 0) -- 2.47.3