#!/usr/bin/env python3 """Validate a SAP Architecture Center-style .drawio file. Catches the bugs that make a diagram look unprofessional: Structural * malformed XML / missing mxGeometry / duplicate ids / comments * root-cell skeleton (parentless root + layer first; content on ids 0/1) * ';base64' inside image= style values (truncates -> blank icons) * mxCell ids, including draw.io UserObject wrapper ids Alignment * x/y/width/height not integer multiples of 10 (grid-snap) * edge source+target don't share a center axis (bent arrows) * container children extending outside container bounds * overlapping siblings (unintended stacking) Text * label text wider than the shape (overflow / clipping) * edge label missing `labelBackgroundColor` (disappears in colored zone) Style * colors not in the SAP Horizon palette (warnings) * missing `absoluteArcSize=1` when `arcSize` is set (percent-rendering bug) * fontFamily not Helvetica * strokeWidth outside {1, 1.5, 3, 4} Exit code: 0 — clean (or only warnings) 1 — errors 2 — usage Flags: --strict warnings become errors --json JSON report to stdout instead of human text Run: python3 validate.py """ from __future__ import annotations import argparse import html import json import re import sys import xml.etree.ElementTree as ET from dataclasses import dataclass, field from pathlib import Path # ---------- SAP Horizon palette ---------------------------------------------- # Source of truth, in priority order: # 1. SAP/btp-solution-diagrams/guideline/docs/btp_guideline/foundation.md # and diagr_comp/areas.md (primary, semantic, accent) # 2. Hex values observed in SAP/btp-solution-diagrams/assets/ # editable-diagram-examples/*.drawio (real-world variations) # 3. SAP/architecture-center/docs/ref-arch/RA*/drawio/*.drawio SAP_PALETTE = { # --- foundation.md primary ------------------------------------------------- "#0070F2", "#EBF8FF", # SAP / BTP area: border, fill "#475E75", "#F5F6F7", # Non-SAP area: border, fill "#1D2D3E", # Title text "#556B82", # Body text # --- foundation.md semantic ------------------------------------------------ "#188918", "#F5FAE5", # Positive (authentication flows) "#C35500", "#FFF8D6", # Critical "#D20A0A", "#FFEAF4", # Negative # --- foundation.md accent (sparingly) ------------------------------------- "#07838F", "#DAFDF5", # Teal "#5D36FF", "#F1ECFF", # Indigo (authorization flows) "#CC00DC", "#FFF0FA", # Pink (trust flows) # --- preset (in drawio-config-all-in-one.json) ---------------------------- "#793802", # Brown — present in preset, no documented role # --- darker text / accent variants used in real SAP diagrams -------------- "#002A86", "#00185A", "#0057D2", "#2395FF", # SAP blue variants observed in Architecture Center "#266F3A", # darker positive green "#470BED", # darker indigo (preset variant of #5D36FF) "#7F00FF", # alt accent purple # --- observed grey / neutral variations (real SAP files) ------------------ "#1A2733", # near-black navy used by some diagrams "#354A5F", # mid grey "#475E74", "#475F75", # off-by-one variants of #475E75 "#5B738B", # lighter grey "#595959", "#D5DADD", "#EAECEE", "#EDEDED", "#EDEFF0", "#EAF8FF", "#EDF8FF", "#ECF8FF", "#D1EFFF", "#CCDDFF", "#FCFCFC", # --- additional colors observed in the bundled 71 reference templates ----- # These are accepted to avoid flagging SAP's own published diagrams as # off-palette. New generated diagrams should still prefer the documented # Horizon colors above. "#178B1B", "#4628EC", "#C0399F", "#1D1B1B", "#00185B", "#0878F5", "#0D3C56", "#1B91FF", "#1C2D3E", "#6A6A6A", "#89D1FF", "#D2F0FF", "#00144A", "#121212", "#212121", "#5F6369", "#CC01DB", "#FFCC99", "#0070F3", "#8EA2B5", "#CB00DC", "#D3E8FD", "#FFE6CC", "#004C99", "#0170F2", "#053B70", "#0A74F3", "#102937", "#107E3E", "#333333", "#666666", "#6D7F91", "#8695A4", "#BFBFBF", "#D79B00", "#F5F5F5", "#0080F0", "#3333FF", "#3399FF", "#354C5F", "#4D4D4D", "#6666FF", "#6C8EBF", "#A100C2", "#B3B3B3", "#B46504", "#C5761C", "#C87515", "#CCE5FF", "#E7E8E9", "#F65AF2", "#FAD7AC", "#FF8000", "#FFB300", "#0000CC", "#0000FF", "#003366", "#0040B0", "#006600", "#0066CC", "#006EAF", "#0071F2", "#007FFF", "#009500", "#00BEF2", "#00CEAC", "#04221C", "#101C22", "#192B3D", "#1BA1E2", "#221111", "#223548", "#251821", "#314354", "#354B5F", "#36393D", "#384C61", "#470CED", "#60A917", "#647687", "#6600CC", "#67AB9F", "#7F7F7F", "#808080", "#9B76FF", "#A8A8FF", "#CCCCFF", "#D6B656", "#DAE8FC", "#DF8C42", "#E07A5F", "#E6E6E6", "#EEEEEE", "#F2CC8F", "#F9F7ED", "#FF87FF", "#FFB366", "#FFD966", "#FFF2CC", # --- basics ---------------------------------------------------------------- "#FFFFFF", "#FFF", "#000000", "#000", } COMMENT_RE = re.compile(r"", re.S) HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b") # data URIs embed their own palette we should NOT flag DATA_URI_RE = re.compile(r"data:image/[^&\";]+") GRID = 10 ALLOWED_STROKE = {"1", "1.5", "2", "3", "4"} # Canonical SAP flow-pill vocabulary, extracted from the published reference # corpus. Pills with labels outside this set tend to indicate hand-crafted # diagrams rather than SAP-style flow narration. We don't enforce strictly — # many cases legitimately add custom verbs — but emit a warning so reviewers # can ratify the deviation. CANONICAL_PILL_LABELS = { # identity / trust / auth "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", # transport "https", "https/active", "https/standby", "rest", "rest/spi", "rest/token", "rest / odata", "odata/rest", "odata/rest/soap", # data flows "destination", "source", "target", "harmonized api", "data federation", "data sync", "task data", # agentic ai vocab seen in RA0029 family "a2a", "mcp", "ord", # business "business data cloud", "business role", "cdm", # other observed "role replica", # semantic renderer labels that follow SAP-style flow narration "commit", "build & test", "release", "deploy", "connectivity", "private link", "sql", "security logs", "alerts, findings & enriched events", "correlated incidents", "status & closure updates", "notification", "open ticket", # generic but acceptable "data", "metadata", } # Pill labels Codex observed in failed generations (PROMPT, ROUTE, CONTEXT, # DELEGATE, etc.) — explicit watch-list to surface the most common drift. NOVELTY_PILL_LABELS = { "prompt", "route", "context", "delegate", "answer", "ask", "respond", "query", "fetch", "invoke", "call", "execute", "run", "process", "send", "receive", "publish", "subscribe", "transform", } # Light/neutral page backgrounds we accept. Anything else (dark, branded, # strongly tinted) is suspect because no SAP reference uses one. ALLOWED_PAGE_BACKGROUNDS = {None, "", "none", "default", "#ffffff", "#fff", "#FFFFFF", "#FFF"} # ---------- Report model ----------------------------------------------------- @dataclass class Issue: kind: str # "error" | "warning" category: str # "xml", "align", "text", "style" msg: str cell: str | None = None @dataclass class Report: path: str issues: list[Issue] = field(default_factory=list) def add(self, kind: str, category: str, msg: str, cell: str | None = None) -> None: self.issues.append(Issue(kind, category, msg, cell)) @property def errors(self) -> list[Issue]: return [i for i in self.issues if i.kind == "error"] @property def warnings(self) -> list[Issue]: return [i for i in self.issues if i.kind == "warning"] def to_json(self) -> dict: return { "path": self.path, "ok": not self.errors, "errors": [{"category": i.category, "msg": i.msg, "cell": i.cell} for i in self.errors], "warnings": [{"category": i.category, "msg": i.msg, "cell": i.cell} for i in self.warnings], } # ---------- Geometry helpers ------------------------------------------------- def geom(cell: ET.Element) -> tuple[float, float, float, float] | None: g = cell.find("mxGeometry") if g is None: return None try: x = float(g.get("x", "0")) y = float(g.get("y", "0")) w = float(g.get("width", "0")) h = float(g.get("height", "0")) return x, y, w, h except ValueError: return None def parse_style(style: str | None) -> dict[str, str]: if not style: return {} out: dict[str, str] = {} for part in style.split(";"): part = part.strip() if not part: continue if "=" in part: k, v = part.split("=", 1) out[k.strip()] = v.strip() else: out[part] = "1" return out def approx_text_width(text: str, font_size: float, bold: bool = False) -> float: """Crude width estimate in px. Good enough to catch egregious overflow.""" if not text: return 0.0 # avg char width ≈ 0.55 × font_size for Helvetica regular, 0.60 bold coef = 0.60 if bold else 0.55 return len(text) * font_size * coef def visible_label_lines(label: str) -> list[str]: """Reduce an HTML-ish draw.io label to visible text lines.""" label = html.unescape(label or "") label = re.sub(r"", "\n", label, flags=re.I) label = re.sub(r"", "\n", label, flags=re.I) no_tags = re.sub(r"<[^>]+>", "", label) lines = [re.sub(r"\s+", " ", line).strip() for line in no_tags.splitlines()] return [line for line in lines if line] def strip_html(label: str) -> str: """Reduce HTML label to its visible text (rough).""" return " ".join(visible_label_lines(label)) def html_font_size(label: str, fallback: float) -> float: """Best-effort font-size extraction from draw.io rich text labels.""" sizes: list[float] = [] for value in re.findall(r"font-size\s*:\s*([0-9.]+)\s*px", label or "", flags=re.I): try: sizes.append(float(value)) except ValueError: pass for value in re.findall(r"]*\bsize=[\"']?([0-9]+)", label or "", flags=re.I): # draw.io/browser HTML font size 1 renders small; this is only a fit heuristic. sizes.append({"1": 10.0, "2": 11.0, "3": 12.0, "4": 14.0, "5": 18.0, "6": 24.0, "7": 32.0}.get(value, fallback)) return min(sizes) if sizes else fallback def bbox_overlap(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float: """Return overlap area in px². 0 if no overlap.""" ax, ay, aw, ah = a bx, by, bw, bh = b dx = max(0.0, min(ax + aw, bx + bw) - max(ax, bx)) dy = max(0.0, min(ay + ah, by + bh) - max(ay, by)) return dx * dy # ---------- Validators ------------------------------------------------------- def validate(path: Path) -> Report: report = Report(path=str(path)) text = path.read_text(encoding="utf-8") if COMMENT_RE.search(text): report.add("error", "xml", "XML comments () forbidden — strip them") try: root = ET.fromstring(text) except ET.ParseError as exc: report.add("error", "xml", f"XML parse error: {exc}") return report # ---- collect cells & basic structural checks --------------------------- parent_by_elem = {id(child): parent for parent in root.iter() for child in list(parent)} def effective_cell_id(cell: ET.Element) -> str | None: cid = cell.get("id") if cid: return cid parent = parent_by_elem.get(id(cell)) if parent is not None and parent.tag == "UserObject": return parent.get("id") return None graphs = root.findall(".//mxGraphModel") or [root] # Page background colour — SAP diagrams are always on a white/transparent canvas. for graph_index, graph in enumerate(graphs): bg = graph.get("background") or graph.get("pageBackgroundColor") if bg and bg.strip().lower() not in {b.lower() for b in ALLOWED_PAGE_BACKGROUNDS if b}: suffix = "" if len(graphs) == 1 else f" (page {graph_index + 1})" report.add( "error", "style", f"page background {bg!r}{suffix} — SAP diagrams use a white/transparent canvas; " "remove the dark/branded background.", ) def scoped_id(graph_index: int, cell_id: str) -> str: return cell_id if len(graphs) == 1 else f"{graph_index}:{cell_id}" cells: dict[str, ET.Element] = {} cell_scopes: dict[str, int] = {} duplicate_ids: set[tuple[int, str]] = set() for graph_index, graph in enumerate(graphs): seen_in_graph: set[str] = set() for cell in graph.iter("mxCell"): cid = effective_cell_id(cell) if cid is None: report.add("error", "xml", "mxCell without id attribute") continue if cid in seen_in_graph: duplicate_ids.add((graph_index, cid)) seen_in_graph.add(cid) key = scoped_id(graph_index, cid) cells[key] = cell cell_scopes[key] = graph_index is_vertex = cell.get("vertex") == "1" is_edge = cell.get("edge") == "1" if (is_vertex or is_edge) and cell.find("mxGeometry") is None: report.add("error", "xml", "vertex/edge missing ", cell=key) for graph_index, cid in duplicate_ids: suffix = "" if len(graphs) == 1 else f" in diagram page {graph_index + 1}" report.add("error", "xml", f"duplicate id {cid!r}{suffix}") # ---- root-cell skeleton ------------------------------------------------ # mxGraph needs a root cell (no parent, not vertex/edge) and a layer cell # parented to it as the first two cells of each page. SAP files often use # namespaced ids (e.g. -0/-1) rather than literal 0/1, so # we check structure, not the id values. A content cell reusing literal # "0"/"1" is a collision hazard when pages are merged into files that DO # use the default ids — warn on that. for graph_index, graph in enumerate(graphs): suffix = "" if len(graphs) == 1 else f" in diagram page {graph_index + 1}" page_cells = list(graph.iter("mxCell")) if not page_cells: continue first = page_cells[0] if first.get("parent") is not None or first.get("vertex") == "1" or first.get("edge") == "1": report.add( "error", "xml", f"page has no root cell skeleton{suffix} — the first mxCell must be the parentless " "root (then a layer cell parented to it) before any content; draw.io rejects files without it", ) elif len(page_cells) >= 2: layer = page_cells[1] if layer.get("parent") != first.get("id") or layer.get("vertex") == "1" or layer.get("edge") == "1": report.add( "warning", "xml", f"second cell is not a layer parented to the root{suffix} (draw.io convention: root then layer)", ) for c in page_cells[2:]: if effective_cell_id(c) in ("0", "1") and (c.get("vertex") == "1" or c.get("edge") == "1"): report.add( "warning", "xml", f"content cell uses id {effective_cell_id(c)!r}{suffix} — mxGraph's default root/layer ids; " "collision-prone, namespace generated ids (n-)", cell=scoped_id(graph_index, effective_cell_id(c)), ) for cid, cell in cells.items(): if cell.get("edge") != "1": continue scope = cell_scopes[cid] for attr in ("source", "target"): ref = cell.get(attr) if ref and scoped_id(scope, ref) not in cells: report.add("warning", "xml", f"edge references missing {attr} id {ref!r}", cell=cid) abs_geom_cache: dict[str, tuple[float, float, float, float] | None] = {} def absolute_geom(cid: str) -> tuple[float, float, float, float] | None: """Return geometry in page coordinates, resolving draw.io child parents.""" if cid in abs_geom_cache: return abs_geom_cache[cid] cell = cells.get(cid) if cell is None: abs_geom_cache[cid] = None return None g = geom(cell) if not g: abs_geom_cache[cid] = None return None x, y, w, h = g parent_id = cell.get("parent") if parent_id and parent_id not in {"0", "1"}: parent_key = scoped_id(cell_scopes[cid], parent_id) pg = absolute_geom(parent_key) if pg: x += pg[0] y += pg[1] abs_geom_cache[cid] = (x, y, w, h) return abs_geom_cache[cid] # ---- style / palette --------------------------------------------------- palette_text = DATA_URI_RE.sub("", text) foreign = {m.upper() for m in HEX_RE.findall(palette_text)} - {c.upper() for c in SAP_PALETTE} for color in sorted(foreign): report.add("warning", "style", f"off-palette color {color}") # ---- per-cell checks --------------------------------------------------- grid_total = 0 grid_off = 0 grid_examples: list[str] = [] for cid, cell in cells.items(): style = parse_style(cell.get("style")) # Grid snap g = geom(cell) if g: x, y, w, h = g for name, val in (("x", x), ("y", y), ("width", w), ("height", h)): grid_total += 1 if abs(val - round(val)) > 0.01 or int(round(val)) % GRID != 0: grid_off += 1 if len(grid_examples) < 5: grid_examples.append(f"{cid}.{name}={val!r}") # absoluteArcSize when arcSize present if "arcSize" in style and style.get("absoluteArcSize") != "1": report.add( "warning", "style", "arcSize without absoluteArcSize=1 renders as percentage", cell=cid, ) # Font family ff = style.get("fontFamily") if ff and ff.lower() != "helvetica": report.add("warning", "style", f"fontFamily={ff!r} (expected Helvetica)", cell=cid) # Image source hygiene raw_style = cell.get("style") or "" if "image=" in raw_style and ";base64," in raw_style: report.add( "error", "style", "';base64' inside image= style — draw.io truncates the value at the ';' " "and the icon renders blank; use image=data:, (payload still base64, marker dropped)", cell=cid, ) image = style.get("image") if image and image.startswith(("http://", "https://")): report.add("warning", "style", "external image URL — prefer bundled SAP inline assets", cell=cid) elif image and not (image.startswith("data:image/") or image == "img/lib/sap/SAP_Logo.svg"): report.add("warning", "style", f"non-bundled image source {image!r}", cell=cid) # Stroke width sw = style.get("strokeWidth") if sw and sw not in ALLOWED_STROKE: report.add("warning", "style", f"strokeWidth={sw!r} (expected one of {sorted(ALLOWED_STROKE)})", cell=cid) # Edge-label background if cell.get("edge") == "1": val = cell.get("value") or "" if val.strip() and not style.get("labelBackgroundColor"): report.add( "warning", "text", "edge label without labelBackgroundColor (will bleed into zone fill)", cell=cid, ) if "endArrow" not in style and "startArrow" not in style: report.add("warning", "style", "edge without endArrow style", cell=cid) # Text overflow (vertex only, has a label, has geometry) if cell.get("vertex") == "1" and g: raw_label = cell.get("value") or "" label = strip_html(raw_label) if label and style.get("autosize") != "1" and style.get("shape") != "image" and "image" not in style: font_size = html_font_size(raw_label, float(style.get("fontSize", "12"))) bold = style.get("fontStyle", "0") in {"1", "3", "5", "7"} spacing = float(style.get("spacingLeft", "0")) + float(style.get("spacingRight", "0")) wrap = style.get("whiteSpace") == "wrap" and style.get("html") == "1" effective_w = g[2] - spacing - 6 # 6 px slop if wrap: # With wrapping, only the single longest token needs to fit longest = max(label.split(), key=len, default="") need = approx_text_width(longest, font_size, bold) if effective_w > 0 and need > effective_w + 6: report.add( "warning", "text", f"longest word '{longest}' ~{int(need)}px > shape width {int(g[2])}px — clip", cell=cid, ) else: longest_line = max(visible_label_lines(raw_label) or [label], key=len) need = approx_text_width(longest_line, font_size, bold) if effective_w > 0 and need > effective_w + 6: report.add( "warning", "text", f"label ~{int(need)}px wider than shape ({int(g[2])}px) — text will clip", cell=cid, ) if grid_total: snap_rate = 1.0 - (grid_off / grid_total) if snap_rate < 0.95: examples = f"; examples: {', '.join(grid_examples)}" if grid_examples else "" report.add( "warning", "align", f"grid-snap rate {snap_rate * 100:.1f}% below recommended 95% " f"({grid_off}/{grid_total} geometry values off {GRID}-px grid){examples}", ) # ---- pill / flow-narration vocabulary check --------------------------- # A pill is roughly arcSize >= 40 with a label. SAP's published corpus uses # a small canonical vocabulary (TRUST, Authenticate, A2A, MCP, ORD, HTTPS, # OData/REST, …). Custom verbs like PROMPT/ROUTE/CONTEXT/DELEGATE indicate # an LLM hand-crafted the diagram instead of starting from a template. # Track the labels we saw, then warn for each one outside the canon. seen_pill_labels: list[tuple[str, str]] = [] # (label, cid) novelty_pills: list[tuple[str, str]] = [] for cid, cell in cells.items(): if cell.get("vertex") != "1": continue style = parse_style(cell.get("style")) try: arc = int(float(style.get("arcSize", "0"))) except ValueError: arc = 0 if arc < 40: continue # Pill must be small (single line, < 200 px wide). Larger rounded # shapes can be cards or banners, which use a separate label vocab. g = geom(cell) if not g or g[2] > 220 or g[3] > 60: continue raw = cell.get("value") or "" # UserObject wrapping: a parent UserObject may carry the visible label if not raw: parent = parent_by_elem.get(id(cell)) if parent is not None and parent.tag == "UserObject": raw = parent.get("value") or parent.get("label") or "" label_text = strip_html(raw).strip() if not label_text: continue seen_pill_labels.append((label_text, cid)) normalized = label_text.lower() if normalized in CANONICAL_PILL_LABELS: continue # Single-token novelty pill — the most common LLM drift mode. first_token = normalized.split()[0] if normalized else "" if first_token in NOVELTY_PILL_LABELS: novelty_pills.append((label_text, cid)) report.add( "warning", "text", f"flow pill {label_text!r} is not in the canonical SAP vocabulary " "(TRUST/Authenticate/A2A/MCP/ORD/HTTPS/OData/REST/…). " "Replace with a SAP-style verb or remove the pill.", cell=cid, ) # ---- sibling overlap checks (vertices sharing a parent) --------------- def is_transparent_or_chrome(cell: ET.Element) -> bool: """Cells that float on top of others by design and shouldn't be flagged.""" s = parse_style(cell.get("style")) if s.get("fillColor") in (None, "none"): return True if s.get("shape") in ("ellipse", "image"): return True # pill (arcSize >= 40 roughly) try: if int(s.get("arcSize", "0")) >= 40: return True except ValueError: pass # text-only cells if s.get("text") == "1" or s.get("strokeColor") == "none": return True return False by_parent: dict[str, list[tuple[str, tuple[float, float, float, float], ET.Element]]] = {} for cid, cell in cells.items(): if cell.get("vertex") != "1": continue parent = scoped_id(cell_scopes[cid], cell.get("parent") or "") g = geom(cell) if not g or g[2] <= 0 or g[3] <= 0: continue by_parent.setdefault(parent, []).append((cid, g, cell)) for parent, members in by_parent.items(): for i in range(len(members)): for j in range(i + 1, len(members)): ida, ga, ca = members[i] idb, gb, cb = members[j] ov = bbox_overlap(ga, gb) if ov <= 100: # ignore slivers continue ax, ay, aw, ah = ga bx, by_, bw, bh = gb contains = (ax <= bx and ay <= by_ and ax + aw >= bx + bw and ay + ah >= by_ + bh) or ( bx <= ax and by_ <= ay and bx + bw >= ax + aw and by_ + bh >= ay + ah ) if contains: continue # Pills / icons / text / transparent cells are allowed to float over frames if is_transparent_or_chrome(ca) or is_transparent_or_chrome(cb): continue report.add( "warning", "align", f"cells {ida} and {idb} overlap by {int(ov)}px² (same parent {parent})", ) # ---- bent-edge detection ---------------------------------------------- for cid, cell in cells.items(): if cell.get("edge") != "1": continue src_id = cell.get("source") tgt_id = cell.get("target") if not src_id or not tgt_id: continue style = parse_style(cell.get("style")) if style.get("edgeStyle") != "orthogonalEdgeStyle": continue # non-orthogonal edges may legitimately curve # Skip edges with explicit entry/exit anchors — author has chosen the docking if any(k in style for k in ("entryX", "exitX", "entryY", "exitY")): continue scope = cell_scopes[cid] src = cells.get(scoped_id(scope, src_id)) tgt = cells.get(scoped_id(scope, tgt_id)) if src is None or tgt is None: continue gs = absolute_geom(scoped_id(scope, src_id)) gt = absolute_geom(scoped_id(scope, tgt_id)) if not gs or not gt: continue cx_s = gs[0] + gs[2] / 2 cy_s = gs[1] + gs[3] / 2 cx_t = gt[0] + gt[2] / 2 cy_t = gt[1] + gt[3] / 2 aligned_v = abs(cx_s - cx_t) <= 1.0 # centers on same vertical aligned_h = abs(cy_s - cy_t) <= 1.0 # centers on same horizontal if not (aligned_v or aligned_h): # Is there overlap on an axis? If boxes overlap on X the edge can still drop straight overlap_x = min(gs[0] + gs[2], gt[0] + gt[2]) - max(gs[0], gt[0]) overlap_y = min(gs[1] + gs[3], gt[1] + gt[3]) - max(gs[1], gt[1]) if overlap_x < 10 and overlap_y < 10: report.add( "warning", "align", f"edge {cid}: source/target centers differ on both axes " f"(Δx={cx_s - cx_t:.0f}, Δy={cy_s - cy_t:.0f}) — arrow will bend. " "Either snap centers or add entryX/exitX anchors.", cell=cid, ) # ---- duplicate SAP logos check ---------------------------------------- # SAP guideline: "It is not recommended to use too many SAP logos in the # same diagram." (product_names.md). One inline SAP_Logo.svg per zone-band # is acceptable; more than ~4 in a single page is suspicious. sap_logo_count = 0 for cid, cell in cells.items(): style = parse_style(cell.get("style")) image = style.get("image") if image and "sap_logo" in image.lower(): sap_logo_count += 1 if sap_logo_count > 6: report.add( "warning", "style", f"{sap_logo_count} SAP logos detected — SAP recommends limiting logo " "repetition. Use text-only product labels instead beyond zone branding.", ) # ---- icon-size check --------------------------------------------------- # SAP corpus: icons cluster tightly at 32x32 (224x), 48x48 (157x), 28x28 (85x). # Icons larger than ~64 px overpower cards and overlap their text — the # most common visible bug from LLM-generated diagrams. for cid, cell in cells.items(): if cell.get("vertex") != "1": continue style = parse_style(cell.get("style")) is_icon = ( style.get("shape") == "image" and style.get("image", "").startswith(("data:image/svg", "data:image/png")) ) or "mxgraph.sap.icon" in (cell.get("style") or "") if not is_icon: continue g = geom(cell) if not g: continue _, _, w, h = g # Only flag near-square images that are big in both dimensions. SAP uses # horizontal text banners (e.g. 67x18 for product names) which are # technically `shape=image` but aren't "icons" in the visual-bug sense. is_square_ish = 0.5 <= (w / max(1, h)) <= 2.0 if w > 64 and h > 64 and is_square_ish: report.add( "warning", "style", f"icon w={int(w)}×h={int(h)} is oversized — SAP corpus standard is 32×32 (most common) " "or 48×48 for focal anchors. Resize this icon to avoid overlapping card text.", cell=cid, ) # ---- icon-overlapping-text check -------------------------------------- # An icon dropped on top of a card's text region is the signature failure # of LLM-placed icons. Detect by: icon vertex whose bounding box overlaps # a non-image vertex by more than 25% of the icon's area, where the other # vertex has a non-empty visible label. icon_cells: list[tuple[str, tuple[float, float, float, float]]] = [] text_vertex_cells: list[tuple[str, tuple[float, float, float, float]]] = [] for cid, cell in cells.items(): if cell.get("vertex") != "1": continue g = geom(cell) if not g: continue style = parse_style(cell.get("style")) is_icon = ( style.get("shape") == "image" and style.get("image", "").startswith(("data:image/svg", "data:image/png")) ) or "mxgraph.sap.icon" in (cell.get("style") or "") if is_icon: icon_cells.append((cid, g)) continue # text-bearing card: has a non-empty visible label and a fill (so it's a card, not chrome) raw = cell.get("value") or "" label = strip_html(raw).strip() if not label: continue if style.get("fillColor", "").lower() in ("none", ""): continue if style.get("shape") in ("ellipse",): continue # ellipses are often legend dots, not text cards text_vertex_cells.append((cid, g)) for icon_id, ig in icon_cells: ix, iy, iw, ih = ig icon_area = max(1.0, iw * ih) for txt_id, tg in text_vertex_cells: ov = bbox_overlap(ig, tg) if ov < icon_area * 0.25: continue # Skip if icon is entirely INSIDE the card (intentional inline placement) tx, ty, tw, th = tg if tx <= ix and ty <= iy and tx + tw >= ix + iw and ty + th >= iy + ih: continue report.add( "warning", "align", f"icon {icon_id} overlaps card {txt_id} by {int(ov)}px² — icon will block " "card text. Move the icon to a dedicated empty region OR shrink it to 32×32 " "and tuck it inside the card.", cell=icon_id, ) # ---- edges passing through other cells -------------------------------- # An edge from A to B should not run its straight-line path across the # bounding box of unrelated card C. This produces the visible bug of # "arrows that look like they hit the card." Sample 50 points along the # edge's straight path; flag if any unrelated vertex contains > 5 of them. other_cells_by_scope: dict[int, list[tuple[str, tuple[float, float, float, float]]]] = {} for cid, cell in cells.items(): if cell.get("vertex") != "1": continue g = absolute_geom(cid) if not g or g[2] <= 0 or g[3] <= 0: continue scope = cell_scopes.get(cid, 0) other_cells_by_scope.setdefault(scope, []).append((cid, g)) for cid, cell in cells.items(): if cell.get("edge") != "1": continue s_id, t_id = cell.get("source"), cell.get("target") if not s_id or not t_id: continue scope = cell_scopes.get(cid, 0) src = cells.get(scoped_id(scope, s_id)) tgt = cells.get(scoped_id(scope, t_id)) if src is None or tgt is None: continue sg = absolute_geom(scoped_id(scope, s_id)) tg = absolute_geom(scoped_id(scope, t_id)) if not sg or not tg: continue # Endpoints: card centers (this is where draw.io draws straight edges to) sx, sy = sg[0] + sg[2] / 2, sg[1] + sg[3] / 2 tx, ty = tg[0] + tg[2] / 2, tg[1] + tg[3] / 2 # Walk the straight line; collect any cell that is hit by > 5 sample points through: dict[str, int] = {} steps = 50 for i in range(1, steps): tparam = i / steps px = sx + (tx - sx) * tparam py = sy + (ty - sy) * tparam for other_id, og in other_cells_by_scope.get(scope, []): if other_id == src.get("id") or other_id == tgt.get("id"): continue if other_id in (s_id, t_id): continue ox, oy, ow, oh = og if ox < px < ox + ow and oy < py < oy + oh: through[other_id] = through.get(other_id, 0) + 1 # Report edges that clearly cross unrelated cells (parents/zones excluded) # A "parent" of source/target is acceptable (the edge enters its own zone) src_parent = src.get("parent") tgt_parent = tgt.get("parent") for other_id, count in sorted(through.items(), key=lambda kv: -kv[1]): # Require substantial coverage: > 25% of the sampled path AND a # minimum absolute step count. Otherwise we get noise from edges # that just clip the corner of an unrelated cell. if count < 13: continue if other_id in {src_parent, tgt_parent}: continue # edge passes through its own zone — fine # Also skip if "other" is a transparent / chrome cell (no fill) other_cell = cells.get(other_id) if other_cell is None: continue if is_transparent_or_chrome(other_cell): continue ostyle = parse_style(other_cell.get("style")) if ostyle.get("fillColor", "").lower() in ("none", "") and ostyle.get("shape") not in ("image",): continue # Skip cells with no visible label — they're decorative chrome # (background bands, page borders, container shells). The user # only cares when an arrow visibly crosses a labeled card. other_raw = other_cell.get("value") or "" other_label = strip_html(other_raw).strip() if not other_label: continue # Skip the source/target's container ancestors (zones the edge # legitimately enters or leaves) anc_id = other_cell.get("parent") ancestors_of_obstacle = set() while anc_id: ancestors_of_obstacle.add(anc_id) anc = cells.get(anc_id) if anc is None: break anc_id = anc.get("parent") if src.get("id") in ancestors_of_obstacle or tgt.get("id") in ancestors_of_obstacle: continue report.add( "warning", "align", f"edge {cid} ({s_id} → {t_id}) passes through cell {other_id} " f"(label={other_label[:40]!r}). Add entryX/exitX/entryY/exitY anchors to dock the edge " "on the card's edge, OR set edgeStyle=orthogonalEdgeStyle and reposition cells so " "the edge can route around the obstacle.", cell=cid, ) break # one warning per edge is enough; LLM will fix and re-run return report # ---------- Output ---------------------------------------------------------- def print_text(report: Report) -> None: path = report.path if not report.issues: print(f"{path}: OK") return for i in report.warnings: loc = f" [{i.cell}]" if i.cell else "" print(f"{path}: warning ({i.category}){loc}: {i.msg}") for i in report.errors: loc = f" [{i.cell}]" if i.cell else "" print(f"{path}: error ({i.category}){loc}: {i.msg}") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("files", nargs="+") ap.add_argument("--strict", action="store_true", help="warnings fail the run") ap.add_argument("--json", action="store_true") args = ap.parse_args() rc = 0 reports = [] for f in args.files: p = Path(f) if not p.exists(): print(f"{p}: not found", file=sys.stderr) rc = 1 continue r = validate(p) reports.append(r) if r.errors or (args.strict and r.warnings): rc = 1 if args.json: print(json.dumps([r.to_json() for r in reports], indent=2)) else: for r in reports: print_text(r) return rc if __name__ == "__main__": sys.exit(main())