#!/usr/bin/env python3 """Render a candidate + reference `.drawio` to PNG and produce a side-by-side HTML review page with overlay-swipe, hover-zoom, and tabs for the different comparison modes. Why this matters: The structural fingerprint score (`compare.py`) is necessary but insufficient for the last 20% of diagram polish. Two diagrams with similar fingerprints can look very different. The honest workflow is: scaffold → manual edit → render → side-by-side compare → iterate. This script collapses "render + compare + review" into one command so the human iteration loop is fast. The HTML supports four review modes: 1. Side-by-side: classic two-pane comparison. 2. Overlay slider: drag a slider to fade between reference and candidate. Catches subtle position/size drifts. 3. Swipe (curtain): vertical dividing line you drag — left half reference, right half candidate. Fastest way to spot zone-level differences. 4. Difference: stacked images with subtraction blend mode (browser- native), no Pillow dependency. Usage: render_compare.py reference.drawio candidate.drawio → writes review.html plus reference.png + candidate.png next to it render_compare.py reference.drawio candidate.drawio \ --out-dir .cache/review/agentic-ai/ --open render_compare.py reference.drawio candidate.drawio \ --scale 1.5 --transparent Exit code: 0 — review HTML written 1 — render or compare failed 2 — usage / draw.io CLI not found """ from __future__ import annotations import argparse import html import json import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path THIS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(THIS_DIR)) import render as _render # noqa: E402 drawio CLI wrapper import compare as _compare # noqa: E402 fingerprint comparison HTML_TEMPLATE = r"""
Black areas = identical pixels. Bright areas = differences. Uses the browser's mix-blend-mode: difference.
| Dimension | Score | Bar |
|---|
pageBackgroundColor=\"{cand_fp.page_background or '?'}\""
" from <mxGraphModel>. SAP diagrams are always on white."
)
if breakdown.get("canvas", 1.0) < 1.0:
out.append(
f"Resize canvas to {ref_fp.canvas_w}×{ref_fp.canvas_h} "
f"(currently {cand_fp.canvas_w}×{cand_fp.canvas_h}). "
"In draw.io: Diagram → Page Setup → Custom."
)
if breakdown.get("zones", 1.0) < 0.7:
delta = abs(ref_fp.zones - cand_fp.zones)
verb = "add" if cand_fp.zones < ref_fp.zones else "remove"
out.append(
f"Zone count differs (ref={ref_fp.zones}, cand={cand_fp.zones}). "
f"{verb.capitalize()} {delta} rounded-corner area container(s) "
"(arcSize=16, strokeWidth=1.5)."
)
if breakdown.get("zone_depth", 1.0) < 1.0:
out.append(
f"Zone nesting depth differs (ref={ref_fp.zone_depth}, cand={cand_fp.zone_depth}). "
"Watch for cases where a focus zone (Joule, Identity Services) is nested inside BTP "
"when the SAP reference puts them side-by-side."
)
if breakdown.get("icons", 1.0) < 0.7:
delta = abs(ref_fp.icons - cand_fp.icons)
verb = "add" if cand_fp.icons < ref_fp.icons else "remove"
out.append(
f"Icon count differs (ref={ref_fp.icons}, cand={cand_fp.icons}). "
f"{verb.capitalize()} {delta} BTP service icon(s) using "
"scripts/extract_icon.py \"<service>\" --x <X> --y <Y>."
)
if breakdown.get("pill_vocab", 1.0) < 1.0:
if cand_fp.novelty_pill_count:
out.append(
f"Replace {cand_fp.novelty_pill_count} novelty pill verb(s). "
"SAP-canonical pill labels: TRUST, Authenticate, "
"Authorization, A2A, MCP, "
"ORD, HTTPS, OData/REST, "
"SAML2/OIDC, SCIM. Avoid "
"PROMPT/ROUTE/CONTEXT/DELEGATE/INVOKE/FETCH."
)
if breakdown.get("edge_palette", 1.0) < 0.6:
missing = sorted(set(ref_fp.edge_palette) - set(cand_fp.edge_palette))
if missing:
swatch = " ".join(
f''
f' {c}'
for c in missing[:6]
)
out.append(
f"Add SAP-mandated connector colors on edges: {swatch}. "
"trust=#CC00DC pink · auth=#188918 green · "
"authorization=#5D36FF indigo · structural=#475E75 slate."
)
if breakdown.get("label_tokens", 1.0) < 0.6:
out.append(
"Label-token Jaccard is low — visible text drifted from the reference. "
"Restore or rename service-card labels to match the SAP scenario vocabulary."
)
if breakdown.get("grid_snap", 1.0) < 0.9:
out.append(
"Geometry off the 10-px grid. Run "
"autofix.py --write <file> to snap automatically."
)
# Surface raw diffs we couldn't classify into specific advice
for d in diffs[:3]:
if not any(d in o for o in out):
out.append(html.escape(d))
if not out:
out.append("Looks structurally close. Use the Swipe or "
"Difference tabs above to spot subtle visual drifts.")
return out
def to_file_uri(path: Path) -> str:
"""Convert an absolute path to a file:// URI for browser links."""
p = path.resolve()
# macOS/Linux: prepend /// (file:///abs/path)
return "file://" + str(p)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("reference", type=Path)
ap.add_argument("candidate", type=Path)
ap.add_argument("--out-dir", type=Path, default=None,
help="output dir for the HTML and PNGs (default: alongside candidate)")
ap.add_argument("--scale", type=float, default=1.0)
ap.add_argument("--border", type=int, default=10)
ap.add_argument("--transparent", action="store_true")
ap.add_argument("--open", action="store_true", help="open the HTML in default browser")
args = ap.parse_args()
if not args.reference.exists():
print(f"reference {args.reference}: not found", file=sys.stderr)
return 1
if not args.candidate.exists():
print(f"candidate {args.candidate}: not found", file=sys.stderr)
return 1
cli = _render.find_drawio_cli()
if not cli:
print(
"draw.io CLI not found — install draw.io desktop or set $DRAWIO_CLI.",
file=sys.stderr,
)
return 2
out_dir = args.out_dir or args.candidate.parent
out_dir.mkdir(parents=True, exist_ok=True)
ref_png = out_dir / f"{args.reference.stem}.reference.png"
cand_png = out_dir / f"{args.candidate.stem}.candidate.png"
rc = _render.render_one(cli, args.reference, ref_png, "png", args.scale, args.border, args.transparent, quiet=False)
if rc != 0:
print(f"reference render failed (rc={rc})", file=sys.stderr)
return 1
rc = _render.render_one(cli, args.candidate, cand_png, "png", args.scale, args.border, args.transparent, quiet=False)
if rc != 0:
print(f"candidate render failed (rc={rc})", file=sys.stderr)
return 1
ref_fp = _compare.fingerprint(args.reference)
cand_fp = _compare.fingerprint(args.candidate)
result = _compare.compare(ref_fp, cand_fp)
breakdown_rows: list[str] = []
for k, v in sorted(result.breakdown.items(), key=lambda kv: kv[1]):
pct = v * 100
cls = bar_class(pct)
breakdown_rows.append(
f'