Files
diagram-design/scripts/verify-drawio-import.py
T
ClaudeandCathryn Lavery ced802198d feat(import): redraw draw.io files at a chosen format, size, and detail level
Adds a draw.io import path to the skill. Points at a .drawio file and
produces an editorial diagram fitted to where it's going — not a
format conversion.

- scripts/drawio_extract.py: decodes all four containers draw.io writes
  (raw XML, deflate+base64 payloads, PNG tEXt chunks, SVG content attrs),
  flattens mxGraphModel into absolute-positioned nodes/edges, and reports
  structural signals: hubs, containers, depth, cycles, collapsible leaf
  groups, budget flags. Deterministic half — makes no design decisions.
- references/output-spec.md: the three dials. Nine size presets with
  per-size-class type ramps and safe areas; faithful/balanced/simplified
  detail levels with a fixed degrade ladder; engineer/mixed/executive
  audience wording; fidelity ledger; CJK font fallback.
- references/import-drawio.md: the redraw procedure — extract, set dials,
  pick type, build the semantic model, redraw on the grid. Includes the
  draw.io palette/shape → semantic role mappings, multi-page handling,
  edge cases, and anti-patterns.
- commands/import-drawio.md: /diagram-design:import slash command.
- SKILL.md: new §11 (import + output dials), taste-gate entries, §12 output.
- export.md: sizing section — scale factors and exact-pixel targets.
- assets/example-import-drawio.html: worked import of the checked-in
  fixture at balanced/doc-inline, lint-clean.
- scripts/verify-drawio-import.py: drives the real extractor against the
  fixture in all four containers and checks the references stay in sync.

faithful is the one documented exemption from the §7 complexity budget,
and it's conditional: zoned above 9 nodes, split above 24. The §6
connector rules never relax for imported content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5f1aK1zNcgnQUnXZgG2LU
2026-08-11 13:51:30 -07:00

