#!/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""" SAP Diagram Review – __TITLE__

SAP Diagram Review · __TITLE__ __BADGE_LABEL__

__SCORE__ structural fidelity / 100
Open candidate in draw.io Open reference

Reference (target)__REF_NAME__

reference

Candidate (your diagram)__CAND_NAME__

candidate
reference candidate
50%
candidate
reference
Reference (left of line) Candidate (right of line)
reference candidate

Black areas = identical pixels. Bright areas = differences. Uses the browser's mix-blend-mode: difference.

Score breakdown — what to fix in priority order

__BREAKDOWN_ROWS__
DimensionScoreBar

Actionable suggestions

Notable structural diffs

__DIFFS_BLOCK__

Reference · __REF_PATH__

Candidate · __CAND_PATH__

Generated __TIMESTAMP__. After editing the candidate in draw.io desktop, re-run render_compare.py to refresh this review.

""" def score_color(score: float) -> tuple[str, str, str]: """Return (color, bg, border) tuple for the score pill.""" if score >= 95: return ("#188918", "#F5FAE5", "#C6E5C8") if score >= 85: return ("#0070F2", "#EBF8FF", "#B8DCFC") if score >= 70: return ("#C35500", "#FFF8D6", "#F2D8A6") return ("#D20A0A", "#FFEAF4", "#F5C5C0") def bar_class(pct: float) -> str: if pct >= 95: return "good" if pct >= 80: return "fine" if pct >= 60: return "warn" return "bad" def actionable_suggestions(breakdown: dict, ref_fp, cand_fp, diffs: list[str]) -> list[str]: """Map low-scoring fingerprint dimensions to concrete edit suggestions. Each rule is "if dimension X is below threshold, suggest a specific edit." This is the bridge from raw scoring to human-actionable advice. """ out: list[str] = [] if breakdown.get("page_bg", 1.0) < 1.0: out.append( "Set the canvas to white or transparent — remove " f"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'{html.escape(k)}' f'{pct:.1f}' f'
' ) if result.diffs: diffs_html = "" else: diffs_html = '' actionable = actionable_suggestions(result.breakdown, ref_fp, cand_fp, result.diffs) actionable_html = "\n".join(f"
  • {a}
  • " for a in actionable) color, bg, border = score_color(result.score) badge_label = "PASS ≥ 90" if result.score >= 90 else ( "Near-miss" if result.score >= 80 else ( "Below 80 — needs structural work" if result.score >= 60 else "Far from target" ) ) placeholders = { "TITLE": html.escape(args.candidate.stem), "BADGE_LABEL": html.escape(badge_label), "SCORE": f"{result.score:.1f}", "SCORE_COLOR": color, "SCORE_BG": bg, "SCORE_BORDER": border, "REF_NAME": html.escape(args.reference.name), "CAND_NAME": html.escape(args.candidate.name), "REF_IMG": html.escape(ref_png.name), "CAND_IMG": html.escape(cand_png.name), "REF_DRAWIO_URI": html.escape(to_file_uri(args.reference)), "CAND_DRAWIO_URI": html.escape(to_file_uri(args.candidate)), "REF_PATH": html.escape(str(args.reference)), "CAND_PATH": html.escape(str(args.candidate)), "TIMESTAMP": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), "BREAKDOWN_ROWS": "\n".join(breakdown_rows), "DIFFS_BLOCK": diffs_html, "ACTIONABLE_BLOCK": actionable_html, } html_out = HTML_TEMPLATE for key, val in placeholders.items(): html_out = html_out.replace(f"__{key}__", val) review_html = out_dir / "review.html" review_html.write_text(html_out, encoding="utf-8") summary = { "reference": str(args.reference), "candidate": str(args.candidate), "score": result.score, "breakdown": result.breakdown, "diffs": result.diffs, "ref_image": str(ref_png), "cand_image": str(cand_png), "review_html": str(review_html), } (out_dir / "review.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") print(f"score : {result.score:.1f}/100 ({badge_label})") print(f"reference : {ref_png}") print(f"candidate : {cand_png}") print(f"review : {review_html}") print(f" open with: open {review_html}") if args.open: try: subprocess.run(["open", str(review_html)], check=False) except FileNotFoundError: pass return 0 if __name__ == "__main__": sys.exit(main())