#!/usr/bin/env python3 """Compare two .drawio files and produce a similarity / divergence report. The goal isn't pixel-perfect equality — it's to surface *structural* and *style* divergences so an iterative loop can drive a generated diagram toward an SAP reference. Two diagrams that draw the same scenario end up with very similar fingerprints across these dimensions: Structural * canvas size (W × H) — should match the selected SAP template * total cell count, vertex count, edge count * zone count (cells with arcSize=16, strokeWidth=1.5, fontStyle=1, top-left label) * service-icon count (cells with shape=image and SAP icon SVG data URI) * pill count (small cells with arcSize=50) Style * palette (set of hex colors, Jaccard similarity) * fonts (set of fontFamily values) * stroke widths (set) * presence of `absoluteArcSize=1`, `labelBackgroundColor=default` * grid-snap rate (% of geometries on the 10-px grid) Usage: compare.py reference.drawio candidate.drawio # human report compare.py --json reference.drawio candidate.drawio # JSON report compare.py --score reference.drawio candidate.drawio # one-line score 0..100 Score is a weighted blend of the dimensions above; 100 = identical fingerprint (not necessarily identical content), 0 = nothing in common. """ from __future__ import annotations import argparse import html import json import re import sys import xml.etree.ElementTree as ET from dataclasses import asdict, dataclass, field from pathlib import Path try: from validate import SAP_PALETTE except Exception: # pragma: no cover - compare.py can run standalone SAP_PALETTE = { "#0070F2", "#EBF8FF", "#475E75", "#F5F6F7", "#1D2D3E", "#556B82", "#188918", "#F5FAE5", "#C35500", "#FFF8D6", "#D20A0A", "#FFEAF4", "#07838F", "#DAFDF5", "#5D36FF", "#F1ECFF", "#CC00DC", "#FFF0FA", "#FFFFFF", "#FFF", "#000000", "#000", "#FCFCFC", } HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b") DATA_URI_RE = re.compile(r"data:image/[^&\";]+") INLINE_SVG_ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg") STENCIL_ICON_RE = re.compile(r"shape=mxgraph\.sap\.icon") ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg|shape=mxgraph\.sap\.icon") EXTERNAL_IMAGE_RE = re.compile(r"shape=image[^\"]*image=https?://|image=https?://") SHAPE_RE = re.compile(r"(?:^|;)shape=([^;\"]+)") ARC16_RE = re.compile(r"arcSize=16\b") ARC50_RE = re.compile(r"arcSize=50\b") ABS_ARC_RE = re.compile(r"absoluteArcSize=1\b") LABEL_BG_RE = re.compile(r"labelBackgroundColor=default\b") ZONE_HINT_RE = re.compile(r"strokeWidth=1\.5[^\"]*fontStyle=1|fontStyle=1[^\"]*strokeWidth=1\.5") PILL_HINT_RE = re.compile(r"arcSize=50[^\"]*strokeWidth=1\b|strokeWidth=1\b[^\"]*arcSize=50") FONT_RE = re.compile(r"fontFamily=([^;\"]+)") STROKE_RE = re.compile(r"strokeWidth=([0-9.]+)") LINE_RE = re.compile(r"^line", re.I) PAGE_BG_RE = re.compile(r'(?:background|pageBackgroundColor)="([^"]+)"') # Canonical SAP flow-pill vocabulary, kept in sync with validate.py. CANONICAL_PILL_VOCAB = { "trust", "authenticate", "authentication", "authorization", "identity", "identity lifecycle", "customer-managed identity lifecycle", "user", "usergroup", "group", "role", "role collection", "role collections", "policy", "scim", "saml2/oidc", "oidc", "saml", "openid", "https", "https/active", "https/standby", "rest", "rest/spi", "rest/token", "rest / odata", "odata/rest", "odata/rest/soap", "destination", "source", "target", "harmonized api", "data federation", "data sync", "task data", "a2a", "mcp", "ord", "business data cloud", "business role", "cdm", "role replica", "commit", "build & test", "release", "deploy", "connectivity", "private link", "sql", "security logs", "alerts, findings & enriched events", "correlated incidents", "status & closure updates", "notification", "open ticket", "data", "metadata", } STOPWORDS = { "a", "an", "and", "app", "apps", "architecture", "as", "at", "be", "by", "cloud", "create", "diagram", "for", "from", "in", "into", "is", "l0", "l1", "l2", "of", "on", "or", "page", "ref", "reference", "sap", "show", "solution", "style", "the", "to", "use", "using", "via", "with", } TOKEN_CANONICAL = { "adminstrator": "administrator", "plaforms": "platforms", "provisoning": "provisioning", } SAP_REFERENCE_GRID_BASELINE = 0.23 # --- Fingerprint --------------------------------------------------------------- @dataclass class Fingerprint: path: str canvas_w: int = 0 canvas_h: int = 0 cells_total: int = 0 vertices: int = 0 edges: int = 0 zones: int = 0 icons: int = 0 icons_inline: int = 0 # bundled inline-SVG icons (preferred) icons_stencil: int = 0 # mxgraph.sap.icon stencil legacy external_images: int = 0 pills: int = 0 grid_snap_rate: float = 0.0 has_absolute_arc: bool = False has_label_bg: bool = False palette: set[str] = field(default_factory=set) edge_palette: set[str] = field(default_factory=set) # strokeColors used on edges fonts: set[str] = field(default_factory=set) stroke_widths: set[float] = field(default_factory=set) shapes: set[str] = field(default_factory=set) label_count: int = 0 label_tokens: set[str] = field(default_factory=set) pill_vocab: set[str] = field(default_factory=set) canonical_pill_count: int = 0 novelty_pill_count: int = 0 page_background: str = "" zone_depth: int = 0 # max nested zone depth observed sap_logo_count: int = 0 def split_words(text: str) -> list[str]: text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text) text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text) text = text.replace("_", " ").replace("-", " ").replace("/", " ") return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)] def clean_label(value: str) -> str: value = html.unescape(value) value = re.sub(r"", " ", value, flags=re.I) value = re.sub(r"<[^>]+>", " ", value) value = re.sub(r" ", " ", value) return re.sub(r"\s+", " ", value).strip() def tokens(text: str) -> set[str]: out: set[str] = set() words = split_words(text) joined = "".join(words) for word in words: word = TOKEN_CANONICAL.get(word, word) if len(word) >= 2 and word not in STOPWORDS: out.add(word) for compact in ("xsuaa", "privatelink", "workzone", "eventmesh", "multiaz", "multiregion", "businessdatacloud"): if compact in joined: out.add(compact) if "businessdatacloud" in out: out.add("bdc") if "cloudconnector" in joined: out.add("cloudconnector") if "principalpropagation" in joined: out.add("principalpropagation") if "s4hana" in joined or "4hana" in out: out.add("s4hana") return out def parse_style_dict(style: str) -> dict[str, str]: out: dict[str, str] = {} if not style: return out for part in style.split(";"): part = part.strip() if not part or "=" not in part: continue k, v = part.split("=", 1) out[k.strip()] = v.strip() return out def fingerprint(path: Path) -> Fingerprint: fp = Fingerprint(path=str(path)) text = path.read_text(encoding="utf-8") try: root = ET.parse(path).getroot() except ET.ParseError: palette_text = DATA_URI_RE.sub("", text) fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)} fp.fonts = set(FONT_RE.findall(palette_text)) fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)} fp.has_absolute_arc = bool(ABS_ARC_RE.search(text)) fp.has_label_bg = bool(LABEL_BG_RE.search(text)) bg_match = PAGE_BG_RE.search(palette_text) if bg_match: fp.page_background = bg_match.group(1).strip().lower() return fp graph = root.find(".//mxGraphModel") scope = graph if graph is not None else root scope_text = ET.tostring(scope, encoding="unicode") palette_text = DATA_URI_RE.sub("", scope_text) fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)} fp.fonts = set(FONT_RE.findall(palette_text)) fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)} fp.has_absolute_arc = bool(ABS_ARC_RE.search(scope_text)) fp.has_label_bg = bool(LABEL_BG_RE.search(scope_text)) bg_match = PAGE_BG_RE.search(palette_text) if bg_match: fp.page_background = bg_match.group(1).strip().lower() if graph is not None: fp.canvas_w = int(graph.get("pageWidth") or graph.get("dx") or 0) fp.canvas_h = int(graph.get("pageHeight") or graph.get("dy") or 0) if not fp.page_background: bg = (graph.get("background") or graph.get("pageBackgroundColor") or "").strip().lower() if bg: fp.page_background = bg cells = scope.findall(".//mxCell") fp.cells_total = len(cells) coords: list[float] = [] labels: set[str] = set() for elem in scope.iter(): for attr in ("name", "label", "value"): raw = elem.get(attr) if not raw: continue label = clean_label(raw) if label: labels.add(label) fp.label_count = len(labels) for label in labels: fp.label_tokens |= tokens(label) # Map cell id → cell so we can compute parent-zone nesting depth. cells_by_id: dict[str, ET.Element] = {} for c in cells: cid = c.get("id") if cid: cells_by_id[cid] = c parent_by_elem = {id(child): parent for parent in scope.iter() for child in list(parent)} def is_zone_cell(c: ET.Element) -> bool: style_text = c.get("style") or "" if not ARC16_RE.search(style_text): return False if "strokeWidth=1.5" not in style_text: return False # SAP zone styling encodes bold via inline HTML in `value`, not fontStyle. # So we accept zone cells with arcSize=16 + strokeWidth=1.5 even without # fontStyle=1 — a critical fix for accurate zone counting. return True zone_ids: set[str] = set() for c in cells: if c.get("vertex") == "1": fp.vertices += 1 style = c.get("style") or "" sd = parse_style_dict(style) inline_icon = bool(INLINE_SVG_ICON_RE.search(style)) stencil_icon = bool(STENCIL_ICON_RE.search(style)) if inline_icon: fp.icons += 1 fp.icons_inline += 1 elif stencil_icon: fp.icons += 1 fp.icons_stencil += 1 if EXTERNAL_IMAGE_RE.search(style): fp.external_images += 1 image = sd.get("image", "") if image and "sap_logo" in image.lower(): fp.sap_logo_count += 1 for shape in SHAPE_RE.findall(style): if shape != "image": fp.shapes.add(shape) if ARC50_RE.search(style): fp.pills += 1 # capture pill label vocabulary raw_label = c.get("value") or "" if not raw_label: parent = parent_by_elem.get(id(c)) if parent is not None and parent.tag == "UserObject": raw_label = parent.get("value") or parent.get("label") or "" pill_label = clean_label(raw_label).strip().lower() if pill_label: fp.pill_vocab.add(pill_label) if pill_label in CANONICAL_PILL_VOCAB: fp.canonical_pill_count += 1 else: fp.novelty_pill_count += 1 elif is_zone_cell(c): fp.zones += 1 cid = c.get("id") if cid: zone_ids.add(cid) geo = c.find("mxGeometry") if geo is not None: for attr in ("x", "y", "width", "height"): v = geo.get(attr) if v is not None: try: coords.append(float(v)) except ValueError: pass elif c.get("edge") == "1": fp.edges += 1 style = c.get("style") or "" sd = parse_style_dict(style) stroke = sd.get("strokeColor", "").upper() if stroke and stroke.startswith("#"): fp.edge_palette.add(stroke) # Zone nesting depth: count how many zone cells appear in the parent chain. def zone_depth_for(c: ET.Element) -> int: depth = 0 parent_id = c.get("parent") seen = set() while parent_id and parent_id not in seen: seen.add(parent_id) parent_cell = cells_by_id.get(parent_id) if parent_cell is None: break if parent_cell.get("id") in zone_ids: depth += 1 parent_id = parent_cell.get("parent") return depth if zone_ids: max_depth = 0 for c in cells: if c.get("vertex") != "1": continue d = zone_depth_for(c) if d > max_depth: max_depth = d fp.zone_depth = max_depth if coords: snapped = sum(1 for v in coords if abs(v - round(v)) < 1e-6 and round(v) % 10 == 0) fp.grid_snap_rate = snapped / len(coords) return fp # --- Comparison ---------------------------------------------------------------- def jaccard(a: set, b: set) -> float: if not a and not b: return 1.0 return len(a & b) / max(1, len(a | b)) @dataclass class CompareResult: score: float = 0.0 breakdown: dict = field(default_factory=dict) diffs: list[str] = field(default_factory=list) @dataclass class SapLikenessResult: score: float = 0.0 breakdown: dict[str, float] = field(default_factory=dict) issues: list[str] = field(default_factory=list) def sap_likeness(fp: Fingerprint, *, validator_errors: int = 0) -> SapLikenessResult: """Reference-free SAP Architecture Center style score.""" result = SapLikenessResult() parts: dict[str, float] = {} accepted_bgs = {"", "none", "default", "#ffffff", "#fff"} parts["page_bg"] = 1.0 if fp.page_background.lower() in accepted_bgs else 0.0 if not parts["page_bg"]: result.issues.append(f"non-white page background {fp.page_background!r}") parts["validator_errors"] = 1.0 if validator_errors == 0 else 0.0 if validator_errors: result.issues.append(f"{validator_errors} validator error(s)") parts["zones"] = min(1.0, fp.zones / 1.0) if fp.zones == 0: result.issues.append("no SAP-style zones detected") parts["icons"] = min(1.0, fp.icons / 3.0) if fp.vertices >= 4 else min(1.0, fp.icons / 1.0) if fp.icons == 0: result.issues.append("no bundled/icon-library assets detected") parts["pills"] = min(1.0, fp.pills / 3.0) if fp.edges >= 2 else 1.0 if fp.edges >= 2 and fp.pills == 0: result.issues.append("no SAP-style flow pills detected") if fp.pills: parts["pill_vocab"] = max(0.0, 1.0 - (fp.novelty_pill_count / max(1, fp.pills))) else: parts["pill_vocab"] = 0.8 if parts["pill_vocab"] < 1.0: result.issues.append(f"{fp.novelty_pill_count} non-canonical pill label(s)") sap_palette = {color.upper() for color in SAP_PALETTE} visible_palette = {color.upper() for color in fp.palette} if visible_palette: parts["palette"] = len(visible_palette & sap_palette) / len(visible_palette) else: parts["palette"] = 1.0 if parts["palette"] < 1.0: result.issues.append(f"off-palette colors: {sorted(visible_palette - sap_palette)[:6]}") if fp.edge_palette: parts["edge_palette"] = len(fp.edge_palette & sap_palette) / len(fp.edge_palette) else: parts["edge_palette"] = 1.0 fonts = {font.lower() for font in fp.fonts} parts["fonts"] = 1.0 if not fonts or fonts <= {"helvetica", "arial"} else 0.0 if not parts["fonts"]: result.issues.append(f"non-SAP font families: {sorted(fp.fonts)}") allowed_strokes = {1.0, 1.5, 2.0, 3.0, 4.0} if fp.stroke_widths: parts["strokes"] = len(fp.stroke_widths & allowed_strokes) / len(fp.stroke_widths) else: parts["strokes"] = 1.0 if parts["strokes"] < 1.0: result.issues.append(f"non-standard stroke widths: {sorted(fp.stroke_widths - allowed_strokes)}") parts["abs_arc"] = 1.0 if fp.has_absolute_arc else 0.6 parts["label_bg"] = 1.0 if fp.has_label_bg or fp.edges == 0 else 0.8 parts["grid_snap"] = min(1.0, fp.grid_snap_rate / SAP_REFERENCE_GRID_BASELINE) if fp.grid_snap_rate < SAP_REFERENCE_GRID_BASELINE: result.issues.append(f"grid-snap rate {fp.grid_snap_rate * 100:.1f}%") parts["external_images"] = max(0.0, 1.0 - min(fp.external_images, 3) * 0.1) if fp.external_images > 1: result.issues.append(f"{fp.external_images} external image(s)") weights = { "page_bg": 2.0, "validator_errors": 2.0, "zones": 1.25, "icons": 1.0, "pills": 0.75, "pill_vocab": 1.25, "palette": 1.5, "edge_palette": 0.75, "fonts": 1.0, "strokes": 0.75, "abs_arc": 0.5, "label_bg": 0.5, "grid_snap": 1.0, "external_images": 1.0, } total = sum(weights[k] for k in parts) result.score = round(sum(parts[k] * weights[k] for k in parts) / total * 100, 1) result.breakdown = parts return result def compare(ref: Fingerprint, cand: Fingerprint) -> CompareResult: r = CompareResult() parts: dict[str, float] = {} parts["canvas"] = 1.0 if (ref.canvas_w == cand.canvas_w and ref.canvas_h == cand.canvas_h) else 0.0 if not parts["canvas"]: r.diffs.append(f"canvas mismatch — ref {ref.canvas_w}x{ref.canvas_h} vs cand {cand.canvas_w}x{cand.canvas_h}") # Page background fidelity. SAP diagrams use white/transparent canvas; # any explicit non-white background is a major red flag. accepted_bgs = {"", "none", "default", "#ffffff", "#fff"} cand_bg = cand.page_background.lower() ref_bg = ref.page_background.lower() if cand_bg in accepted_bgs: parts["page_bg"] = 1.0 elif cand_bg == ref_bg: parts["page_bg"] = 1.0 else: parts["page_bg"] = 0.0 r.diffs.append( f"non-white page background {cand.page_background!r} — SAP uses white/transparent canvas" ) def ratio(a: float, b: float) -> float: if a == 0 and b == 0: return 1.0 if a == 0 or b == 0: return 0.0 return min(a, b) / max(a, b) # zones: zone detection now uses arcSize=16 + strokeWidth=1.5 (no fontStyle # requirement) — matches SAP's actual encoding where bold is HTML-inline. if ref.zones > 0 or cand.zones > 0: parts["zones"] = ratio(ref.zones, cand.zones) # zone nesting depth — matters for templates like Joule-inside-vs-beside-BTP if ref.zone_depth > 0 or cand.zone_depth > 0: parts["zone_depth"] = ratio(ref.zone_depth, cand.zone_depth) if ref.zone_depth != cand.zone_depth: r.diffs.append( f"zone nesting depth differs — ref {ref.zone_depth} vs cand {cand.zone_depth}" ) # icons: prefer inline-SVG over legacy mxgraph.sap.icon stencils. SAP's own # corpus uses both, so we don't penalize stencils outright; we just count # inline + stencil together to match SAP's behavior. parts["icons"] = ratio(ref.icons, cand.icons) parts["external_images"] = 1.0 if cand.external_images <= ref.external_images else ratio(ref.external_images, cand.external_images) parts["edges"] = ratio(ref.edges, cand.edges) parts["vertices"] = ratio(ref.vertices, cand.vertices) parts["pills"] = ratio(ref.pills, cand.pills) if (ref.pills or cand.pills) else 1.0 # Pill vocabulary fidelity. Reward use of SAP-canonical pill labels; # penalize candidates whose pills are mostly novelty verbs. if ref.pills > 0 or cand.pills > 0: ref_canon_rate = ref.canonical_pill_count / max(1, ref.pills) cand_canon_rate = cand.canonical_pill_count / max(1, cand.pills) if ref.pills == 0: parts["pill_vocab"] = 1.0 if cand_canon_rate >= 0.6 else cand_canon_rate else: # Match the reference's own canon rate. Some official SAP diagrams # intentionally use scenario-specific flow labels such as # "Security Logs" or "Open Ticket"; comparing a reference with # itself must still score 100. target = ref_canon_rate parts["pill_vocab"] = 1.0 if cand_canon_rate >= target else (cand_canon_rate / max(0.01, target)) if cand.novelty_pill_count > 0 and cand.novelty_pill_count > ref.novelty_pill_count: r.diffs.append( f"novelty pill labels: {cand.novelty_pill_count} (cand) vs {ref.novelty_pill_count} (ref) " "— prefer canonical SAP verbs (TRUST/Authenticate/A2A/MCP/ORD/...)" ) # Edge palette: which colors are actually used on edges? This catches # green↔magenta semantic swaps that the global palette set hides. if ref.edge_palette or cand.edge_palette: parts["edge_palette"] = jaccard(ref.edge_palette, cand.edge_palette) edge_diff = ref.edge_palette - cand.edge_palette if edge_diff: r.diffs.append(f"edge stroke colors missing from candidate: {sorted(edge_diff)[:6]}") parts["palette"] = jaccard(ref.palette, cand.palette) only_in_cand = cand.palette - ref.palette if only_in_cand: r.diffs.append(f"colors in candidate not in reference: {sorted(only_in_cand)[:8]}") # fonts: candidate ⊆ ref counts as full credit. SAP's own files mix # Arial+Helvetica; if our candidate uses only one of them, that's fine. if cand.fonts and ref.fonts and cand.fonts <= ref.fonts: parts["fonts"] = 1.0 else: parts["fonts"] = jaccard(ref.fonts, cand.fonts) if cand.fonts and not (cand.fonts <= ref.fonts): r.diffs.append(f"fonts: ref={sorted(ref.fonts)} cand={sorted(cand.fonts)}") parts["strokes"] = jaccard(ref.stroke_widths, cand.stroke_widths) parts["shapes"] = jaccard(ref.shapes, cand.shapes) parts["label_count"] = ratio(ref.label_count, cand.label_count) parts["label_tokens"] = jaccard(ref.label_tokens, cand.label_tokens) missing_label_tokens = ref.label_tokens - cand.label_tokens extra_label_tokens = cand.label_tokens - ref.label_tokens if parts["label_tokens"] < 0.8: r.diffs.append( "label token drift — " f"missing={sorted(missing_label_tokens)[:8]} extra={sorted(extra_label_tokens)[:8]}" ) extra_shapes = cand.shapes - ref.shapes if extra_shapes: r.diffs.append(f"shape styles in candidate not in reference: {sorted(extra_shapes)[:8]}") if cand.external_images > ref.external_images: r.diffs.append(f"external image count increased — ref {ref.external_images} vs cand {cand.external_images}") parts["abs_arc"] = 1.0 if ref.has_absolute_arc == cand.has_absolute_arc else 0.5 parts["label_bg"] = 1.0 if ref.has_label_bg == cand.has_label_bg else 0.5 # grid_snap: candidate at-or-above reference scores 1.0; else proportional. # We don't penalise the candidate for being MORE snapped than SAP's own files, # we just want it to be at least as clean. Target absolute rate is ≥ 0.95 # which we surface as a separate diff. if ref.grid_snap_rate >= 0.95: parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate * 0.95 else cand.grid_snap_rate else: # SAP reference itself is sloppy — give candidate full credit if it matches or exceeds it parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate else cand.grid_snap_rate / max(0.01, ref.grid_snap_rate) if cand.grid_snap_rate < 0.95: r.diffs.append(f"grid-snap rate {cand.grid_snap_rate*100:.1f}% (recommend 95%+; reference is {ref.grid_snap_rate*100:.1f}%)") weights = { "canvas": 1.0, "page_bg": 1.5, # NEW: dark/branded canvas now penalized "zones": 1.5, "zone_depth": 1.0, # NEW: nesting hierarchy match (Joule-in-BTP bug) "icons": 1.5, "external_images": 0.5, "edges": 1.0, "vertices": 0.5, "pills": 0.5, "pill_vocab": 1.5, # NEW: canonical SAP pill verbs vs novelty "palette": 1.5, "edge_palette": 1.0, # NEW: connector colors actually used on edges "fonts": 1.0, "strokes": 0.5, "shapes": 1.0, "label_count": 0.5, "label_tokens": 2.0, "abs_arc": 0.5, "label_bg": 0.5, "grid_snap": 1.0, } total_weight = sum(weights[k] for k in parts) score = sum(parts[k] * weights[k] for k in parts) / total_weight * 100 r.score = round(score, 1) r.breakdown = parts return r # --- CLI ----------------------------------------------------------------------- def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("reference", type=Path) ap.add_argument("candidate", type=Path) ap.add_argument("--json", action="store_true") ap.add_argument("--score", action="store_true") args = ap.parse_args() ref = fingerprint(args.reference) cand = fingerprint(args.candidate) result = compare(ref, cand) quality = sap_likeness(cand) if args.score: print(f"{result.score:.1f}") return 0 if args.json: out = { "score": result.score, "sap_likeness": asdict(quality), "breakdown": result.breakdown, "diffs": result.diffs, "reference": asdict(ref), "candidate": asdict(cand), } # sets aren't JSON-serializable; coerce for fp_dict in (out["reference"], out["candidate"]): for k in ("palette", "edge_palette", "fonts", "stroke_widths", "shapes", "label_tokens", "pill_vocab"): fp_dict[k] = sorted(fp_dict[k]) print(json.dumps(out, indent=2)) return 0 print(f"reference : {args.reference}") print(f"candidate : {args.candidate}") print(f"score : {result.score:.1f}/100") print(f"sap-like : {quality.score:.1f}/100") print("breakdown :") for k, v in result.breakdown.items(): bar = "█" * int(v * 20) print(f" {k:10s} {v*100:5.1f}% {bar}") if result.diffs: print("\nnotable diffs:") for d in result.diffs: print(f" - {d}") print("\nreference fingerprint:") for k, v in asdict(ref).items(): if isinstance(v, set): v = sorted(v) print(f" {k}: {v}") print("\ncandidate fingerprint:") for k, v in asdict(cand).items(): if isinstance(v, set): v = sorted(v) print(f" {k}: {v}") return 0 if __name__ == "__main__": sys.exit(main())