Implements #91 bullet 2 ("images where only image relative path changes → ie, all railroad diagrams"). Splitting it out with a reference prototype, a spec, and verified code locations so it's actionable without re-deriving the investigation. Context + measured scope in #91 (this comment): on the core-spec-wg v1.1.0 ballot diff, 12 of 209 railroad diagrams actually changed; 197 were path/render noise.
One primitive, two surfaces
The core capability is content-aware diagram comparison — "are these two diagrams the same picture?", independent of file path/identity. Once doc_build has it, it drives two things:
- Suppress false highlights in the inline
--diff render (the bullet-2 fix).
- Extract a changed-diagrams digest — render only the diagrams that actually changed, with the changed label circled. (In practice this is the higher-value review surface: you see the changes instead of hunting a 200-page PDF.)
Open spike before implementing (don't skip)
pmolodo's bullet 2 attributes the noise to "image relative path changes." But at the AST-diff stage I could not confirm where railroad diagrams are represented: the combined markdown the diff runs on has no railroad image nodes (only static value-resolution <img>s) — the diagrams appear to be generated after the AST diff. So step 1 is to pin down exactly how a regenerated-but-unchanged diagram ends up flagged. Depending on that, the fix is one of:
- content-hash/normalize image nodes in the AST diff (if path-keyed as pmolodo believes), or
- make diagram generation deterministic, or
- exclude regenerated diagrams from inline highlighting unless their source grammar changed.
Reference prototype (proves the content-compare works)
This is the throwaway used for the ballot. It operates on the rendered before/after HTML from build_diff.yml (railroad diagrams are embedded there as base64 data:image/svg+xml URIs), decodes them, compares at the vector-<text> level (immune to path/layout churn), and emits a contact sheet with the changed label circled. A productionized version should instead drive off doc_build's own diagram outputs / AST-diff results rather than scraping rendered HTML, and take refs/paths as args (the BEF/AFT/OUT constants are the only session-specific bits).
import re, base64, urllib.parse, html as htmlmod, difflib
from lxml import etree
# Inputs: the before/after HTML renders from build_diff.yml (-before / -after artifacts).
BEF = r'before/aousd_core_spec.before_<fromref>.html'
AFT = r'after/aousd_core_spec.after_<toref>.html'
OUT = r'digest.html'
SVGNS = 'http://www.w3.org/2000/svg'
NS = {'s': SVGNS}
def load_svgs(path):
h = open(path, encoding='utf-8').read()
uris = re.findall(r'data:image/svg\+xml[^"\')]+', h)
out = []
for u in uris:
if ';base64,' in u:
out.append(base64.b64decode(u.split(';base64,', 1)[1]))
elif ',' in u:
out.append(urllib.parse.unquote(u.split(',', 1)[1]).encode())
return out
def text_nodes(root):
return root.findall('.//s:text', NS)
bef = load_svgs(BEF)
aft = load_svgs(AFT)
roots_b = [etree.fromstring(s) for s in bef]
roots_a = [etree.fromstring(s) for s in aft]
cards = []
for i in range(min(len(roots_b), len(roots_a))):
tb = [(t.text or '') for t in text_nodes(roots_b[i])]
ta_nodes = text_nodes(roots_a[i])
ta = [(t.text or '') for t in ta_nodes]
if tb == ta:
continue
# Structure is identical when only a label's text changed, so POSITIONAL compare is exact.
# (A set-diff misfires on repeated tokens, e.g. several `MultilinePadding` in one diagram.)
changes = [] # (after_index, old, new)
if len(tb) == len(ta):
for k in range(len(ta)):
if tb[k] != ta[k]:
changes.append((k, tb[k], ta[k]))
else:
# fallback: rare length change -> difflib replace blocks
sm = difflib.SequenceMatcher(a=tb, b=ta, autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == 'equal':
continue
oldspan = tb[i1:i2]
for k in range(j1, j2):
old = oldspan[k - j1] if (k - j1) < len(oldspan) else ''
changes.append((k, old, ta[k]))
if not changes:
continue
root = roots_a[i]
for (k, old, new) in changes:
node = ta_nodes[k]
try:
x = float(node.get('x')); y = float(node.get('y'))
except (TypeError, ValueError):
continue
txt = node.text or ''
rx = max(len(txt) * 4.6, 16) + 7 # text-anchor=middle: x is the label center
ry = 13
cy = y - 4 # baseline -> visual center
el = etree.SubElement(root, '{%s}ellipse' % SVGNS)
el.set('cx', f'{x:.1f}'); el.set('cy', f'{cy:.1f}')
el.set('rx', f'{rx:.1f}'); el.set('ry', f'{ry:.1f}')
el.set('fill', 'none'); el.set('stroke', '#d62828')
el.set('stroke-width', '2.2')
svg_str = etree.tostring(root, encoding='unicode')
olds = ', '.join(sorted({o for _, o, _ in changes if o}))
news = ', '.join(sorted({n for _, _, n in changes if n}))
cards.append((i, olds, news, svg_str))
parts = ['''<!doctype html><meta charset=utf-8><style>
body{font-family:Segoe UI,Arial,sans-serif;margin:24px;background:#fafafa;color:#1a1a1a}
h1{font-size:20px} .sub{color:#555;margin-bottom:18px;font-size:13px}
.card{background:#fff;border:1px solid #e2e2e2;border-radius:8px;padding:14px 16px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.05)}
.hd{font-size:14px;margin-bottom:8px}
.chg{font-family:Consolas,monospace;font-size:13px}
.old{color:#b00020;text-decoration:line-through} .new{color:#0a7d28;font-weight:600}
svg{max-width:100%;height:auto;border:1px solid #f0f0f0;background:#fff}
.ring{color:#d62828;font-weight:600}
</style>
<h1>v1.1.0 ballot preview — railroad diagrams that actually changed</h1>
<div class=sub>Baseline <code>60e060e</code> (tagged v1.1.0) → <code>cb1ac59</code> (main, post-#519). '''
+ f'{len(cards)} of {min(len(roots_b), len(roots_a))} diagrams changed'
+ '''; the rest are unchanged (any other highlight in the auto-diff is rendering noise). <span class=ring>Red circle</span> = the label whose spelling changed. Editorial only, no semantic change.</div>''']
for idx, olds, news, svg in cards:
parts.append(
f"<div class=card><div class=hd>Diagram #{idx} "
f"<span class=chg><span class=old>{htmlmod.escape(olds)}</span> → "
f"<span class=new>{htmlmod.escape(news)}</span></span></div>{svg}</div>")
open(OUT, 'w', encoding='utf-8').write('\n'.join(parts))
print(f"changed diagrams: {len(cards)}")
for idx, olds, news, _ in cards:
print(f" #{idx}: {olds} -> {news}")
Render the resulting HTML to PDF/PNG via headless Chrome (--headless --print-to-pdf / --screenshot).
Reproducible recipe (end-to-end) - validated on the core-spec-wg v1.1.0 ballot (2026-07-06)
Run from a clean core-spec-wg checkout; produces the ballot digest PDF. This is the authoritative recipe - don't re-derive it.
- Pick refs.
to = candidate (current main HEAD, e.g. cb1ac59); from = previous candidate / release tag. NOTE: v1.1.0's annotated-tag object SHA is 2e781be but the commit it points to is 60e060e - use the commit. Sanity-check the spec-only change set first:
git diff <from>..<to> -- specification/ (v1.1.0 ballot: only file_formats/README.md grammar rule-name fixes -> railroad diagrams).
- Get before/after renders (they embed the diagrams as base64 SVG; the
-diff artifact is the NOISY ~200-page render - NOT used for the digest):
gh workflow run build_diff.yml --repo aousd/core-spec-wg --ref main -f from_ref=<from>
# to_ref defaults to the checkout HEAD (main). Wait for the run, then:
gh run download <run_id> --repo aousd/core-spec-wg -n aousd_core_spec-before -D before
gh run download <run_id> --repo aousd/core-spec-wg -n aousd_core_spec-after -D after
- Run the prototype below with
BEF/AFT/OUT at the downloaded HTML and FROM_LABEL/TO_LABEL set. It prints the changed-diagram list and writes the styled digest HTML. Needs lxml.
- Validate (oracle): the printed changed set MUST equal the step-1 source diff - zero false positives. v1.1.0 = 12 of 209 diagrams.
- Render to PDF via headless Chrome:
chrome --headless=new --disable-gpu --no-pdf-header-footer --print-to-pdf=OUT.pdf "file:///ABS/PATH/digest.html"
- Name per ballot convention (files go on the release owner's Desktop bundle): digest =
aousd_core_spec_1.1.0_diagram_diff_<from>_to_<to>.pdf; candidate (from the normal build_docs run on main) = aousd_core_spec_1.1.0_<to>.pdf.
Implementation spec (the non-obvious parts)
- SVG source: railroad diagrams are base64
data:image/svg+xml URIs in the rendered HTML; the persistent originals are SVGs in build/images/ (via filter_bundle_images).
- Alignment: compare label lists positionally, not as sets — set-diff over/under-counts repeated tokens (the trap: multiple identical
MultilinePadding nodes in one diagram).
- Annotation geometry: railroad
<text> uses text-anchor=middle; x is the label center, y the baseline. Draw an <ellipse> at (x, y-4).
- Validation oracle: the changed set must match the grammar source diff exactly (
git diff <from>..<to> -- <spec file>). For the ballot that was 12 diagrams; use this to self-check any reimplementation.
Verified doc_build code locations (main @ c918081)
- Standard PDF path (pandoc → tectonic):
doc_build/doc_builder.py:500-507
- Bare-name
--pdf-engine=tectonic SVG pre-conversion comment: doc_build/doc_builder.py:514-517
- AST diff entry point:
doc_build/ast_diff.py (diff_ast_files, generic block differ — no Image-specific handling today)
- Image bundling to
build/images/: doc_build/filters/filter_bundle_images.py
stabilize_tex_media TOCTOU-ish skip: doc_build/tools/tectonic:74
Related
- #102 — separate PDF-build fragility (unvalidated ephemeral SVG→PDF). Distinct, but both touch how diagrams flow through the pipeline.
Suggested scope
Spike the mechanism first → then either (a) implement suppression in the diff, (b) add the changed-diagrams digest output, or both off the shared primitive. The digest is independently shippable as a post-processor on the --diff HTML artifacts (the prototype above), if a full pipeline change isn't wanted yet.
Implements #91 bullet 2 ("images where only image relative path changes → ie, all railroad diagrams"). Splitting it out with a reference prototype, a spec, and verified code locations so it's actionable without re-deriving the investigation. Context + measured scope in #91 (this comment): on the core-spec-wg v1.1.0 ballot diff, 12 of 209 railroad diagrams actually changed; 197 were path/render noise.
One primitive, two surfaces
The core capability is content-aware diagram comparison — "are these two diagrams the same picture?", independent of file path/identity. Once doc_build has it, it drives two things:
--diffrender (the bullet-2 fix).Open spike before implementing (don't skip)
pmolodo's bullet 2 attributes the noise to "image relative path changes." But at the AST-diff stage I could not confirm where railroad diagrams are represented: the combined markdown the diff runs on has no railroad image nodes (only static value-resolution
<img>s) — the diagrams appear to be generated after the AST diff. So step 1 is to pin down exactly how a regenerated-but-unchanged diagram ends up flagged. Depending on that, the fix is one of:Reference prototype (proves the content-compare works)
This is the throwaway used for the ballot. It operates on the rendered before/after HTML from
build_diff.yml(railroad diagrams are embedded there as base64data:image/svg+xmlURIs), decodes them, compares at the vector-<text>level (immune to path/layout churn), and emits a contact sheet with the changed label circled. A productionized version should instead drive off doc_build's own diagram outputs / AST-diff results rather than scraping rendered HTML, and take refs/paths as args (theBEF/AFT/OUTconstants are the only session-specific bits).Render the resulting HTML to PDF/PNG via headless Chrome (
--headless --print-to-pdf/--screenshot).Reproducible recipe (end-to-end) - validated on the core-spec-wg v1.1.0 ballot (2026-07-06)
Run from a clean
core-spec-wgcheckout; produces the ballot digest PDF. This is the authoritative recipe - don't re-derive it.to= candidate (currentmainHEAD, e.g.cb1ac59);from= previous candidate / release tag. NOTE: v1.1.0's annotated-tag object SHA is2e781bebut the commit it points to is60e060e- use the commit. Sanity-check the spec-only change set first:git diff <from>..<to> -- specification/(v1.1.0 ballot: onlyfile_formats/README.mdgrammar rule-name fixes -> railroad diagrams).-diffartifact is the NOISY ~200-page render - NOT used for the digest):BEF/AFT/OUTat the downloaded HTML andFROM_LABEL/TO_LABELset. It prints the changed-diagram list and writes the styled digest HTML. Needslxml.aousd_core_spec_1.1.0_diagram_diff_<from>_to_<to>.pdf; candidate (from the normalbuild_docsrun onmain) =aousd_core_spec_1.1.0_<to>.pdf.Implementation spec (the non-obvious parts)
data:image/svg+xmlURIs in the rendered HTML; the persistent originals are SVGs inbuild/images/(viafilter_bundle_images).MultilinePaddingnodes in one diagram).<text>usestext-anchor=middle;xis the label center,ythe baseline. Draw an<ellipse>at(x, y-4).git diff <from>..<to> -- <spec file>). For the ballot that was 12 diagrams; use this to self-check any reimplementation.Verified doc_build code locations (
main@c918081)doc_build/doc_builder.py:500-507--pdf-engine=tectonicSVG pre-conversion comment:doc_build/doc_builder.py:514-517doc_build/ast_diff.py(diff_ast_files, generic block differ — no Image-specific handling today)build/images/:doc_build/filters/filter_bundle_images.pystabilize_tex_mediaTOCTOU-ish skip:doc_build/tools/tectonic:74Related
Suggested scope
Spike the mechanism first → then either (a) implement suppression in the diff, (b) add the changed-diagrams digest output, or both off the shared primitive. The digest is independently shippable as a post-processor on the
--diffHTML artifacts (the prototype above), if a full pipeline change isn't wanted yet.