297 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Structural verification for the draw.io import + output-spec work.
Drives the real shipped artifacts (drawio_extract.py, import-drawio.md,
output-spec.md, SKILL.md, the slash command) against the checked-in fixture.
Exit 0 only when all gates pass. Intended for local/PR checks alongside:
python scripts/lint-skin.py --all --baseline
"""
from __future__ import annotations
import base64
import json
import re
import struct
import subprocess
import sys
import zlib
from pathlib import Path
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent
SKILL = ROOT / "skills/diagram-design/SKILL.md"
EXTRACT = ROOT / "skills/diagram-design/scripts/drawio_extract.py"
IMPORT_REF = ROOT / "skills/diagram-design/references/import-drawio.md"
OUTPUT_REF = ROOT / "skills/diagram-design/references/output-spec.md"
EXPORT_REF = ROOT / "skills/diagram-design/references/export.md"
COMMAND = ROOT / "commands/import-drawio.md"
FIXTURE = ROOT / "scripts/fixtures/sample-architecture.drawio"
EXAMPLE = ROOT / "skills/diagram-design/assets/example-import-drawio.html"
def fail(msg: str) -> None:
print(f"FAIL: {msg}", file=sys.stderr)
raise SystemExit(1)
def ok(msg: str) -> None:
print(f"OK: {msg}")
def run_extract(args: list[str]) -> str:
proc = subprocess.run(
[sys.executable, str(EXTRACT), *args],
capture_output=True,
text=True,
)
if proc.returncode != 0:
fail(f"extractor exited {proc.returncode} for {args}: {proc.stderr.strip()}")
return proc.stdout
def compress_model(model: str) -> str:
compressor = zlib.compressobj(9, zlib.DEFLATED, -15)
payload = quote(model, safe="")
raw = compressor.compress(payload.encode()) + compressor.flush()
return base64.b64encode(raw).decode()
def png_chunk(ctype: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ ctype
+ data
+ struct.pack(">I", zlib.crc32(ctype + data) & 0xFFFFFFFF)
)
def check_files() -> None:
for path in (SKILL, EXTRACT, IMPORT_REF, OUTPUT_REF, EXPORT_REF, COMMAND, FIXTURE, EXAMPLE):
if not path.is_file():
fail(f"missing {path.relative_to(ROOT)}")
ok("all import artifacts present")
def check_parse_raw() -> dict:
payload = json.loads(run_extract([str(FIXTURE), "--json"]))
page = payload["pages"][0]
analysis = page["analysis"]
nodes = {n["id"]: n for n in page["nodes"]}
if analysis["nodes_total"] != 12 or analysis["edges_total"] != 8:
fail(f"fixture graph mis-parsed: {analysis['nodes_total']}n/{analysis['edges_total']}e")
if analysis["containers"] != 2:
fail(f"expected 2 containers, got {analysis['containers']}")
# Child geometry must be resolved to absolute coordinates (parent 360 + 20).
if (nodes["gw"]["x"], nodes["gw"]["y"]) != (380.0, 80.0):
fail(f"absolute geometry wrong for gw: {nodes['gw']['x']},{nodes['gw']['y']}")
if nodes["gw"]["depth"] != 1:
fail("container depth not tracked")
# HTML labels flattened, entities decoded, <br> preserved as a line break.
if nodes["web"]["label"] != "Web App\nnext.js":
fail(f"label not cleaned: {nodes['web']['label']!r}")
# Shape classification across style keys, shape= values, and stencil families.
expected_shapes = {
"zone-edge": "swimlane",
"check": "rhombus",
"pg": "cylinder",
"s3": "icon:aws",
"note1": "note",
}
for nid, shape in expected_shapes.items():
if nodes[nid]["shape"] != shape:
fail(f"{nid}: expected shape {shape}, got {nodes[nid]['shape']}")
# Degree counts feed focal-node selection.
if (nodes["gw"]["in_degree"], nodes["gw"]["out_degree"]) != (2, 2):
fail("degree counting wrong for the hub node")
if not analysis["hubs"] or analysis["hubs"][0]["id"] != "gw":
fail("hub ranking did not surface the gateway")
# An edge label carried by a child edgeLabel cell must fold into the edge.
e1 = next(e for e in page["edges"] if e["id"] == "e1")
if e1["label"] != "HTTPS":
fail(f"edgeLabel child not folded into edge: {e1['label']!r}")
if not next(e for e in page["edges"] if e["id"] == "e7")["dashed"]:
fail("dashed edge style not detected")
if any(e["source"] is None or e["target"] is None for e in page["edges"]):
fail("edge endpoints lost")
# Container zones are not lanes: the swimlane candidate must not fire here.
if analysis["type_candidates"][0] == "swimlane":
fail("unaligned container zones mis-detected as a swimlane")
if not analysis["over_node_budget"]:
fail("12-node source should flag the 9-node budget")
if not analysis["collapsible_groups"]:
fail("collapsible groups not reported")
ok("raw XML fixture parses: geometry, labels, shapes, degrees, edges")
return payload
def check_containers(tmp: Path) -> None:
model = re.search(
r"<mxGraphModel.*?</mxGraphModel>", FIXTURE.read_text(encoding="utf-8"), re.S
)
if not model:
fail("fixture has no mxGraphModel")
encoded = compress_model(model.group(0))
mxfile = (
'<mxfile host="app.diagrams.net">'
f'<diagram name="Compressed" id="c1">{encoded}</diagram>'
f'<diagram name="Second" id="c2">{encoded}</diagram>'
"</mxfile>"
)
compressed = tmp / "compressed.drawio"
compressed.write_text(mxfile, encoding="utf-8")
png = tmp / "embedded.drawio.png"
png.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ png_chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0))
+ png_chunk(b"tEXt", b"mxfile\x00" + quote(mxfile, safe="").encode())
+ png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00\x00"))
+ png_chunk(b"IEND", b"")
)
svg = tmp / "embedded.drawio.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" content="'
+ mxfile.replace("&", "&amp;").replace("<", "&lt;").replace('"', "&quot;")
+ '"><g/></svg>',
encoding="utf-8",
)
for path in (compressed, png, svg):
payload = json.loads(run_extract([str(path), "--json"]))
analysis = payload["pages"][0]["analysis"]
if analysis["nodes_total"] != 12 or analysis["edges_total"] != 8:
fail(f"{path.name}: decoded graph differs from the raw fixture")
ok("deflate+base64, PNG-embedded, and SVG-embedded containers all decode")
# Page selection.
digest = run_extract([str(compressed)])
if "2 page(s)" not in digest:
fail("multi-page header not reported")
if "## Page 1" in digest:
fail("default run must emit only the first page")
if "## Page 1" not in run_extract([str(compressed), "--page", "all"]):
fail("--page all did not emit both pages")
if "## Page 1" not in run_extract([str(compressed), "--page", "Second"]):
fail("--page by name failed")
ok("page selection: default, --page all, --page by name")
bad = tmp / "notadiagram.txt"
bad.write_text("hello", encoding="utf-8")
proc = subprocess.run(
[sys.executable, str(EXTRACT), str(bad)], capture_output=True, text=True
)
if proc.returncode != 2 or "not a draw.io file" not in proc.stderr:
fail("non-draw.io input must exit 2 with a clear message")
ok("unsupported input exits 2 with a diagnostic")
def check_docs() -> None:
import_text = IMPORT_REF.read_text(encoding="utf-8")
for needle in (
"drawio_extract.py",
"output-spec.md",
"## Step 1 — Extract the IR",
"## Step 3 — Pick the target type",
"Multi-page files",
"## Anti-patterns",
"fidelity ledger",
"--page all",
):
if needle not in import_text:
fail(f"import-drawio.md missing {needle!r}")
for container in (".drawio.png", ".drawio.svg"):
if container not in import_text:
fail(f"import-drawio.md does not cover {container}")
output_text = OUTPUT_REF.read_text(encoding="utf-8")
for preset in (
"doc-inline",
"doc-wide",
"slide-16x9",
"slide-4x3",
"social-og",
"social-square",
"print-a4-landscape",
"print-letter-landscape",
"`fit`",
):
if preset not in output_text:
fail(f"output-spec.md missing size preset {preset}")
for level in ("faithful", "balanced", "simplified"):
if level not in output_text:
fail(f"output-spec.md missing detail level {level}")
for audience in ("engineer", "mixed", "executive"):
if audience not in output_text:
fail(f"output-spec.md missing audience level {audience}")
for needle in ("Degrade ladder", "Fidelity ledger", "Type ramp", "Non-Latin labels"):
if needle not in output_text:
fail(f"output-spec.md missing section {needle!r}")
# Every viewBox preset must respect the 4px grid rule (SKILL.md §7).
for w, h in re.findall(r"`0 0 (\d+) (\d+)`", output_text):
if int(w) % 4 or int(h) % 4:
fail(f"viewBox preset {w}×{h} is off the 4px grid")
if "Sizing the export" not in EXPORT_REF.read_text(encoding="utf-8"):
fail("export.md missing the sizing section")
skill_text = SKILL.read_text(encoding="utf-8")
for needle in (
"## 11. Importing an Existing Diagram (draw.io)",
"## 12. Output",
"references/import-drawio.md",
"references/output-spec.md",
"drawio_extract.py",
):
if needle not in skill_text:
fail(f"SKILL.md missing {needle!r}")
if ".drawio" not in skill_text.split("---")[1]:
fail("SKILL.md frontmatter description does not mention draw.io import")
command_text = COMMAND.read_text(encoding="utf-8")
for flag in ("--format", "--size", "--detail", "--audience", "--page", "--variant"):
if flag not in command_text:
fail(f"import command missing {flag}")
if "example-import-drawio.html" not in import_text:
fail("import-drawio.md does not point at the worked example")
example = EXAMPLE.read_text(encoding="utf-8")
if 'viewBox="0 0 960 600"' not in example:
fail("worked example does not use the doc-inline viewBox")
if example.count("#eb6c36") > 4:
fail("worked example uses the accent on more than the focal node + legend")
proc = subprocess.run(
[sys.executable, str(ROOT / "scripts/lint-skin.py"), str(EXAMPLE)],
capture_output=True,
text=True,
)
if proc.returncode != 0:
fail(f"worked example fails lint-skin: {proc.stdout.strip()}")
ok("worked example present, doc-inline sized, and skin-clean")
ok("references, SKILL.md wiring, and slash command are consistent")
def main() -> int:
tmp = ROOT / ".verify-drawio-tmp"
tmp.mkdir(exist_ok=True)
try:
check_files()
check_parse_raw()
check_containers(tmp)
check_docs()
finally:
for child in tmp.glob("*"):
child.unlink()
tmp.rmdir()
print("\nAll draw.io import gates passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())