Skip to content

tools: structural + visual diff for the SVG figure renders - #82

Merged
M0LTE merged 1 commit into
mainfrom
tools/svg-visual-diff
Sep 9, 2026
Merged

M0LTE merged 1 commit into
mainfrom
tools/svg-visual-diff

Conversation

@M0LTE

@M0LTE M0LTE commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Reviewing a figure change by eye is currently impractical. The svg/ renders are auto-laid-out, so adding a single node reflows the whole page — on #81, which splices one decision diamond into two figures, a rendered image diff reports ~55 changed regions, the largest spanning half the diagram, because nearly every edge shifts by a few pixels. Off-the-shelf image-diff tools (ImageMagick compare, pixelmatch, GitHub's own swipe/onion-skin) all hit the same wall.

tools/svgdiff/svg_diff.py diffs the structure instead. The renderer emits each node as a <g transform="translate(..)"> wrapping one shape, so a node can be given a position-independent signature (shape + dimensions + label), an edge a signature of (source label → target label), and a free label its text. Diffing those as multisets makes layout movement cancel out, leaving only genuinely added or removed content.

Run against #81, the same change reduces to seven findings that read as a sentence — the N(s) == V(r)? No branch now goes through a window check, whose No side discards the frame:

spec-sdl/v2.2-errata/data-link/svg/DataLink_Connected.svg
  + node   V(r) < N(s) < V(r) + k?
  + label  No
  + label  Yes
  + edge   N(s) == V(r)? → V(r) < N(s) < V(r) + k?
  + edge   V(r) < N(s) < V(r) + k? → Discard Contents of I Frame
  + edge   V(r) < N(s) < V(r) + k? → Reject Exception?
  − edge   N(s) == V(r)? → Reject Exception?

What's in it

  • svg_struct.py — parses a render into nodes / edges / free labels and diffs the signatures. Node counts come out as a sanity check (181→182, edges 209→211).
  • svg_diff.py — the CLI.
  • viewer_template.html — a self-contained HTML viewer, written per figure with both renders embedded as data URIs. No network, no dependencies, opens straight from disk. New / Old / Swipe / Onion-skin / Blink / Difference views, plus a clickable change list down the side that zooms to each change and flips to the old render for removed content. It also computes a raw pixel diff in-browser, kept behind a Structural ⇄ Pixel toggle for the cases where a purely cosmetic render change needs checking.

Usage

# every SVG that differs on a branch, against the point it forked from
python3 tools/svgdiff/svg_diff.py main my-branch --all --merge-base

# just the change list, as a table for a PR comment
python3 tools/svgdiff/svg_diff.py main my-branch --all --merge-base --format markdown --no-html

Viewers land in the now-gitignored build/svgdiff/. Stdlib Python only — no new CI dependency, and --format markdown --no-html is ready to drop into a workflow that posts the change list on any PR touching spec-sdl/**/svg/. I have not added that workflow here; happy to follow up with it if you want it.

Verification

Exercised against #81 (both figures), a pure-reflow case, an added-file case, and a no-change case; the generated viewers were rendered in headless Chrome to confirm the change list, the zoom-to-change navigation and the old/new flip all behave.

🤖 Generated with Claude Code

https://claude.ai/code/session_0188JcqNg5JDQqdsBt7LrbyE

The svg/ renders are auto-laid-out, so adding a single node reflows the
whole page. A plain pixel diff of the render is therefore unreadable for
review: on PR #81, which splices one decision diamond into two figures, a
rendered image diff reports ~55 changed regions, the largest spanning half
the diagram, because nearly every edge shifts by a few pixels.

tools/svgdiff/svg_diff.py diffs the structure instead. The renderer emits
each node as a <g transform="translate(..)"> wrapping one shape, so a node
can be given a position-independent signature (shape + dimensions + label),
an edge a signature of (source label -> target label), and a free label its
text. Diffing those as multisets makes layout movement cancel out, leaving
only genuinely added or removed content. The same PR reduces to seven
findings that read as a sentence:

  + node   V(r) < N(s) < V(r) + k?
  + edge   N(s) == V(r)? -> V(r) < N(s) < V(r) + k?
  + edge   V(r) < N(s) < V(r) + k? -> Discard Contents of I Frame
  - edge   N(s) == V(r)? -> Reject Exception?

Alongside the change list it writes a self-contained HTML viewer per figure
(both renders embedded as data URIs; no network, no dependencies) with
new/old/swipe/onion-skin/blink/difference views and a clickable change list
that zooms to each change, flipping to the old render for removed content.
The viewer also computes a raw pixel diff in-browser, kept behind a
Structural/Pixel toggle for the cases where a purely cosmetic render change
needs checking.

--all walks every SVG differing between two revisions, --merge-base compares
a branch against its fork point, and --format markdown emits the change list
as a table suitable for posting as a PR comment. Viewers are written to the
now-gitignored build/svgdiff/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188JcqNg5JDQqdsBt7LrbyE
@M0LTE
M0LTE merged commit 151409d into main Sep 9, 2026
2 checks passed
@M0LTE
M0LTE deleted the tools/svg-visual-diff branch September 9, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant