Skip to content

Implement content-aware diagram diff + changed-diagram digest (#91 bullet 2) #104

Description

@asluk

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:

  1. Suppress false highlights in the inline --diff render (the bullet-2 fix).
  2. 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 &mdash; railroad diagrams that actually changed</h1>
<div class=sub>Baseline <code>60e060e</code> (tagged v1.1.0) &rarr; <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} &nbsp; "
        f"<span class=chg><span class=old>{htmlmod.escape(olds)}</span> &rarr; "
        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.

  1. 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).
  2. 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
    
  3. 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.
  4. Validate (oracle): the printed changed set MUST equal the step-1 source diff - zero false positives. v1.1.0 = 12 of 209 diagrams.
  5. 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"
    
  6. 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)

  1. 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).
  2. Alignment: compare label lists positionally, not as sets — set-diff over/under-counts repeated tokens (the trap: multiple identical MultilinePadding nodes in one diagram).
  3. Annotation geometry: railroad <text> uses text-anchor=middle; x is the label center, y the baseline. Draw an <ellipse> at (x, y-4).
  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions