[teamai] Push 87 resource(s) from XingfenD
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a normalized intermediate representation (IR) from a draw.io file.
|
||||
|
||||
The deterministic half of the draw.io import flow: this script never makes a
|
||||
design decision. It decodes whatever draw.io wrote (raw XML, deflate+base64
|
||||
payloads, PNG/SVG files with an embedded ``mxfile``), flattens the mxGraphModel
|
||||
into absolute-positioned nodes and edges, and reports structural signals — hubs,
|
||||
containers, depth, cycles, leaf clusters — that the skill uses to pick a diagram
|
||||
type and a level of detail.
|
||||
|
||||
Usage:
|
||||
python3 drawio_extract.py <file.drawio> [--page N|NAME] [--json]
|
||||
[--max-rows N] [--out PATH]
|
||||
|
||||
Default output is a compact Markdown digest meant to be read into context.
|
||||
``--json`` emits the full IR instead (every node, every edge, every style).
|
||||
|
||||
Exit codes: 0 ok, 2 unreadable / unsupported input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# container / payload decoding
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_INPUT_BYTES = 32 * 1024 * 1024
|
||||
MAX_XML_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class PayloadTooLarge(ValueError):
|
||||
"""Raised when compressed metadata expands beyond the supported limit."""
|
||||
|
||||
|
||||
def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
|
||||
print(f"drawio_extract: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _reject_unsafe_xml(xml: str, source: str) -> None:
|
||||
"""Reject declarations that can make XML parsing expand external data."""
|
||||
upper = xml.upper()
|
||||
if "<!DOCTYPE" in upper or "<!ENTITY" in upper:
|
||||
_fail(f"{source}: DTD and entity declarations are not supported")
|
||||
|
||||
|
||||
def _decompress_limited(data: bytes, wbits: int, limit: int = MAX_XML_BYTES) -> bytes:
|
||||
"""Decompress without allowing a small payload to expand without bound."""
|
||||
decompressor = zlib.decompressobj(wbits)
|
||||
output = bytearray()
|
||||
chunk = data
|
||||
while chunk:
|
||||
remaining = limit + 1 - len(output)
|
||||
if remaining <= 0:
|
||||
raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
|
||||
output.extend(decompressor.decompress(chunk, remaining))
|
||||
if len(output) > limit:
|
||||
raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
|
||||
chunk = decompressor.unconsumed_tail
|
||||
if not decompressor.eof:
|
||||
raise zlib.error("incomplete compressed payload")
|
||||
remaining = limit + 1 - len(output)
|
||||
if remaining <= 0:
|
||||
raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
|
||||
output.extend(decompressor.flush(remaining))
|
||||
if len(output) > limit:
|
||||
raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def _inflate(payload: str) -> str | None:
|
||||
"""Undo draw.io's base64 + raw-deflate + URL-encoding pipeline."""
|
||||
try:
|
||||
raw = base64.b64decode(payload, validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
for wbits in (-15, 15, 47):
|
||||
try:
|
||||
text = _decompress_limited(raw, wbits).decode("utf-8", "replace")
|
||||
except PayloadTooLarge:
|
||||
_fail(
|
||||
f"decoded diagram exceeds the {MAX_XML_BYTES // (1024 * 1024)} MiB limit"
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
# draw.io URL-encodes before deflating; unquote is a no-op if it didn't.
|
||||
return unquote(text)
|
||||
return None
|
||||
|
||||
|
||||
def _png_embedded_xml(data: bytes) -> str | None:
|
||||
"""Pull the ``mxfile`` tEXt/zTXt chunk out of a draw.io-exported PNG."""
|
||||
pos = len(PNG_MAGIC)
|
||||
while pos + 8 <= len(data):
|
||||
(length,) = struct.unpack(">I", data[pos : pos + 4])
|
||||
ctype = data[pos + 4 : pos + 8]
|
||||
body_end = pos + 8 + length
|
||||
chunk_end = body_end + 4
|
||||
if chunk_end > len(data):
|
||||
_fail("PNG has a truncated metadata chunk")
|
||||
body = data[pos + 8 : body_end]
|
||||
pos = chunk_end
|
||||
if ctype not in (b"tEXt", b"zTXt", b"iTXt"):
|
||||
if ctype == b"IEND":
|
||||
break
|
||||
continue
|
||||
key, _, rest = body.partition(b"\x00")
|
||||
if key.lower() != b"mxfile":
|
||||
continue
|
||||
try:
|
||||
if ctype == b"tEXt":
|
||||
value = rest
|
||||
elif ctype == b"zTXt":
|
||||
value = _decompress_limited(rest[1:], 15)
|
||||
else: # iTXt: compression flag, method, lang, translated key, text
|
||||
flag = rest[0:1]
|
||||
tail = rest[2:].split(b"\x00", 2)[-1]
|
||||
value = _decompress_limited(tail, 15) if flag == b"\x01" else tail
|
||||
except PayloadTooLarge:
|
||||
_fail(
|
||||
f"embedded PNG diagram exceeds the "
|
||||
f"{MAX_XML_BYTES // (1024 * 1024)} MiB limit"
|
||||
)
|
||||
except (IndexError, ValueError, zlib.error):
|
||||
_fail("PNG has invalid compressed draw.io metadata")
|
||||
return unquote(value.decode("utf-8", "replace"))
|
||||
return None
|
||||
|
||||
|
||||
def _svg_embedded_xml(text: str) -> str | None:
|
||||
for match in re.finditer(
|
||||
r"\bcontent\s*=\s*([\"'])(.*?)\1", text, flags=re.IGNORECASE | re.DOTALL
|
||||
):
|
||||
candidate = html.unescape(match.group(2))
|
||||
if "<mxfile" in candidate or "<mxGraphModel" in candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def load_mxfile(path: Path) -> str:
|
||||
"""Return the ``<mxfile>`` (or bare ``<mxGraphModel>``) XML for any input."""
|
||||
size = path.stat().st_size
|
||||
if size > MAX_INPUT_BYTES:
|
||||
_fail(
|
||||
f"{path.name}: input is {size} bytes; maximum is "
|
||||
f"{MAX_INPUT_BYTES // (1024 * 1024)} MiB"
|
||||
)
|
||||
data = path.read_bytes()
|
||||
if data.startswith(PNG_MAGIC):
|
||||
xml = _png_embedded_xml(data)
|
||||
if not xml:
|
||||
_fail(f"{path.name}: PNG has no embedded draw.io diagram")
|
||||
return xml
|
||||
text = data.decode("utf-8", "replace").lstrip("").strip()
|
||||
if "<mxfile" in text or "<mxGraphModel" in text:
|
||||
return text
|
||||
if "<svg" in text[:2000]:
|
||||
xml = _svg_embedded_xml(text)
|
||||
if not xml:
|
||||
_fail(f"{path.name}: SVG has no embedded draw.io diagram")
|
||||
return xml
|
||||
inflated = _inflate(text)
|
||||
if inflated and "<mxGraphModel" in inflated:
|
||||
return inflated
|
||||
_fail(f"{path.name}: not a draw.io file (no mxfile, mxGraphModel, or payload)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# style + label helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
BR_RE = re.compile(r"<br\s*/?>|</p\s*>|</div\s*>", re.IGNORECASE)
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def parse_style(style: str | None) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
if not style:
|
||||
return out
|
||||
for part in style.split(";"):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
key, sep, value = part.partition("=")
|
||||
out[key.strip()] = value.strip() if sep else "1"
|
||||
return out
|
||||
|
||||
|
||||
def clean_label(value: str | None) -> str:
|
||||
"""draw.io labels are often HTML fragments; flatten to plain text lines."""
|
||||
if not value:
|
||||
return ""
|
||||
text = BR_RE.sub("\n", value)
|
||||
text = TAG_RE.sub("", text)
|
||||
text = html.unescape(text)
|
||||
text = text.replace("\xa0", " ")
|
||||
lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in text.split("\n")]
|
||||
return "\n".join(ln for ln in lines if ln).strip()
|
||||
|
||||
|
||||
SHAPE_FAMILIES = (
|
||||
("mxgraph.aws", "aws"),
|
||||
("mxgraph.azure", "azure"),
|
||||
("mxgraph.gcp", "gcp"),
|
||||
("mxgraph.kubernetes", "kubernetes"),
|
||||
("mxgraph.cisco", "network"),
|
||||
("mxgraph.veeam", "infra"),
|
||||
("mxgraph.flowchart", "flowchart"),
|
||||
("mxgraph.bpmn", "bpmn"),
|
||||
("mxgraph.er", "er"),
|
||||
("mxgraph.sysml", "uml"),
|
||||
("mxgraph.archimate", "archimate"),
|
||||
)
|
||||
|
||||
# style key -> canonical shape name, checked in order
|
||||
SHAPE_KEYS = (
|
||||
("swimlane", "swimlane"),
|
||||
("ellipse", "ellipse"),
|
||||
("rhombus", "rhombus"),
|
||||
("triangle", "triangle"),
|
||||
("cylinder", "cylinder"),
|
||||
("cylinder3", "cylinder"),
|
||||
("hexagon", "hexagon"),
|
||||
("cloud", "cloud"),
|
||||
("actor", "actor"),
|
||||
("umlActor", "actor"),
|
||||
("note", "note"),
|
||||
("card", "card"),
|
||||
("step", "step"),
|
||||
("process", "process"),
|
||||
("parallelogram", "parallelogram"),
|
||||
("document", "document"),
|
||||
("datastore", "cylinder"),
|
||||
("umlLifeline", "lifeline"),
|
||||
("umlFrame", "frame"),
|
||||
("table", "table"),
|
||||
("tableRow", "table-row"),
|
||||
("partialRectangle", "table-row"),
|
||||
("image", "image"),
|
||||
("text", "text"),
|
||||
("group", "group"),
|
||||
)
|
||||
|
||||
|
||||
def classify_shape(style: dict[str, str]) -> str:
|
||||
raw = style.get("shape", "")
|
||||
if raw:
|
||||
for key, name in SHAPE_KEYS:
|
||||
if raw == key or raw.startswith(key):
|
||||
return name
|
||||
for prefix, family in SHAPE_FAMILIES:
|
||||
if raw.startswith(prefix):
|
||||
return f"icon:{family}"
|
||||
return f"shape:{raw}"
|
||||
for key, name in SHAPE_KEYS:
|
||||
if key in style:
|
||||
return name
|
||||
if style.get("ellipse") == "1":
|
||||
return "ellipse"
|
||||
return "rect"
|
||||
|
||||
|
||||
def shape_family(shape: str) -> str:
|
||||
if shape.startswith("icon:"):
|
||||
return shape.split(":", 1)[1]
|
||||
if shape.startswith("shape:"):
|
||||
return "custom"
|
||||
return shape
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# IR model
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
id: str
|
||||
label: str = ""
|
||||
shape: str = "rect"
|
||||
parent: str | None = None
|
||||
depth: int = 0
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
w: float = 0.0
|
||||
h: float = 0.0
|
||||
fill: str = ""
|
||||
stroke: str = ""
|
||||
font_color: str = ""
|
||||
dashed: bool = False
|
||||
rounded: bool = False
|
||||
container: bool = False
|
||||
children: list[str] = field(default_factory=list)
|
||||
link: str = ""
|
||||
attrs: dict[str, str] = field(default_factory=dict)
|
||||
in_degree: int = 0
|
||||
out_degree: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
id: str
|
||||
source: str | None
|
||||
target: str | None
|
||||
label: str = ""
|
||||
dashed: bool = False
|
||||
bidirectional: bool = False
|
||||
undirected: bool = False
|
||||
style_name: str = ""
|
||||
waypoints: int = 0
|
||||
stroke: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
id: str
|
||||
name: str
|
||||
index: int
|
||||
nodes: list[Node] = field(default_factory=list)
|
||||
edges: list[Edge] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def node_map(self) -> dict[str, Node]:
|
||||
return {n.id: n for n in self.nodes}
|
||||
|
||||
|
||||
def _num(geom: ET.Element | None, key: str) -> float:
|
||||
if geom is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(geom.get(key, "0") or 0)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_page(diagram: ET.Element, index: int) -> Page:
|
||||
name = diagram.get("name") or f"Page-{index + 1}"
|
||||
page = Page(id=diagram.get("id") or f"page-{index}", name=name, index=index)
|
||||
|
||||
model = diagram.find(".//mxGraphModel")
|
||||
if model is None:
|
||||
text = (diagram.text or "").strip()
|
||||
inflated = _inflate(text) if text else None
|
||||
if not inflated:
|
||||
return page
|
||||
_reject_unsafe_xml(inflated, f"page {index}")
|
||||
model = ET.fromstring(inflated)
|
||||
if model.tag != "mxGraphModel":
|
||||
found = model.find(".//mxGraphModel")
|
||||
if found is None:
|
||||
return page
|
||||
model = found
|
||||
|
||||
root = model.find("root")
|
||||
if root is None:
|
||||
return page
|
||||
|
||||
# Pass 1: collect raw cells, unwrapping <object>/<UserObject> containers.
|
||||
raw: dict[str, dict[str, Any]] = {}
|
||||
order: list[str] = []
|
||||
for element in root:
|
||||
if element.tag in ("object", "UserObject"):
|
||||
cell = element.find("mxCell")
|
||||
if cell is None:
|
||||
continue
|
||||
attrs = {
|
||||
k: v
|
||||
for k, v in element.attrib.items()
|
||||
if k not in ("id", "label", "placeholders")
|
||||
}
|
||||
cid = element.get("id") or cell.get("id") or ""
|
||||
value = element.get("label", "")
|
||||
elif element.tag == "mxCell":
|
||||
cell = element
|
||||
attrs = {}
|
||||
cid = cell.get("id") or ""
|
||||
value = cell.get("value", "")
|
||||
else:
|
||||
continue
|
||||
if not cid:
|
||||
continue
|
||||
raw[cid] = {"cell": cell, "attrs": attrs, "value": value}
|
||||
order.append(cid)
|
||||
|
||||
# Pass 2: vertices (absolute geometry resolved after the pass).
|
||||
edge_label_parts: dict[str, list[str]] = {}
|
||||
for cid in order:
|
||||
entry = raw[cid]
|
||||
cell = entry["cell"]
|
||||
style = parse_style(cell.get("style"))
|
||||
parent = cell.get("parent")
|
||||
if cell.get("edge") == "1":
|
||||
continue
|
||||
if cell.get("vertex") != "1":
|
||||
continue
|
||||
# An edge label is a vertex parented to an edge; fold it into the edge.
|
||||
parent_entry = raw.get(parent or "")
|
||||
parent_is_edge = bool(
|
||||
parent_entry and parent_entry["cell"].get("edge") == "1"
|
||||
)
|
||||
if parent_is_edge or "edgeLabel" in style:
|
||||
if parent:
|
||||
text = clean_label(entry["value"])
|
||||
if text:
|
||||
edge_label_parts.setdefault(parent, []).append(text)
|
||||
continue
|
||||
|
||||
geom = cell.find("mxGeometry")
|
||||
node = Node(
|
||||
id=cid,
|
||||
label=clean_label(entry["value"]),
|
||||
shape=classify_shape(style),
|
||||
parent=parent,
|
||||
x=_num(geom, "x"),
|
||||
y=_num(geom, "y"),
|
||||
w=_num(geom, "width"),
|
||||
h=_num(geom, "height"),
|
||||
fill=style.get("fillColor", ""),
|
||||
stroke=style.get("strokeColor", ""),
|
||||
font_color=style.get("fontColor", ""),
|
||||
dashed=style.get("dashed") == "1",
|
||||
rounded=style.get("rounded") == "1",
|
||||
container=style.get("container") == "1" or "swimlane" in style,
|
||||
link=entry["attrs"].get("link", ""),
|
||||
attrs={
|
||||
k: v
|
||||
for k, v in entry["attrs"].items()
|
||||
if k not in ("link", "tooltip")
|
||||
},
|
||||
)
|
||||
page.nodes.append(node)
|
||||
|
||||
node_map = page.node_map
|
||||
|
||||
# Resolve absolute geometry + depth by walking the parent chain.
|
||||
def resolve(node: Node, seen: set[str]) -> tuple[float, float, int]:
|
||||
if node.id in seen:
|
||||
return node.x, node.y, 0
|
||||
seen.add(node.id)
|
||||
parent = node_map.get(node.parent or "")
|
||||
if parent is None:
|
||||
return node.x, node.y, 0
|
||||
px, py, pdepth = resolve(parent, seen)
|
||||
return node.x + px, node.y + py, pdepth + 1
|
||||
|
||||
for node in page.nodes:
|
||||
ax, ay, depth = resolve(node, set())
|
||||
node.x, node.y, node.depth = ax, ay, depth
|
||||
parent = node_map.get(node.parent or "")
|
||||
if parent is not None:
|
||||
parent.children.append(node.id)
|
||||
parent.container = True
|
||||
|
||||
# Pass 3: edges.
|
||||
for cid in order:
|
||||
entry = raw[cid]
|
||||
cell = entry["cell"]
|
||||
if cell.get("edge") != "1":
|
||||
continue
|
||||
style = parse_style(cell.get("style"))
|
||||
geom = cell.find("mxGeometry")
|
||||
waypoints = 0
|
||||
if geom is not None:
|
||||
waypoints = len(
|
||||
[p for p in geom.findall(".//mxPoint") if p.get("as") is None]
|
||||
)
|
||||
label = clean_label(entry["value"])
|
||||
extra = edge_label_parts.get(cid, [])
|
||||
if extra:
|
||||
label = " / ".join([p for p in ([label] + extra) if p])
|
||||
source = cell.get("source")
|
||||
target = cell.get("target")
|
||||
page.edges.append(
|
||||
Edge(
|
||||
id=cid,
|
||||
source=source if source in node_map else None,
|
||||
target=target if target in node_map else None,
|
||||
label=label,
|
||||
dashed=style.get("dashed") == "1",
|
||||
bidirectional=style.get("startArrow", "none")
|
||||
not in ("none", "0", "")
|
||||
and style.get("endArrow", "classic") not in ("none", "0"),
|
||||
undirected=style.get("endArrow") in ("none", "0")
|
||||
and style.get("startArrow", "none") in ("none", "0", ""),
|
||||
style_name=style.get("shape", "")
|
||||
or ("orthogonal" if style.get("edgeStyle") else ""),
|
||||
waypoints=waypoints,
|
||||
stroke=style.get("strokeColor", ""),
|
||||
)
|
||||
)
|
||||
|
||||
for edge in page.edges:
|
||||
if edge.source and edge.source in node_map:
|
||||
node_map[edge.source].out_degree += 1
|
||||
if edge.target and edge.target in node_map:
|
||||
node_map[edge.target].in_degree += 1
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def parse_file(path: Path) -> list[Page]:
|
||||
xml = load_mxfile(path)
|
||||
_reject_unsafe_xml(xml, path.name)
|
||||
try:
|
||||
root = ET.fromstring(xml)
|
||||
except ET.ParseError as exc:
|
||||
_fail(f"{path.name}: malformed XML ({exc})")
|
||||
if root.tag == "mxGraphModel":
|
||||
wrapper = ET.Element("diagram", {"name": path.stem, "id": "single"})
|
||||
wrapper.append(root)
|
||||
return [parse_page(wrapper, 0)]
|
||||
diagrams = root.findall(".//diagram")
|
||||
if not diagrams:
|
||||
_fail(f"{path.name}: mxfile contains no <diagram> pages")
|
||||
return [parse_page(d, i) for i, d in enumerate(diagrams)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# structural analysis — signals, not decisions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _has_cycle(nodes: list[Node], edges: list[Edge]) -> bool:
|
||||
adjacency: dict[str, list[str]] = {n.id: [] for n in nodes}
|
||||
for edge in edges:
|
||||
if edge.source and edge.target and edge.source in adjacency:
|
||||
adjacency[edge.source].append(edge.target)
|
||||
WHITE, GREY, BLACK = 0, 1, 2
|
||||
color = {n.id: WHITE for n in nodes}
|
||||
|
||||
def visit(start: str) -> bool:
|
||||
stack = [(start, iter(adjacency.get(start, [])))]
|
||||
color[start] = GREY
|
||||
while stack:
|
||||
nid, it = stack[-1]
|
||||
advanced = False
|
||||
for nxt in it:
|
||||
state = color.get(nxt, BLACK)
|
||||
if state == GREY:
|
||||
return True
|
||||
if state == WHITE:
|
||||
color[nxt] = GREY
|
||||
stack.append((nxt, iter(adjacency.get(nxt, []))))
|
||||
advanced = True
|
||||
break
|
||||
if not advanced:
|
||||
color[nid] = BLACK
|
||||
stack.pop()
|
||||
return False
|
||||
|
||||
return any(color[n.id] == WHITE and visit(n.id) for n in nodes)
|
||||
|
||||
|
||||
def _aligned(boxes: list[Node], tolerance: float = 8.0) -> bool:
|
||||
"""True when the boxes stack as lanes — shared left edge or shared top edge."""
|
||||
if len(boxes) < 2:
|
||||
return False
|
||||
same_x = max(n.x for n in boxes) - min(n.x for n in boxes) <= tolerance
|
||||
same_w = max(n.w for n in boxes) - min(n.w for n in boxes) <= tolerance
|
||||
same_y = max(n.y for n in boxes) - min(n.y for n in boxes) <= tolerance
|
||||
same_h = max(n.h for n in boxes) - min(n.h for n in boxes) <= tolerance
|
||||
return (same_x and same_w) or (same_y and same_h)
|
||||
|
||||
|
||||
def analyze(page: Page) -> dict[str, Any]:
|
||||
nodes = page.nodes
|
||||
edges = page.edges
|
||||
drawable = [n for n in nodes if n.shape not in ("text",) and (n.label or n.children)]
|
||||
containers = [n for n in nodes if n.children]
|
||||
leaves = [n for n in nodes if not n.children]
|
||||
shapes: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
shapes[shape_family(node.shape)] = shapes.get(shape_family(node.shape), 0) + 1
|
||||
|
||||
def name_of(node: Node) -> str:
|
||||
return (node.label.replace("\n", " · ") or node.id)
|
||||
|
||||
ranked = sorted(
|
||||
leaves, key=lambda n: (n.in_degree + n.out_degree), reverse=True
|
||||
)
|
||||
hubs = [
|
||||
{"id": n.id, "label": name_of(n), "degree": n.in_degree + n.out_degree}
|
||||
for n in ranked[:5]
|
||||
if (n.in_degree + n.out_degree) > 0
|
||||
]
|
||||
sources = [name_of(n) for n in leaves if n.out_degree and not n.in_degree]
|
||||
sinks = [name_of(n) for n in leaves if n.in_degree and not n.out_degree]
|
||||
orphans = [name_of(n) for n in leaves if not n.in_degree and not n.out_degree]
|
||||
|
||||
# Type candidates, strongest signal first. Advisory only.
|
||||
candidates: list[str] = []
|
||||
if shapes.get("lifeline"):
|
||||
candidates.append("sequence")
|
||||
if shapes.get("table") or shapes.get("er"):
|
||||
candidates.append("er")
|
||||
lanes = [n for n in nodes if n.shape == "swimlane" and n.children]
|
||||
if len(lanes) >= 2 and _aligned(lanes):
|
||||
candidates.append("swimlane")
|
||||
if shapes.get("rhombus"):
|
||||
candidates.append("flowchart")
|
||||
if shapes.get("ellipse", 0) >= max(2, len(leaves) // 3) and edges:
|
||||
candidates.append("state")
|
||||
if any(f in shapes for f in ("aws", "azure", "gcp", "kubernetes", "network")):
|
||||
candidates.append("architecture")
|
||||
if containers and not shapes.get("swimlane"):
|
||||
candidates.append("nested")
|
||||
if edges and not _has_cycle(nodes, edges) and len(sources) == 1:
|
||||
candidates.append("tree")
|
||||
if edges:
|
||||
candidates.append("architecture")
|
||||
if not candidates:
|
||||
candidates.append("architecture")
|
||||
|
||||
seen: set[str] = set()
|
||||
candidates = [c for c in candidates if not (c in seen or seen.add(c))]
|
||||
|
||||
# Collapse candidates: containers whose children are all leaves, and
|
||||
# fan-out clusters — the first things to merge when simplifying.
|
||||
collapsible = [
|
||||
{
|
||||
"id": c.id,
|
||||
"label": name_of(c),
|
||||
"children": len(c.children),
|
||||
"child_labels": [
|
||||
name_of(page.node_map[cid])
|
||||
for cid in c.children
|
||||
if page.node_map.get(cid) and page.node_map[cid].label
|
||||
][:8],
|
||||
}
|
||||
for c in containers
|
||||
if c.children and all(not page.node_map[cid].children for cid in c.children)
|
||||
]
|
||||
collapsible.sort(key=lambda c: c["children"], reverse=True)
|
||||
|
||||
return {
|
||||
"nodes_total": len(nodes),
|
||||
"nodes_drawable": len(drawable),
|
||||
"containers": len(containers),
|
||||
"leaves": len(leaves),
|
||||
"edges_total": len(edges),
|
||||
"edges_labeled": sum(1 for e in edges if e.label),
|
||||
"edges_dangling": sum(1 for e in edges if not (e.source and e.target)),
|
||||
"max_depth": max((n.depth for n in nodes), default=0),
|
||||
"shapes": dict(sorted(shapes.items(), key=lambda kv: -kv[1])),
|
||||
"has_cycle": _has_cycle(nodes, edges),
|
||||
"hubs": hubs,
|
||||
"entry_points": sources[:6],
|
||||
"terminals": sinks[:6],
|
||||
"orphans": orphans[:6],
|
||||
"type_candidates": candidates[:3],
|
||||
"collapsible_groups": collapsible[:8],
|
||||
"over_node_budget": len(drawable) > 9,
|
||||
"over_edge_budget": len(edges) > 12,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rendering the digest
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def page_bounds(page: Page) -> tuple[float, float, float, float]:
|
||||
boxes = [(n.x, n.y, n.x + n.w, n.y + n.h) for n in page.nodes if n.w and n.h]
|
||||
if not boxes:
|
||||
return (0.0, 0.0, 0.0, 0.0)
|
||||
return (
|
||||
min(b[0] for b in boxes),
|
||||
min(b[1] for b in boxes),
|
||||
max(b[2] for b in boxes),
|
||||
max(b[3] for b in boxes),
|
||||
)
|
||||
|
||||
|
||||
def digest(path: Path, pages: list[Page], selected: list[Page], max_rows: int) -> str:
|
||||
out: list[str] = []
|
||||
out.append(f"# draw.io IR — {path.name}")
|
||||
out.append("")
|
||||
out.append(
|
||||
f"{len(pages)} page(s): "
|
||||
+ ", ".join(f"[{p.index}] {p.name} ({len(p.nodes)}n/{len(p.edges)}e)" for p in pages)
|
||||
)
|
||||
for page in selected:
|
||||
info = analyze(page)
|
||||
x0, y0, x1, y1 = page_bounds(page)
|
||||
out.append("")
|
||||
out.append(f"## Page {page.index} — {page.name}")
|
||||
out.append("")
|
||||
out.append(
|
||||
f"- source canvas: {int(x1 - x0)}×{int(y1 - y0)} px "
|
||||
f"(aspect {((x1 - x0) / (y1 - y0)):.2f})"
|
||||
if y1 > y0
|
||||
else "- source canvas: empty"
|
||||
)
|
||||
out.append(
|
||||
f"- nodes: {info['nodes_total']} total / {info['nodes_drawable']} drawable "
|
||||
f"/ {info['containers']} containers, depth {info['max_depth']}"
|
||||
)
|
||||
out.append(
|
||||
f"- edges: {info['edges_total']} ({info['edges_labeled']} labeled, "
|
||||
f"{info['edges_dangling']} dangling), cycle: {info['has_cycle']}"
|
||||
)
|
||||
out.append(f"- shapes: {info['shapes']}")
|
||||
out.append(f"- type candidates: {', '.join(info['type_candidates'])}")
|
||||
out.append(
|
||||
f"- budget: nodes {'OVER' if info['over_node_budget'] else 'ok'} (max 9), "
|
||||
f"edges {'OVER' if info['over_edge_budget'] else 'ok'} (max 12)"
|
||||
)
|
||||
if info["hubs"]:
|
||||
hubs = ", ".join(f"{h['label'] or h['id']}({h['degree']})" for h in info["hubs"])
|
||||
out.append(f"- hubs (focal candidates): {hubs}")
|
||||
if info["entry_points"]:
|
||||
out.append(f"- entry points: {', '.join(info['entry_points'])}")
|
||||
if info["terminals"]:
|
||||
out.append(f"- terminals: {', '.join(info['terminals'])}")
|
||||
if info["orphans"]:
|
||||
out.append(f"- unconnected: {', '.join(info['orphans'])}")
|
||||
if info["collapsible_groups"]:
|
||||
out.append("- collapsible groups (simplify here first):")
|
||||
for group in info["collapsible_groups"]:
|
||||
kids = ", ".join(group["child_labels"])
|
||||
out.append(f" - {group['label']} — {group['children']} children: {kids}")
|
||||
|
||||
out.append("")
|
||||
out.append("### Nodes")
|
||||
out.append("")
|
||||
out.append("| id | label | shape | depth | parent | deg | box |")
|
||||
out.append("|---|---|---|---|---|---|---|")
|
||||
listed = [n for n in page.nodes if n.label or n.children]
|
||||
for node in listed[:max_rows]:
|
||||
label = node.label.replace("\n", " ⏎ ").replace("|", "\\|")
|
||||
out.append(
|
||||
f"| {node.id} | {label} | {node.shape} | {node.depth} | "
|
||||
f"{node.parent or '-'} | {node.in_degree}/{node.out_degree} | "
|
||||
f"{int(node.x)},{int(node.y)} {int(node.w)}×{int(node.h)} |"
|
||||
)
|
||||
if len(listed) > max_rows:
|
||||
out.append(f"| … | +{len(listed) - max_rows} more (use --json) | | | | | |")
|
||||
|
||||
out.append("")
|
||||
out.append("### Edges")
|
||||
out.append("")
|
||||
out.append("| source | target | label | style |")
|
||||
out.append("|---|---|---|---|")
|
||||
names = {n.id: (n.label.split("\n")[0] or n.id) for n in page.nodes}
|
||||
for edge in page.edges[:max_rows]:
|
||||
marks = []
|
||||
if edge.dashed:
|
||||
marks.append("dashed")
|
||||
if edge.bidirectional:
|
||||
marks.append("bidir")
|
||||
if edge.undirected:
|
||||
marks.append("undirected")
|
||||
out.append(
|
||||
f"| {names.get(edge.source or '', '?')} | {names.get(edge.target or '', '?')} "
|
||||
f"| {edge.label.replace('|', chr(92) + '|') or '-'} | {' '.join(marks) or '-'} |"
|
||||
)
|
||||
if len(page.edges) > max_rows:
|
||||
out.append(f"| … | +{len(page.edges) - max_rows} more (use --json) | | |")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def to_json(path: Path, pages: list[Page], selected: list[Page]) -> str:
|
||||
payload = {
|
||||
"source": str(path),
|
||||
"pages_total": len(pages),
|
||||
"pages": [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"index": p.index,
|
||||
"bounds": dict(zip(("x0", "y0", "x1", "y1"), page_bounds(p))),
|
||||
"analysis": analyze(p),
|
||||
"nodes": [asdict(n) for n in p.nodes],
|
||||
"edges": [asdict(e) for e in p.edges],
|
||||
}
|
||||
for p in selected
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def select_pages(pages: list[Page], selector: str | None) -> list[Page]:
|
||||
if selector is None:
|
||||
return pages if len(pages) == 1 else pages[:1]
|
||||
if selector == "all":
|
||||
return pages
|
||||
if selector.isdigit():
|
||||
index = int(selector)
|
||||
match = [p for p in pages if p.index == index]
|
||||
if not match:
|
||||
_fail(f"no page with index {index} (have 0..{len(pages) - 1})")
|
||||
return match
|
||||
match = [p for p in pages if p.name.lower() == selector.lower()]
|
||||
if not match:
|
||||
names = ", ".join(p.name for p in pages)
|
||||
_fail(f"no page named {selector!r} (have: {names})")
|
||||
return match
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
parser.add_argument("file", help=".drawio / .xml / .drawio.png / .drawio.svg")
|
||||
parser.add_argument(
|
||||
"--page",
|
||||
help="page index, page name, or 'all' (default: first page)",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit the full IR as JSON")
|
||||
parser.add_argument(
|
||||
"--max-rows",
|
||||
type=int,
|
||||
default=40,
|
||||
help="rows per table in the Markdown digest (default 40)",
|
||||
)
|
||||
parser.add_argument("--out", help="write to this path instead of stdout")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.max_rows < 1:
|
||||
parser.error("--max-rows must be at least 1")
|
||||
|
||||
path = Path(args.file)
|
||||
if not path.is_file():
|
||||
_fail(f"{path}: no such file")
|
||||
|
||||
pages = parse_file(path)
|
||||
selected = select_pages(pages, args.page)
|
||||
text = (
|
||||
to_json(path, pages, selected)
|
||||
if args.json
|
||||
else digest(path, pages, selected, args.max_rows)
|
||||
)
|
||||
if args.out:
|
||||
Path(args.out).write_text(text, encoding="utf-8")
|
||||
print(f"wrote {args.out} ({len(text)} bytes)")
|
||||
else:
|
||||
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-check a generated diagram HTML file, with no third-party deps.
|
||||
|
||||
Ships inside the skill so an installed agent can verify its own output:
|
||||
|
||||
python3 <skill-dir>/scripts/self_check.py my-diagram.html
|
||||
|
||||
Checks the accessible-SVG contract, the single-file safety rules (no remote
|
||||
assets beyond the approved Google Fonts stylesheet, no executable attributes,
|
||||
no scripts other than the one canonical motion controller), and — when motion
|
||||
markup is present — the structural motion contract. This is a distilled
|
||||
subset of the repository gates (`lint-skin.py`, `verify-motion.py`), which
|
||||
remain the authority for contributions to the repository itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
MOTION_TEMPLATE = SKILL_DIR / "assets" / "template-motion.html"
|
||||
MODES = {"none", "reveal", "step", "loop"}
|
||||
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||
ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
|
||||
REFERENCE_ATTRS = {"src", "href", "xlink:href", "poster", "srcset", "action", "formaction"}
|
||||
|
||||
|
||||
class DiagramParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.roots: list[dict[str, str]] = []
|
||||
self.items: list[dict[str, str]] = []
|
||||
self.actions: set[str] = set()
|
||||
self.controls = 0
|
||||
self.statuses: list[dict[str, str]] = []
|
||||
self.statuses_in_controls = 0
|
||||
self.scripts: list[dict[str, object]] = []
|
||||
self.styles: list[str] = []
|
||||
self.svgs: list[dict[str, object]] = []
|
||||
self.unsafe: list[str] = []
|
||||
self.references: list[tuple[str, str, str]] = []
|
||||
self._svg_depth = 0
|
||||
self._current_svg: dict[str, object] | None = None
|
||||
self._capture: str | None = None
|
||||
self._current_script: dict[str, object] | None = None
|
||||
self._in_style = False
|
||||
self._element_stack: list[str] = []
|
||||
self._motion_root_depth: int | None = None
|
||||
self._controls_depth: int | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.casefold()
|
||||
normalized_attrs = [(key.casefold(), value or "") for key, value in attrs]
|
||||
data = {key: value for key, value in normalized_attrs}
|
||||
if tag in {"base", "embed", "object", "iframe"}:
|
||||
self.unsafe.append(f"<{tag}> is not allowed in a diagram file")
|
||||
for key, value in normalized_attrs:
|
||||
if key.startswith("on"):
|
||||
self.unsafe.append(f"executable attribute {key} on <{tag}>")
|
||||
if key == "srcdoc":
|
||||
self.unsafe.append(f"srcdoc attribute on <{tag}>")
|
||||
if key in REFERENCE_ATTRS:
|
||||
self.references.append((tag, data.get("rel", ""), value))
|
||||
if "data-motion-root" in data:
|
||||
self.roots.append(data)
|
||||
if self._motion_root_depth is None:
|
||||
self._motion_root_depth = len(self._element_stack)
|
||||
if self._motion_root_depth is not None:
|
||||
if "data-motion-item" in data:
|
||||
self.items.append(data)
|
||||
if "data-motion-action" in data:
|
||||
self.actions.add(data["data-motion-action"])
|
||||
if "data-motion-controls" in data:
|
||||
self.controls += 1
|
||||
if self._controls_depth is None:
|
||||
self._controls_depth = len(self._element_stack)
|
||||
if "data-motion-status" in data:
|
||||
self.statuses.append(data)
|
||||
if self._controls_depth is not None:
|
||||
self.statuses_in_controls += 1
|
||||
if tag == "script":
|
||||
self._current_script = {
|
||||
"attrs": data,
|
||||
"attr_names": [name for name, _value in normalized_attrs],
|
||||
"body": [],
|
||||
"closed": False,
|
||||
}
|
||||
self.scripts.append(self._current_script)
|
||||
if tag == "style":
|
||||
self._in_style = True
|
||||
self._element_stack.append(tag)
|
||||
if tag == "svg" and self._svg_depth == 0:
|
||||
self._svg_depth = 1
|
||||
self._current_svg = {"attrs": data, "first": None, "title": {}, "desc": {}}
|
||||
self.svgs.append(self._current_svg)
|
||||
return
|
||||
if self._svg_depth:
|
||||
self._svg_depth += 1
|
||||
assert self._current_svg is not None
|
||||
if self._svg_depth == 2 and self._current_svg["first"] is None:
|
||||
self._current_svg["first"] = tag
|
||||
if self._svg_depth == 2 and tag in {"title", "desc"}:
|
||||
self._current_svg[tag] = {"attrs": data, "text": ""}
|
||||
self._capture = tag
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.casefold()
|
||||
if tag == "script" and self._current_script is not None:
|
||||
self._current_script["closed"] = True
|
||||
self._current_script = None
|
||||
if tag == "style":
|
||||
self._in_style = False
|
||||
if self._svg_depth:
|
||||
if tag in {"title", "desc"}:
|
||||
self._capture = None
|
||||
self._svg_depth -= 1
|
||||
if self._svg_depth == 0:
|
||||
self._current_svg = None
|
||||
for index in range(len(self._element_stack) - 1, -1, -1):
|
||||
if self._element_stack[index] == tag:
|
||||
del self._element_stack[index:]
|
||||
break
|
||||
if (
|
||||
self._motion_root_depth is not None
|
||||
and len(self._element_stack) <= self._motion_root_depth
|
||||
):
|
||||
self._motion_root_depth = None
|
||||
if (
|
||||
self._controls_depth is not None
|
||||
and len(self._element_stack) <= self._controls_depth
|
||||
):
|
||||
self._controls_depth = None
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._current_script is not None:
|
||||
body = self._current_script["body"]
|
||||
assert isinstance(body, list)
|
||||
body.append(data)
|
||||
if self._in_style:
|
||||
self.styles.append(data)
|
||||
if self._capture and self._current_svg:
|
||||
node = self._current_svg[self._capture]
|
||||
assert isinstance(node, dict)
|
||||
node["text"] = str(node.get("text", "")) + data
|
||||
|
||||
|
||||
def normalized_controller(body: str) -> str:
|
||||
return body.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def parsed_document(source: str) -> DiagramParser:
|
||||
parser = DiagramParser()
|
||||
parser.feed(source)
|
||||
parser.close()
|
||||
return parser
|
||||
|
||||
|
||||
def is_approved_google_fonts_stylesheet(value: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and parsed.hostname is not None
|
||||
and parsed.hostname.casefold() == "fonts.googleapis.com"
|
||||
and parsed.port is None
|
||||
and parsed.path == "/css2"
|
||||
and not parsed.fragment
|
||||
)
|
||||
|
||||
|
||||
def reference_error(tag: str, rel: str, value: str) -> str | None:
|
||||
stripped = value.strip()
|
||||
lowered = stripped.casefold()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
return None
|
||||
if lowered.startswith("javascript:") or lowered.startswith("data:text/html"):
|
||||
return f"executable URL on <{tag}>: {stripped[:80]}"
|
||||
remote = lowered.startswith(("http://", "https://", "//")) or (
|
||||
":" in stripped.split("/", 1)[0] and not lowered.startswith("data:")
|
||||
)
|
||||
if not remote:
|
||||
if lowered.startswith("data:") and not lowered.startswith("data:image/"):
|
||||
return f"non-image data URL on <{tag}>: {stripped[:80]}"
|
||||
return None
|
||||
if tag == "link" and "stylesheet" in rel.casefold().split():
|
||||
if is_approved_google_fonts_stylesheet(stripped):
|
||||
return None
|
||||
return f"remote stylesheet is not the approved Google Fonts /css2 URL: {stripped[:80]}"
|
||||
return f"remote reference on <{tag}>: {stripped[:80]}"
|
||||
|
||||
|
||||
def canonical_controller() -> str:
|
||||
if not MOTION_TEMPLATE.is_file():
|
||||
raise RuntimeError(
|
||||
f"cannot find the canonical controller at {MOTION_TEMPLATE}; "
|
||||
"run self_check.py from its shipped location inside the skill"
|
||||
)
|
||||
parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8"))
|
||||
if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]:
|
||||
raise RuntimeError("template-motion.html must contain one closed controller")
|
||||
body = parser.scripts[0]["body"]
|
||||
assert isinstance(body, list)
|
||||
return normalized_controller("".join(body))
|
||||
|
||||
|
||||
def check_svgs(parser: DiagramParser, errors: list[str]) -> None:
|
||||
checkable = [
|
||||
svg
|
||||
for svg in parser.svgs
|
||||
if isinstance(svg["attrs"], dict)
|
||||
and str(svg["attrs"].get("aria-hidden", "")).casefold() != "true"
|
||||
]
|
||||
if not checkable:
|
||||
errors.append("diagram file needs at least one accessible (non-aria-hidden) SVG")
|
||||
for number, svg in enumerate(checkable, 1):
|
||||
attrs = svg["attrs"]
|
||||
assert isinstance(attrs, dict)
|
||||
if attrs.get("role") != "img":
|
||||
errors.append(f"svg {number} needs role=img")
|
||||
labelled = attrs.get("aria-labelledby", "").split()
|
||||
title = svg["title"]
|
||||
desc = svg["desc"]
|
||||
assert isinstance(title, dict) and isinstance(desc, dict)
|
||||
title_attrs = title.get("attrs", {})
|
||||
desc_attrs = desc.get("attrs", {})
|
||||
assert isinstance(title_attrs, dict) and isinstance(desc_attrs, dict)
|
||||
if svg["first"] != "title":
|
||||
errors.append(f"svg {number} title must be its first child")
|
||||
if not str(title.get("text", "")).strip() or not str(desc.get("text", "")).strip():
|
||||
errors.append(f"svg {number} needs non-empty title and desc")
|
||||
title_id = title_attrs.get("id", "")
|
||||
desc_id = desc_attrs.get("id", "")
|
||||
if title_id in {"", "title"} or desc_id in {"", "desc"}:
|
||||
errors.append(f"svg {number} title/desc IDs must be diagram-prefixed, never bare")
|
||||
if labelled != [title_id, desc_id]:
|
||||
errors.append(f"svg {number} aria-labelledby must name title then desc")
|
||||
|
||||
|
||||
def check_scripts(parser: DiagramParser, errors: list[str]) -> None:
|
||||
if not parser.scripts:
|
||||
return
|
||||
if len(parser.scripts) > 1:
|
||||
errors.append(f"at most one script is allowed; found {len(parser.scripts)}")
|
||||
for number, script in enumerate(parser.scripts, 1):
|
||||
attrs = script["attrs"]
|
||||
attr_names = script["attr_names"]
|
||||
body = script["body"]
|
||||
assert isinstance(attrs, dict) and isinstance(attr_names, list) and isinstance(body, list)
|
||||
if not script["closed"]:
|
||||
errors.append(f"script {number} must have a closing script tag")
|
||||
if attr_names != ["data-diagram-controls"] or attrs.get("data-diagram-controls") != "":
|
||||
errors.append(f"script {number} must carry only the canonical data-diagram-controls attribute")
|
||||
continue
|
||||
try:
|
||||
if normalized_controller("".join(body)) != canonical_controller():
|
||||
errors.append(f"script {number} must exactly match the controller in template-motion.html")
|
||||
except RuntimeError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
|
||||
def check_motion(parser: DiagramParser, source: str, errors: list[str]) -> None:
|
||||
has_motion_markup = bool(parser.roots or parser.items or parser.scripts)
|
||||
if not has_motion_markup:
|
||||
return
|
||||
if len(parser.roots) != 1:
|
||||
errors.append(f"expected exactly one data-motion-root; found {len(parser.roots)}")
|
||||
return
|
||||
root = parser.roots[0]
|
||||
mode = root.get("data-motion-mode", "")
|
||||
if mode not in MODES:
|
||||
errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}")
|
||||
raw_count = root.get("data-step-count", "")
|
||||
if not ASCII_DECIMAL_RE.fullmatch(raw_count):
|
||||
count = -1
|
||||
errors.append("data-step-count must be an ASCII decimal integer")
|
||||
else:
|
||||
count = int(raw_count)
|
||||
minimum_count = 0 if mode == "none" else 1
|
||||
if count < minimum_count or count > 8:
|
||||
errors.append(f"semantic step count must be {minimum_count}..8; got {count}")
|
||||
|
||||
if len(parser.items) > 12:
|
||||
errors.append(f"motion item budget is 12; found {len(parser.items)}")
|
||||
semantic_steps: list[int] = []
|
||||
for index, item in enumerate(parser.items, 1):
|
||||
raw_step = item.get("data-step", "")
|
||||
if not ASCII_DECIMAL_RE.fullmatch(raw_step):
|
||||
errors.append(f"motion item {index} has a non-ASCII-decimal data-step")
|
||||
continue
|
||||
step = int(raw_step)
|
||||
decorative = "data-motion-decorative" in item
|
||||
if not decorative:
|
||||
semantic_steps.append(step)
|
||||
if not item.get("aria-label", "").strip():
|
||||
errors.append(f"semantic motion item {index} needs a non-color aria-label")
|
||||
elif item.get("aria-hidden") != "true" or item.get("focusable") != "false":
|
||||
errors.append(f"decorative motion item {index} needs aria-hidden=true and focusable=false")
|
||||
inline = item.get("style", "").replace(" ", "").lower()
|
||||
if any(token in inline for token in ("display:none", "visibility:hidden", "opacity:0")):
|
||||
errors.append(f"motion item {index} is hidden in source; the fallback must be visible")
|
||||
|
||||
expected = set(range(1, count + 1)) if count > 0 else set()
|
||||
if set(semantic_steps) != expected:
|
||||
errors.append(f"semantic steps must be contiguous 1..{count}; found {sorted(set(semantic_steps))}")
|
||||
crowded = {step: n for step, n in Counter(semantic_steps).items() if n > 2}
|
||||
if crowded:
|
||||
errors.append(f"no more than two semantic items may share a step; found {crowded}")
|
||||
|
||||
if mode in {"none", "loop"} and parser.scripts:
|
||||
errors.append(f"{mode} mode must be script-free")
|
||||
if mode in {"none", "loop"} and (parser.controls or parser.actions or parser.statuses):
|
||||
errors.append(f"{mode} mode must not expose playback controls or live status")
|
||||
controlled = mode == "step" or (mode == "reveal" and bool(parser.scripts))
|
||||
if controlled:
|
||||
if parser.controls != 1:
|
||||
errors.append(f"controlled mode needs one in-root control group; found {parser.controls}")
|
||||
missing = ACTIONS - parser.actions
|
||||
if missing:
|
||||
errors.append(f"controlled mode is missing actions: {', '.join(sorted(missing))}")
|
||||
if not parser.statuses:
|
||||
errors.append("controlled mode needs data-motion-status")
|
||||
else:
|
||||
status = parser.statuses[0]
|
||||
if (
|
||||
status.get("role") != "status"
|
||||
or status.get("aria-live") != "polite"
|
||||
or status.get("aria-atomic") != "true"
|
||||
):
|
||||
errors.append("motion status needs role=status, aria-live=polite, aria-atomic=true")
|
||||
if parser.statuses_in_controls:
|
||||
errors.append("motion status must sit outside data-motion-controls")
|
||||
if not parser.scripts:
|
||||
errors.append("controlled mode needs the scoped control script")
|
||||
|
||||
style_source = "".join(parser.styles)
|
||||
if parser.scripts:
|
||||
if re.search(r"prefers-reduced-motion\s*:\s*reduce", style_source, re.IGNORECASE) is None:
|
||||
errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)")
|
||||
if re.search(r"@media\s+print\b", style_source, re.IGNORECASE) is None:
|
||||
errors.append("missing print CSS fallback (@media print)")
|
||||
if "<noscript" not in source.casefold():
|
||||
errors.append("motion file needs a <noscript> explanation of the complete static frame")
|
||||
|
||||
|
||||
def verify(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
parser = parsed_document(source)
|
||||
errors: list[str] = []
|
||||
errors.extend(parser.unsafe)
|
||||
for tag, rel, value in parser.references:
|
||||
finding = reference_error(tag, rel, value)
|
||||
if finding:
|
||||
errors.append(finding)
|
||||
check_svgs(parser, errors)
|
||||
check_scripts(parser, errors)
|
||||
check_motion(parser, source, errors)
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argument_parser = argparse.ArgumentParser(description=__doc__)
|
||||
argument_parser.add_argument("files", nargs="+", type=Path)
|
||||
args = argument_parser.parse_args()
|
||||
failed = False
|
||||
for path in args.files:
|
||||
try:
|
||||
errors = verify(path)
|
||||
except (OSError, UnicodeError) as exc:
|
||||
errors = [str(exc)]
|
||||
if errors:
|
||||
failed = True
|
||||
print(f"FAIL {path}")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
else:
|
||||
print(f"OK {path}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user