341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic structural linter for .drawio files.
|
|
|
|
Catches the class of mistakes a vision self-check is slow and unreliable at:
|
|
dangling edge endpoints, duplicate or reserved ids, broken parent references,
|
|
and (as warnings) off-grid geometry, overlapping sibling nodes, and edge
|
|
routing defects. Runs without launching draw.io, so it is a fast pre-check
|
|
before the visual review step.
|
|
|
|
python3 validate.py diagram.drawio
|
|
|
|
Edge routing checks (warnings): an edge segment crossing a non-incident leaf
|
|
vertex ("routes through vertex"), and two edges crossing each other ("edges X
|
|
and Y cross") — the two defects the SKILL.md step-5 self-check looks for
|
|
("Edge-shape overlap", "Stacked edges"), but caught here deterministically.
|
|
|
|
Routing is only knowable from the XML when an edge carries explicit waypoints
|
|
(``<Array as="points">``) — exactly the hand-routed case the SKILL.md tells
|
|
authors to use to route around shapes. Edges with no waypoints are auto-routed
|
|
by draw.io at render time (the path is not stored), so they are NOT geometry-
|
|
checked here, keeping these warnings free of false positives. Endpoints honour
|
|
``exitX/exitY``/``entryX/entryY`` when present, else the node centre, and
|
|
absolute positions are resolved through parent containers.
|
|
|
|
Exit status is non-zero when any error (or, with --strict, any warning) is
|
|
found, so it can gate a workflow. Compressed (non-XML) diagram pages are
|
|
skipped with a warning — this skill always writes uncompressed XML.
|
|
|
|
Usage: python3 validate.py <file.drawio> [--strict]
|
|
"""
|
|
import argparse
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
RESERVED = {"0", "1"}
|
|
|
|
|
|
def rect(cell):
|
|
"""Return (x, y, w, h) floats for a cell's geometry, or None if absent/bad.
|
|
|
|
x/y default to 0 when omitted: draw.io treats a missing position as the
|
|
origin, and container-managed children (table rows, swimlane/UML-class
|
|
lines under tableLayout) legitimately omit x/y while keeping width/height.
|
|
Only width/height are required to be present and numeric.
|
|
"""
|
|
g = cell.find("mxGeometry")
|
|
if g is None:
|
|
return None
|
|
try:
|
|
return (float(g.get("x", "0")), float(g.get("y", "0")),
|
|
float(g.get("width", "nan")), float(g.get("height", "nan")))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def is_edge_label(cell):
|
|
"""True for a draw.io edge label / relative-positioned child vertex.
|
|
|
|
These legitimately omit width/height: their position is given relative to a
|
|
parent edge (style ``edgeLabel``) or via ``relative="1"`` geometry. Treating
|
|
them as normal vertices wrongly flags them as missing/invalid geometry.
|
|
"""
|
|
if "edgeLabel" in (cell.get("style") or ""):
|
|
return True
|
|
g = cell.find("mxGeometry")
|
|
return g is not None and g.get("relative") == "1"
|
|
|
|
|
|
def overlap(a, b):
|
|
ax, ay, aw, ah = a
|
|
bx, by, bw, bh = b
|
|
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
|
|
|
|
|
# --- Edge routing geometry -------------------------------------------------
|
|
#
|
|
# These helpers reason about edge paths. They only apply to edges with explicit
|
|
# waypoints (the route is otherwise computed by draw.io at render time and not
|
|
# stored in the XML), so the checks never guess an auto-routed path.
|
|
|
|
def style_num(style, key):
|
|
"""Return float value of ``key=`` in a draw.io style string, or None."""
|
|
for part in (style or "").split(";"):
|
|
if part.startswith(key + "="):
|
|
try:
|
|
return float(part.split("=", 1)[1])
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def abs_rect(cell, by_id):
|
|
"""Absolute (x, y, w, h) of a vertex, summing parent-container offsets.
|
|
|
|
Children of a container use coordinates relative to the container origin, so
|
|
an edge spanning containers needs absolute positions to be compared.
|
|
"""
|
|
r = rect(cell)
|
|
if r is None or any(v != v for v in r):
|
|
return None
|
|
x, y, w, h = r
|
|
parent, seen = cell.get("parent"), set()
|
|
while parent and parent in by_id and parent not in seen:
|
|
seen.add(parent)
|
|
p = by_id[parent]
|
|
if p.get("vertex") == "1":
|
|
pr = rect(p)
|
|
if pr and not any(v != v for v in pr):
|
|
x += pr[0]
|
|
y += pr[1]
|
|
parent = p.get("parent")
|
|
return (x, y, w, h)
|
|
|
|
|
|
def endpoint(edge, end, by_id):
|
|
"""Absolute (x, y) where ``edge`` meets its source/target vertex.
|
|
|
|
Honours exitX/exitY (source) and entryX/entryY (target) if the style pins
|
|
them; otherwise the vertex centre. Returns None if the vertex is unresolved.
|
|
"""
|
|
vid = edge.get(end)
|
|
if not vid or vid not in by_id:
|
|
return None
|
|
box = abs_rect(by_id[vid], by_id)
|
|
if box is None:
|
|
return None
|
|
x, y, w, h = box
|
|
style = edge.get("style") or ""
|
|
fx = style_num(style, "exitX" if end == "source" else "entryX")
|
|
fy = style_num(style, "exitY" if end == "source" else "entryY")
|
|
return (x + (fx if fx is not None else 0.5) * w,
|
|
y + (fy if fy is not None else 0.5) * h)
|
|
|
|
|
|
def edge_waypoints(edge):
|
|
"""Explicit <Array as="points"> waypoints of an edge as [(x, y), ...]."""
|
|
g = edge.find("mxGeometry")
|
|
if g is None:
|
|
return []
|
|
arr = g.find("Array")
|
|
if arr is None:
|
|
return []
|
|
pts = []
|
|
for pt in arr.findall("mxPoint"):
|
|
px, py = pt.get("x"), pt.get("y")
|
|
if px is not None and py is not None:
|
|
try:
|
|
pts.append((float(px), float(py)))
|
|
except ValueError:
|
|
pass
|
|
return pts
|
|
|
|
|
|
def edge_route(edge, by_id):
|
|
"""Absolute polyline [(x, y), ...] for a waypointed edge, or None.
|
|
|
|
Returns None when the edge has no explicit waypoints (auto-routed; path
|
|
unknown) or an endpoint cannot be resolved.
|
|
"""
|
|
waypoints = edge_waypoints(edge)
|
|
if not waypoints:
|
|
return None
|
|
s, t = endpoint(edge, "source", by_id), endpoint(edge, "target", by_id)
|
|
if s is None or t is None:
|
|
return None
|
|
return [s] + waypoints + [t]
|
|
|
|
|
|
def _orient(a, b, c):
|
|
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
|
|
|
|
|
|
def segments_cross(p1, p2, p3, p4):
|
|
"""True if segments p1p2 and p3p4 properly cross (interior intersection).
|
|
|
|
Proper crossing only: collinear overlap and shared-endpoint touches return
|
|
False, so edges meeting at a common node or grazing a corner are not flagged.
|
|
"""
|
|
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
|
|
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
|
|
return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4)
|
|
|
|
|
|
def _point_in_rect(p, box, eps=1e-6):
|
|
x, y, w, h = box
|
|
return x + eps < p[0] < x + w - eps and y + eps < p[1] < y + h - eps
|
|
|
|
|
|
def route_hits_rect(points, box):
|
|
"""True if a polyline enters a rectangle's interior or crosses a border."""
|
|
x, y, w, h = box
|
|
corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
|
borders = list(zip(corners, corners[1:] + corners[:1]))
|
|
for a, b in zip(points, points[1:]):
|
|
if _point_in_rect(a, box) or _point_in_rect(b, box):
|
|
return True
|
|
if any(segments_cross(a, b, c, d) for c, d in borders):
|
|
return True
|
|
return False
|
|
|
|
|
|
def routes_cross(pa, pb):
|
|
"""True if any segment of polyline pa properly crosses any of pb."""
|
|
for a1, a2 in zip(pa, pa[1:]):
|
|
for b1, b2 in zip(pb, pb[1:]):
|
|
if segments_cross(a1, a2, b1, b2):
|
|
return True
|
|
return False
|
|
|
|
|
|
def geometry_warnings(cells, ids, parents):
|
|
"""Edge-through-vertex and edge-crossing warnings for waypointed edges."""
|
|
warns = []
|
|
routed = [] # (edge_id, polyline, {source, target})
|
|
for c in cells:
|
|
if c.get("edge") == "1":
|
|
pts = edge_route(c, ids)
|
|
if pts:
|
|
routed.append((c.get("id"), pts,
|
|
{c.get("source"), c.get("target")}))
|
|
# Edge routes through an unrelated leaf vertex (containers wrap children, so
|
|
# an edge legitimately traverses them — restrict to leaves, as overlap does).
|
|
leaves = [(c.get("id"), abs_rect(c, ids)) for c in cells
|
|
if c.get("vertex") == "1" and c.get("id") not in parents
|
|
and not is_edge_label(c)]
|
|
leaves = [(vid, box) for vid, box in leaves if box]
|
|
for eid, pts, ends in routed:
|
|
for vid, box in leaves:
|
|
if vid not in ends and route_hits_rect(pts, box):
|
|
warns.append(f"edge {eid!r} routes through vertex {vid!r}")
|
|
# Edge-edge crossings (both routes known).
|
|
for i in range(len(routed)):
|
|
for j in range(i + 1, len(routed)):
|
|
(ia, pa, _), (ib, pb, _) = routed[i], routed[j]
|
|
if routes_cross(pa, pb):
|
|
warns.append(f"edges {ia!r} and {ib!r} cross")
|
|
return warns
|
|
|
|
|
|
def check_page(diagram):
|
|
"""Return (errors, warnings) for one <diagram> page."""
|
|
name = diagram.get("name", "?")
|
|
model = diagram.find("mxGraphModel")
|
|
if model is None:
|
|
if (diagram.text or "").strip():
|
|
return [], [f"page {name!r}: compressed, skipped (cannot lint)"]
|
|
return [f"page {name!r}: no <mxGraphModel>"], []
|
|
root = model.find("root")
|
|
# Normalize UserObject/object wrappers (used for links & metadata): the id
|
|
# lives on the wrapper, geometry/style on the inner mxCell — fold the two
|
|
# into one cell so edges referencing the wrapper id resolve.
|
|
cells = []
|
|
for child in (root if root is not None else []):
|
|
if child.tag == "mxCell":
|
|
cells.append(child)
|
|
elif child.tag in ("UserObject", "object"):
|
|
inner = child.find("mxCell")
|
|
if inner is not None:
|
|
inner.set("id", child.get("id", ""))
|
|
cells.append(inner)
|
|
errors, warns = [], []
|
|
ids = {}
|
|
for c in cells:
|
|
cid = c.get("id")
|
|
if cid in ids:
|
|
errors.append(f"duplicate id {cid!r}")
|
|
ids[cid] = c
|
|
parents = {c.get("parent") for c in cells} # ids that have children
|
|
for c in cells:
|
|
cid, parent = c.get("id"), c.get("parent")
|
|
is_v, is_e = c.get("vertex") == "1", c.get("edge") == "1"
|
|
if parent is not None and parent not in ids:
|
|
errors.append(f"cell {cid!r} parent {parent!r} does not exist")
|
|
for end in ("source", "target"):
|
|
ref = c.get(end)
|
|
if ref and ref not in ids:
|
|
errors.append(f"edge {cid!r} {end} {ref!r} does not exist")
|
|
if (is_v or is_e) and cid in RESERVED:
|
|
errors.append(f"cell {cid!r} reuses reserved id 0/1")
|
|
if is_v and not is_edge_label(c):
|
|
r = rect(c)
|
|
if r is None or any(v != v for v in r): # None or NaN
|
|
errors.append(f"vertex {cid!r} has missing/invalid geometry")
|
|
else:
|
|
x, y, w, h = r
|
|
if w <= 0 or h <= 0:
|
|
warns.append(f"vertex {cid!r} non-positive size {w:g}x{h:g}")
|
|
if x < 0 or y < 0:
|
|
warns.append(f"vertex {cid!r} negative position ({x:g},{y:g})")
|
|
# Sibling overlap: only leaf vertices (containers legitimately wrap children).
|
|
boxes = [(c.get("id"), c.get("parent"), rect(c)) for c in cells
|
|
if c.get("vertex") == "1" and c.get("id") not in parents and rect(c)
|
|
and not any(v != v for v in rect(c))]
|
|
for i in range(len(boxes)):
|
|
for j in range(i + 1, len(boxes)):
|
|
(ia, pa, ra), (ib, pb, rb) = boxes[i], boxes[j]
|
|
if pa == pb and overlap(ra, rb):
|
|
warns.append(f"vertices {ia!r} and {ib!r} overlap")
|
|
warns += geometry_warnings(cells, ids, parents)
|
|
return errors, warns
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Lint a .drawio file for structural errors.")
|
|
ap.add_argument("file")
|
|
ap.add_argument("--strict", action="store_true", help="treat warnings as failure too")
|
|
ap.add_argument("--score", action="store_true",
|
|
help="also print a readability score (lower is better) — "
|
|
"useful for comparing layout variants of the same graph")
|
|
args = ap.parse_args()
|
|
try:
|
|
tree = ET.parse(args.file)
|
|
except (ET.ParseError, OSError) as exc:
|
|
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
|
pages = tree.getroot().findall("diagram") or [tree.getroot()]
|
|
errors, warns = [], []
|
|
for page in pages:
|
|
e, w = check_page(page)
|
|
errors += e
|
|
warns += w
|
|
for w in warns:
|
|
print(f"warning: {w}")
|
|
for e in errors:
|
|
print(f"error: {e}")
|
|
print(f"{len(errors)} error(s), {len(warns)} warning(s)")
|
|
if args.score:
|
|
# Weighted by how badly each defect hurts readability. Comparable only
|
|
# across variants of the SAME graph (same nodes/edges).
|
|
through = sum(1 for w in warns if "routes through" in w)
|
|
cross = sum(1 for w in warns if " cross" in w)
|
|
olap = sum(1 for w in warns if " overlap" in w)
|
|
print(f"score: {20 * through + 10 * cross + 5 * olap} "
|
|
f"({through} through-vertex, {cross} crossings, {olap} overlaps)")
|
|
if errors or (args.strict and warns):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|