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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions