Files
sap-architecture/scripts/render_compare.py
T
2026-07-24 20:47:16 +00:00

960 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SAP Diagram Review __TITLE__</title>
<style>
:root {
color-scheme: light;
--bg: #f5f6f7;
--card: #ffffff;
--border: #d5dadd;
--primary: #0070f2;
--good: #188918;
--warn: #c35500;
--bad: #d20a0a;
--text: #1d2d3e;
--muted: #556b82;
--shadow: 0 2px 8px rgba(29, 45, 62, 0.06);
}
* { box-sizing: border-box; }
body {
font-family: Helvetica, Arial, sans-serif;
margin: 0;
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
header {
padding: 16px 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
header h1 {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 1;
min-width: 280px;
}
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--bg);
color: var(--muted);
border-radius: 4px;
margin-left: 8px;
}
.score-pill {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 18px;
background: var(--score-bg, var(--bg));
border: 1px solid var(--score-border, var(--border));
border-radius: 999px;
}
.score-pill .num {
font-size: 28px;
font-weight: 700;
color: var(--score-color, var(--text));
font-variant-numeric: tabular-nums;
}
.score-pill .lbl {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.actions {
display: flex;
gap: 8px;
}
.actions a, .actions button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
border-radius: 6px;
text-decoration: none;
cursor: pointer;
transition: background 80ms;
}
.actions a:hover, .actions button:hover {
background: var(--bg);
}
.actions a.primary, .actions button.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.actions a.primary:hover, .actions button.primary:hover {
filter: brightness(1.06);
}
nav.modes {
padding: 0 24px;
background: var(--card);
border-bottom: 1px solid var(--border);
display: flex;
gap: 4px;
}
nav.modes button {
background: transparent;
border: none;
padding: 12px 16px;
font-size: 13px;
font-weight: 500;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-family: inherit;
}
nav.modes button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
nav.modes button:hover { color: var(--text); }
.stage {
padding: 16px 24px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
/* Mode: side-by-side */
.mode { display: none; }
.mode.active { display: block; }
.sbs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1024px) {
.sbs { grid-template-columns: 1fr; }
}
.pane h2 {
margin: 0;
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
background: #fafbfc;
display: flex;
justify-content: space-between;
align-items: center;
}
.pane h2 .who {
display: flex;
align-items: center;
gap: 8px;
}
.pane h2 .who::before {
content: "";
width: 8px; height: 8px; border-radius: 50%;
}
.pane h2.ref .who::before { background: var(--good); }
.pane h2.cand .who::before { background: var(--primary); }
.pane figure {
margin: 0;
padding: 12px;
background: #fff;
text-align: center;
}
.pane figure img {
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
cursor: zoom-in;
}
/* Mode: overlay slider */
.overlay-wrap {
padding: 14px;
text-align: center;
background: #fff;
}
.overlay-stage {
position: relative;
display: inline-block;
max-width: 100%;
}
.overlay-stage img {
display: block;
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
}
.overlay-stage img.cand {
position: absolute;
top: 0; left: 0;
pointer-events: none;
}
.overlay-controls {
padding: 14px 24px 18px;
display: flex;
align-items: center;
gap: 14px;
justify-content: center;
background: #fafbfc;
border-top: 1px solid var(--border);
}
.overlay-controls label {
font-size: 13px;
color: var(--muted);
}
.overlay-controls input[type=range] {
width: 280px;
}
.opacity-readout {
width: 56px;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text);
font-weight: 600;
}
/* Mode: swipe / curtain */
.swipe-wrap {
padding: 14px;
background: #fff;
text-align: center;
}
.swipe-stage {
position: relative;
display: inline-block;
max-width: 100%;
cursor: ew-resize;
user-select: none;
}
.swipe-stage img {
display: block;
max-width: 100%;
max-height: 78vh;
border: 1px solid var(--border);
}
.swipe-stage .clip {
position: absolute;
top: 0; left: 0;
bottom: 0;
overflow: hidden;
width: 50%;
border-right: 2px solid var(--primary);
pointer-events: none;
}
.swipe-stage .clip img {
max-width: none;
width: auto;
}
.swipe-handle {
position: absolute;
top: 50%; transform: translateY(-50%);
width: 32px; height: 32px;
background: var(--primary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
cursor: ew-resize;
box-shadow: 0 2px 8px rgba(0, 112, 242, 0.4);
pointer-events: auto;
}
.swipe-labels {
display: flex;
justify-content: space-between;
padding: 8px 24px;
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
background: #fafbfc;
border-top: 1px solid var(--border);
}
/* Mode: difference (CSS blend) */
.diff-wrap {
padding: 14px;
background: #1d2d3e;
text-align: center;
}
.diff-stage {
position: relative;
display: inline-block;
}
.diff-stage img {
max-width: 100%;
max-height: 78vh;
display: block;
}
.diff-stage .top {
position: absolute;
top: 0; left: 0;
mix-blend-mode: difference;
}
.diff-note {
padding: 12px 24px;
background: #1d2d3e;
color: #d5dadd;
font-size: 12px;
text-align: center;
border-top: 1px solid #354a5f;
}
/* Score breakdown */
.breakdown {
padding: 18px 24px 8px;
}
.breakdown h3 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.breakdown table {
border-collapse: collapse;
width: 100%;
max-width: 920px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.breakdown th, .breakdown td {
padding: 9px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
text-align: left;
}
.breakdown th {
background: #fafbfc;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
}
.breakdown td.score {
text-align: right;
font-variant-numeric: tabular-nums;
width: 80px;
font-weight: 600;
}
.breakdown td.bar-cell {
width: 280px;
}
.bar {
height: 7px;
background: #eaecee;
border-radius: 3px;
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
transition: width 200ms;
}
.bar > span.good { background: var(--good); }
.bar > span.fine { background: var(--primary); }
.bar > span.warn { background: var(--warn); }
.bar > span.bad { background: var(--bad); }
/* Suggestions and diffs */
.insights {
padding: 18px 24px 28px;
max-width: 1180px;
}
.insights h3 {
margin: 18px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.insights ul {
margin: 0;
padding: 0;
list-style: none;
}
.insights li {
padding: 12px 14px;
margin-bottom: 8px;
background: var(--card);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 6px;
font-size: 13px;
}
.insights li.diff {
border-left-color: var(--warn);
}
.insights code {
background: #f5f6f7;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
font-family: Menlo, Consolas, monospace;
}
.insights .empty {
color: var(--muted);
font-style: italic;
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(29, 45, 62, 0.85);
display: none;
align-items: center;
justify-content: center;
cursor: zoom-out;
z-index: 50;
}
.lightbox.active { display: flex; }
.lightbox img {
max-width: 95vw;
max-height: 95vh;
}
/* Meta footer */
.meta {
padding: 14px 24px 30px;
font-size: 12px;
color: var(--muted);
}
.meta p {
margin: 4px 0;
}
.meta code {
background: var(--card);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
}
</style>
</head>
<body>
<header>
<h1>SAP Diagram Review · __TITLE__ <span class="badge">__BADGE_LABEL__</span></h1>
<div class="score-pill" style="--score-color: __SCORE_COLOR__; --score-bg: __SCORE_BG__; --score-border: __SCORE_BORDER__">
<span class="num">__SCORE__</span>
<span class="lbl">structural fidelity / 100</span>
</div>
<div class="actions">
<a class="primary" href="__CAND_DRAWIO_URI__" title="Open the candidate in draw.io desktop">Open candidate in draw.io</a>
<a href="__REF_DRAWIO_URI__" title="Open the reference template in draw.io desktop">Open reference</a>
</div>
</header>
<nav class="modes" id="mode-nav">
<button data-mode="sbs" class="active">Side-by-side</button>
<button data-mode="overlay">Overlay slider</button>
<button data-mode="swipe">Swipe</button>
<button data-mode="diff">Difference</button>
</nav>
<div class="stage">
<!-- Side-by-side -->
<div class="mode active" id="m-sbs">
<div class="sbs">
<section class="panel pane">
<h2 class="ref"><span class="who">Reference (target)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__REF_NAME__</span></h2>
<figure><img src="__REF_IMG__" alt="reference" data-zoom></figure>
</section>
<section class="panel pane">
<h2 class="cand"><span class="who">Candidate (your diagram)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__CAND_NAME__</span></h2>
<figure><img src="__CAND_IMG__" alt="candidate" data-zoom></figure>
</section>
</div>
</div>
<!-- Overlay slider -->
<div class="mode" id="m-overlay">
<section class="panel">
<div class="overlay-wrap">
<div class="overlay-stage">
<img src="__REF_IMG__" alt="reference">
<img src="__CAND_IMG__" alt="candidate" class="cand" id="overlay-cand" style="opacity: 0.5">
</div>
</div>
<div class="overlay-controls">
<label>Reference</label>
<input type="range" min="0" max="100" value="50" id="overlay-slider">
<label>Candidate</label>
<span class="opacity-readout" id="overlay-readout">50%</span>
</div>
</section>
</div>
<!-- Swipe / curtain -->
<div class="mode" id="m-swipe">
<section class="panel">
<div class="swipe-wrap">
<div class="swipe-stage" id="swipe-stage">
<img src="__CAND_IMG__" alt="candidate" id="swipe-bg">
<div class="clip" id="swipe-clip">
<img src="__REF_IMG__" alt="reference" id="swipe-fg">
</div>
<div class="swipe-handle" id="swipe-handle">↔</div>
</div>
</div>
<div class="swipe-labels">
<span>Reference (left of line)</span>
<span>Candidate (right of line)</span>
</div>
</section>
</div>
<!-- Difference (CSS blend) -->
<div class="mode" id="m-diff">
<section class="panel">
<div class="diff-wrap">
<div class="diff-stage">
<img src="__REF_IMG__" alt="reference">
<img src="__CAND_IMG__" alt="candidate" class="top">
</div>
</div>
<p class="diff-note">Black areas = identical pixels. Bright areas = differences. Uses the browser's <code>mix-blend-mode: difference</code>.</p>
</section>
</div>
</div>
<section class="breakdown">
<h3>Score breakdown — what to fix in priority order</h3>
<table>
<thead><tr><th>Dimension</th><th class="score">Score</th><th class="bar-cell">Bar</th></tr></thead>
<tbody>
__BREAKDOWN_ROWS__
</tbody>
</table>
</section>
<section class="insights">
<h3>Actionable suggestions</h3>
<ul>
__ACTIONABLE_BLOCK__
</ul>
<h3>Notable structural diffs</h3>
__DIFFS_BLOCK__
</section>
<section class="meta">
<p><strong>Reference</strong> · <code>__REF_PATH__</code></p>
<p><strong>Candidate</strong> · <code>__CAND_PATH__</code></p>
<p>Generated <code>__TIMESTAMP__</code>. After editing the candidate in draw.io desktop, re-run <code>render_compare.py</code> to refresh this review.</p>
</section>
<div class="lightbox" id="lightbox"><img id="lightbox-img" alt="zoomed"></div>
<script>
// Mode switcher
document.getElementById('mode-nav').addEventListener('click', e => {
if (e.target.tagName !== 'BUTTON') return;
const target = e.target.dataset.mode;
document.querySelectorAll('#mode-nav button').forEach(b => b.classList.toggle('active', b.dataset.mode === target));
document.querySelectorAll('.mode').forEach(m => m.classList.toggle('active', m.id === 'm-' + target));
});
// Overlay slider
const overlaySlider = document.getElementById('overlay-slider');
const overlayCand = document.getElementById('overlay-cand');
const overlayReadout = document.getElementById('overlay-readout');
overlaySlider.addEventListener('input', () => {
const v = overlaySlider.value;
overlayCand.style.opacity = v / 100;
overlayReadout.textContent = v + '%';
});
// Swipe / curtain — keep reference image fully sized in the clip; resize on window/image load
const swipeStage = document.getElementById('swipe-stage');
const swipeClip = document.getElementById('swipe-clip');
const swipeFg = document.getElementById('swipe-fg');
const swipeBg = document.getElementById('swipe-bg');
const swipeHandle = document.getElementById('swipe-handle');
function syncSwipeFgSize() {
const r = swipeBg.getBoundingClientRect();
swipeFg.style.width = r.width + 'px';
swipeFg.style.height = r.height + 'px';
}
swipeBg.addEventListener('load', syncSwipeFgSize);
window.addEventListener('resize', syncSwipeFgSize);
let dragging = false;
function setSwipe(clientX) {
const r = swipeStage.getBoundingClientRect();
let pct = ((clientX - r.left) / r.width) * 100;
pct = Math.max(0, Math.min(100, pct));
swipeClip.style.width = pct + '%';
swipeHandle.style.left = pct + '%';
}
swipeStage.addEventListener('mousedown', e => { dragging = true; setSwipe(e.clientX); e.preventDefault(); });
window.addEventListener('mousemove', e => { if (dragging) setSwipe(e.clientX); });
window.addEventListener('mouseup', () => { dragging = false; });
swipeStage.addEventListener('touchstart', e => { dragging = true; setSwipe(e.touches[0].clientX); }, { passive: true });
window.addEventListener('touchmove', e => { if (dragging) setSwipe(e.touches[0].clientX); }, { passive: true });
window.addEventListener('touchend', () => { dragging = false; });
// Initialize handle position
window.addEventListener('load', () => {
syncSwipeFgSize();
const r = swipeStage.getBoundingClientRect();
swipeHandle.style.left = '50%';
});
// Lightbox click-to-zoom on side-by-side images
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
document.querySelectorAll('img[data-zoom]').forEach(img => {
img.addEventListener('click', () => {
lightboxImg.src = img.src;
lightbox.classList.add('active');
});
});
lightbox.addEventListener('click', () => lightbox.classList.remove('active'));
// Keyboard shortcuts: 1/2/3/4 to switch modes, Esc closes lightbox
document.addEventListener('keydown', e => {
if (e.key === 'Escape') lightbox.classList.remove('active');
const idx = ['1', '2', '3', '4'].indexOf(e.key);
if (idx >= 0) {
const modes = ['sbs', 'overlay', 'swipe', 'diff'];
document.querySelector(`#mode-nav button[data-mode=${modes[idx]}]`).click();
}
});
</script>
</body>
</html>
"""
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"<code>pageBackgroundColor=\"{cand_fp.page_background or '?'}\"</code>"
" from <code>&lt;mxGraphModel&gt;</code>. SAP diagrams are always on white."
)
if breakdown.get("canvas", 1.0) < 1.0:
out.append(
f"Resize canvas to <code>{ref_fp.canvas_w}×{ref_fp.canvas_h}</code> "
f"(currently <code>{cand_fp.canvas_w}×{cand_fp.canvas_h}</code>). "
"In draw.io: <em>Diagram → Page Setup → Custom</em>."
)
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) "
"(<code>arcSize=16</code>, <code>strokeWidth=1.5</code>)."
)
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 "
"<code>scripts/extract_icon.py \"&lt;service&gt;\" --x &lt;X&gt; --y &lt;Y&gt;</code>."
)
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: <code>TRUST</code>, <code>Authenticate</code>, "
"<code>Authorization</code>, <code>A2A</code>, <code>MCP</code>, "
"<code>ORD</code>, <code>HTTPS</code>, <code>OData/REST</code>, "
"<code>SAML2/OIDC</code>, <code>SCIM</code>. 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'<span style="display:inline-block;width:14px;height:14px;background:{c};'
f'border-radius:3px;border:1px solid var(--border);vertical-align:middle"></span>'
f' <code>{c}</code>'
for c in missing[:6]
)
out.append(
f"Add SAP-mandated connector colors on edges: {swatch}. "
"trust=<code>#CC00DC</code> pink · auth=<code>#188918</code> green · "
"authorization=<code>#5D36FF</code> indigo · structural=<code>#475E75</code> 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 "
"<code>autofix.py --write &lt;file&gt;</code> 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 <strong>Swipe</strong> or "
"<strong>Difference</strong> 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'<tr><td>{html.escape(k)}</td>'
f'<td class="score">{pct:.1f}</td>'
f'<td class="bar-cell"><div class="bar"><span class="{cls}" style="width: {pct:.1f}%"></span></div></td></tr>'
)
if result.diffs:
diffs_html = "<ul>"
for d in result.diffs:
diffs_html += f'<li class="diff">{html.escape(d)}</li>'
diffs_html += "</ul>"
else:
diffs_html = '<ul><li class="empty">No notable structural differences detected.</li></ul>'
actionable = actionable_suggestions(result.breakdown, ref_fp, cand_fp, result.diffs)
actionable_html = "\n".join(f" <li>{a}</li>" 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())