Import sap-architecture skill

This commit is contained in:
2026-07-24 20:47:16 +00:00
commit f3699e6f43
121 changed files with 127110 additions and 0 deletions
+924
View File
@@ -0,0 +1,924 @@
#!/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())