925 lines
39 KiB
Python
925 lines
39 KiB
Python
#!/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())
|