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

925 lines
39 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
"""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 <file.drawio>
"""
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"</(?:div|p|li)>", "\n", label, flags=re.I)
label = re.sub(r"<br\s*/?>", "\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"<font[^>]*\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 <mxGeometry>", 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. <prefix>-0/<prefix>-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-<id>)",
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:<mime>,<b64> (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())