From ced802198d622cfe544fbc1d2b5452e038a5f55f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:14:17 +0000 Subject: [PATCH] feat(import): redraw draw.io files at a chosen format, size, and detail level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01M5f1aK1zNcgnQUnXZgG2LU --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 8 +- .codex-plugin/plugin.json | 8 +- README.md | 57 +- commands/import-drawio.md | 50 ++ docs/screenshots/import-drawio.png | Bin 0 -> 106905 bytes scripts/fixtures/sample-architecture.drawio | 77 ++ scripts/verify-drawio-import.py | 296 +++++++ skills/diagram-design/SKILL.md | 39 +- .../assets/example-import-drawio.html | 183 ++++ skills/diagram-design/references/export.md | 26 + .../references/import-drawio.md | 169 ++++ .../diagram-design/references/output-spec.md | 179 ++++ .../diagram-design/scripts/drawio_extract.py | 789 ++++++++++++++++++ 14 files changed, 1873 insertions(+), 10 deletions(-) create mode 100644 commands/import-drawio.md create mode 100644 docs/screenshots/import-drawio.png create mode 100644 scripts/fixtures/sample-architecture.drawio create mode 100644 scripts/verify-drawio-import.py create mode 100644 skills/diagram-design/assets/example-import-drawio.html create mode 100644 skills/diagram-design/references/import-drawio.md create mode 100644 skills/diagram-design/references/output-spec.md create mode 100644 skills/diagram-design/scripts/drawio_extract.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0e28335..6701395 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "metadata": { - "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand" + "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io import that redraws existing .drawio files at a chosen format, size, and level of detail" }, "owner": { "name": "Cathryn Lavery" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index cdc047a..174be96 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", - "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand", - "version": "2.0.0", + "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io import that redraws existing .drawio files at a chosen format, size, and level of detail", + "version": "2.1.0", "homepage": "https://github.com/cathrynlavery/diagram-design", "repository": "https://github.com/cathrynlavery/diagram-design", "license": "MIT", @@ -11,6 +11,8 @@ "architecture", "flowchart", "visualization", - "editorial" + "editorial", + "drawio", + "import" ] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 40a0dc8..84c69dc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", - "description": "Editorial-quality technical and product diagrams - 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand", - "version": "2.0.0", + "description": "Editorial-quality technical and product diagrams - 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io import that redraws existing .drawio files at a chosen format, size, and level of detail", + "version": "2.1.0", "author": { "name": "Cathryn Lavery", "url": "https://github.com/cathrynlavery" @@ -15,7 +15,9 @@ "architecture", "flowchart", "visualization", - "editorial" + "editorial", + "drawio", + "import" ], "skills": "./skills/", "interface": { diff --git a/README.md b/README.md index ba712db..ee8eec0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ *New in 2.0 — the Loop: flywheels with a shared-memory hub. The dashed lines are the write-backs.* -27 types. One agent skill for Claude Code, Codex, and Pi. Your brand in 60 seconds — the skill reads your website and maps colors + fonts to every diagram. +27 types. One agent skill for Claude Code, Codex, and Pi. Your brand in 60 seconds — the skill reads your website and maps colors + fonts to every diagram. Already have diagrams in draw.io? Point it at the file and it redraws them at the format, size, and level of detail your audience needs. No Figma. No generic rounded boxes. No 30-minute color-picking sessions. @@ -204,6 +204,48 @@ cp skills/diagram-design/assets/template-full.html my-diagram.html # editorial --- +## Import from draw.io + +Already have diagrams in draw.io / diagrams.net? Point the skill at the file and it **redraws** them — same content, this design system, at whatever the destination needs. + +![Redrawn from a .drawio file](docs/screenshots/import-drawio.png) + +*A 12-node draw.io file redrawn at `balanced` detail for a blog post. The source's six pastel fills became one accent; its hand-dragged coordinates became a 4px grid.* + +``` +/diagram-design:import platform.drawio +/diagram-design:import platform.drawio --size=slide-16x9 --detail=simplified --audience=executive +/diagram-design:import platform.drawio --detail=faithful --format=png --page=all +``` + +Or just ask: *"redraw this drawio file for my deck"* / *"この drawio をスライド用にきれいにして"*. + +Reads every container draw.io writes — `.drawio`, `.drawio.xml`, `.drawio.png` (embedded diagram), `.drawio.svg` — including the compressed payloads that look like base64 garbage in an editor. + +### The three dials + +The point isn't conversion, it's **fitting the output to where it's going**. Same source file, three different diagrams: + +| Dial | Options | What it changes | +|---|---|---| +| **Format** | `html` · `svg` · `png` · `html+png` | The deliverable. SVG for Figma, PNG for slides, HTML for the web. | +| **Size** | `doc-inline` · `doc-wide` · `slide-16x9` · `slide-4x3` · `social-og` · `social-square` · `print-a4-landscape` · `print-letter-landscape` · `fit` | The `viewBox` **and the type ramp** — a projected slide gets 16px node names, not 12px. | +| **Detail** | `faithful` (≤24 nodes, zoned) · `balanced` (≤12) · `simplified` (≤7) | How much of the source survives, via a fixed degrade ladder — decorations, then duplicates, then leaf clusters, then infrastructure. | +| **Audience** | `engineer` · `mixed` · `executive` | The *wording*, not the count. `Auth Service / JWT · RS256 · :8443` → `Auth Service / token check` → `Sign-in`. | + +Every import ends with a **fidelity ledger** — what got merged, collapsed, or dropped. You know the source; you'd notice anyway. + +``` +Detail: balanced · 12 source nodes → 8 drawn +Collapsed: "Token valid?" decision → edge label on Gateway → Auth +Dropped: 1 sticky note ("legacy path, to be retired") — unconnected in source +Kept in full: the request path (Web/Mobile → Gateway → Orders → Postgres) +``` + +What never carries over: source coordinates, source palette, source fonts, draw.io's diagonal connector spaghetti. What always does: components, relationships, grouping, and direction. See [`references/import-drawio.md`](skills/diagram-design/references/import-drawio.md) and [`references/output-spec.md`](skills/diagram-design/references/output-spec.md). + +--- + ## Export to PNG / SVG Diagrams ship as self-contained HTML, but you can export the diagram itself for Figma, slides, or social cards. Use the slash command for your agent: @@ -244,6 +286,9 @@ Progressive disclosure. `SKILL.md` is a lean index — it tells the agent how to ``` diagram-design/ +├── commands/ +│ ├── export-diagram.md — Claude Code export command +│ └── import-drawio.md — Claude Code draw.io import command ├── prompts/ │ └── export-diagram.md — Pi `/export-diagram` prompt template ├── skills/ @@ -252,6 +297,9 @@ diagram-design/ │ ├── references/ — loaded only when a type or primitive is chosen │ │ ├── style-guide.md — single source of truth for colors + fonts │ │ ├── onboarding.md — the URL-to-tokens flow +│ │ ├── import-drawio.md — draw.io redraw procedure +│ │ ├── output-spec.md — format × size × detail level +│ │ ├── export.md — SVG / PNG export + sizing │ │ ├── type-architecture.md │ │ ├── type-flowchart.md │ │ ├── type-sequence.md @@ -269,12 +317,15 @@ diagram-design/ │ │ ├── primitive-annotation.md │ │ ├── primitive-sketchy.md │ │ └── primitive-terminal.md +│ ├── scripts/ +│ │ └── drawio_extract.py — draw.io → structured IR │ └── assets/ │ ├── index.html — live gallery, tabbed │ ├── template*.html — scaffolds for new diagrams │ ├── example-.html — 3 variants × 27 types │ ├── example-loop-terminal.html │ ├── example-quadrant-consultant.html +│ ├── example-import-drawio.html │ └── example-sequence-oauth*.html └── docs/screenshots/ — images used in this README ``` @@ -285,6 +336,9 @@ This keeps the agent's working context tight (only load what you need) and makes Before submitting a new example, run `python3 scripts/lint-skin.py `. The repository-wide check `python3 scripts/lint-skin.py --all --baseline` must stay green. +If you touch the draw.io import path, `python3 scripts/verify-drawio-import.py` must also pass — +it drives the real extractor against `scripts/fixtures/sample-architecture.drawio` in all four +container formats and checks the references stay in sync. ### What loads when @@ -298,6 +352,7 @@ At startup, the agent sees only the skill name and description. When a request m | "Add an editorial callout to this diagram" | `SKILL.md` + `references/primitive-annotation.md` | | "Give me a hand-drawn version" | `SKILL.md` + `references/primitive-sketchy.md` | | "Give me a terminal / CLI-window version" | `SKILL.md` + `references/primitive-terminal.md` | +| "Redraw this .drawio file for my deck" | `SKILL.md` + `references/import-drawio.md` + `references/output-spec.md` + the chosen type's reference | | Routine diagram-making (any of the 27 diagrams) | Only `SKILL.md` + that one type's reference | No matter how many types exist, the agent only reads the one you need. Add a new type tomorrow and nothing else changes. diff --git a/commands/import-drawio.md b/commands/import-drawio.md new file mode 100644 index 0000000..78e2b57 --- /dev/null +++ b/commands/import-drawio.md @@ -0,0 +1,50 @@ +--- +description: Redraw a draw.io file as an editorial diagram at a chosen format, size, and detail level +argument-hint: [--format=html|svg|png|html+png] [--size=] [--detail=faithful|balanced|simplified] [--audience=engineer|mixed|executive] [--type=] [--page=N|NAME|all] [--variant=light|dark|full] [--output=] +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob +--- + +Redraw the draw.io file at `$1` as a diagram in this skill's design system, following [`skills/diagram-design/references/import-drawio.md`](../skills/diagram-design/references/import-drawio.md) and [`skills/diagram-design/references/output-spec.md`](../skills/diagram-design/references/output-spec.md). Treat those references as the source of truth — don't reimplement the logic here. + +Full argument string: `$ARGUMENTS` + +Accepts `.drawio`, `.drawio.xml`, `.xml`, `.drawio.png`, and `.drawio.svg`. + +## Defaults + +- `--format=html` — a self-contained HTML file next to the source. +- `--size=doc-inline` — `viewBox 0 0 960 600`. +- `--detail=balanced` · `--audience=mixed`. +- `--variant=light` — the minimal light template. +- Page 0 when the file has several pages. +- Type is chosen from the extracted structure; `--type` forces one of the 27. + +## Flags + +- `--format` — `html` (default), `svg`, `png`, or `html+png`. Non-HTML formats are produced from the HTML via `references/export.md`, never hand-authored. +- `--size` — any preset in `output-spec.md` §2: `doc-inline`, `doc-wide`, `slide-16x9`, `slide-4x3`, `social-og`, `social-square`, `print-a4-landscape`, `print-letter-landscape`, `fit`. +- `--detail` — `faithful` (≤24 nodes, zoned), `balanced` (≤12), `simplified` (≤7). +- `--audience` — `engineer`, `mixed`, `executive`. Governs wording, not element count. +- `--type` — force a diagram type instead of inferring it. +- `--page` — page index, page name, or `all` (one file per page). +- `--variant` — `light`, `dark`, or `full` editorial template. +- `--output` — output base path; the extension is appended per format. + +## Required behaviour + +1. **No file provided** → ask which `.drawio` file. Don't guess. +2. **Always run `skills/diagram-design/scripts/drawio_extract.py` first.** Never read a `.drawio` file directly — most are compressed, and the raw XML is noise. +3. **Extractor exits non-zero** → report its message verbatim and stop. +4. **Digest shows 0 nodes** → the source is image-only or encrypted. Say so; ask for the original file. Don't invent content. +5. **Multi-page file with no `--page`** → list the pages with their node/edge counts and ask which one. +6. **Requested detail level is impossible at the requested size** (e.g. `faithful` on a 40-node source at `slide-16x9`) → say so before drawing and propose overview + per-zone detail. +7. **`--detail=faithful` above 9 nodes** → zone the layout; above 24 nodes, split into overview + detail files. +8. **Never carry over source coordinates, colors, or fonts.** The output is a redraw in the project's `style-guide.md` skin. +9. Run the SKILL.md §9 taste gate and the `output-spec.md` §6 checklist before writing. + +After writing the files, report the paths, sizes, the three dials used, and the fidelity ledger (what was merged, collapsed, or dropped). diff --git a/docs/screenshots/import-drawio.png b/docs/screenshots/import-drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..84a07e44e457d9eabee3edba5d8bd3a944d576d9 GIT binary patch literal 106905 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 literal 0 HcmV?d00001 diff --git a/scripts/fixtures/sample-architecture.drawio b/scripts/fixtures/sample-architecture.drawio new file mode 100644 index 0000000..7df3cc8 --- /dev/null +++ b/scripts/fixtures/sample-architecture.drawio @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/verify-drawio-import.py b/scripts/verify-drawio-import.py new file mode 100644 index 0000000..5e7a959 --- /dev/null +++ b/scripts/verify-drawio-import.py @@ -0,0 +1,296 @@ +#!/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,
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"", FIXTURE.read_text(encoding="utf-8"), re.S + ) + if not model: + fail("fixture has no mxGraphModel") + encoded = compress_model(model.group(0)) + mxfile = ( + '' + f'{encoded}' + f'{encoded}' + "" + ) + + 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( + '', + 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()) diff --git a/skills/diagram-design/SKILL.md b/skills/diagram-design/SKILL.md index cf10009..9806f83 100644 --- a/skills/diagram-design/SKILL.md +++ b/skills/diagram-design/SKILL.md @@ -1,6 +1,6 @@ --- name: diagram-design -description: Create technical and product diagrams — architecture, IT current-state, flowchart, sequence, state machine, ER / data model, timeline, swimlane, quadrant, radar / spider, loop / flywheel, nested, tree, org chart, layer stack, venn, pyramid / funnel, bar chart, line chart, Gantt, scatter plot, high-level, process, medallion, data flow, DP integration, DP security matrix — as standalone HTML files with inline SVG. Ships with a neutral editorial skin and a first-run gate that prompts users to customize the style guide (colors, fonts) from their own website before generating. Includes annotation-callout primitive and optional sketchy variant. +description: Create technical and product diagrams — architecture, IT current-state, flowchart, sequence, state machine, ER / data model, timeline, swimlane, quadrant, radar / spider, loop / flywheel, nested, tree, org chart, layer stack, venn, pyramid / funnel, bar chart, line chart, Gantt, scatter plot, high-level, process, medallion, data flow, DP integration, DP security matrix — as standalone HTML files with inline SVG. Also imports existing draw.io / diagrams.net files (.drawio, .drawio.png, .drawio.svg) and redraws them at a chosen output format (HTML / SVG / PNG), canvas size (slide, social card, doc, print), and level of detail (faithful reproduction or simplified for the audience's technical level). Ships with a neutral editorial skin and a first-run gate that prompts users to customize the style guide (colors, fonts) from their own website before generating. Includes annotation-callout primitive and optional sketchy variant. license: MIT metadata: version: "2.0" @@ -411,6 +411,8 @@ Run before producing any diagram. - [ ] Right type for what I'm showing? (§3 selection guide) - [ ] Would a table / paragraph do the same job? (If yes — don't draw.) - [ ] Loaded the matching `references/type-*.md`? +- [ ] Output dials set — format, size, detail level, audience? `viewBox` and type ramp match the size preset? (§11, [output-spec.md §6](references/output-spec.md)) +- [ ] If this is a draw.io import — fidelity ledger ready to report? (§11) **Remove test:** @@ -473,7 +475,38 @@ Every diagram ships in three variants (see `assets/`): --- -## 11. Output +## 11. Importing an Existing Diagram (draw.io) + +When the user points at a `.drawio`, `.drawio.xml`, `.drawio.png`, or `.drawio.svg` file — "convert this", "redraw this diagram", "make this presentable", or the `/diagram-design:import` command — load [`references/import-drawio.md`](references/import-drawio.md) and follow it. + +The short version: + +1. **Extract, don't read.** Run `python3 scripts/drawio_extract.py `. Most `.drawio` files are deflate+base64 payloads; the script handles every container draw.io writes and prints a digest of nodes, edges, containers, hubs, and budget flags. +2. **Set the three dials** (§ below) before drawing. +3. **Redraw — never convert.** Source coordinates, colors, fonts, and shape quirks are discarded. You keep the *content*: components, relationships, grouping, direction. +4. **Report the fidelity ledger** — what you merged, collapsed, or dropped. The user knows the source and will notice. + +An import is bounded by its source: never invent a component to fill a layout, and never silently drop one. + +### Output dials — format, size, detail level + +Every diagram — imported or authored from scratch — is shaped by three decisions. Full spec in [`references/output-spec.md`](references/output-spec.md); set them **before** drawing, since each one changes the layout. + +| Dial | Options | Default | +|---|---|---| +| **Format** | `html` · `svg` · `png` · `html+png` | `html` | +| **Size** | `doc-inline` · `doc-wide` · `slide-16x9` · `slide-4x3` · `social-og` · `social-square` · `print-a4-landscape` · `print-letter-landscape` · `fit` | `doc-inline` | +| **Detail** | `faithful` (≤24 nodes, zoned) · `balanced` (≤12) · `simplified` (≤7) | `balanced` | +| **Audience** | `engineer` · `mixed` · `executive` — governs wording, not count | `mixed` | + +Two consequences worth remembering here: + +- The size preset sets the `viewBox` **and** the type ramp. A slide gets 16px node names, not 12px — scaling the canvas without scaling the type is how projected diagrams end up unreadable. +- `faithful` is the one documented exemption from the §7 complexity budget, and it's conditional: above 9 nodes the layout must be zoned, above 24 it must split into overview + detail. The connector rules in §6 never relax. + +--- + +## 12. Output Always produce a single self-contained `.html` file: @@ -486,3 +519,5 @@ Renders correctly in any modern browser. ### Exporting to PNG / SVG When the user asks to export, save, rasterize, or convert a generated diagram to `.png` or `.svg`, load [`references/export.md`](references/export.md) and follow the procedure there. Both formats deliver the diagram only (the `` node) — editorial wrappers like cards and headers are dropped by design. Export is **manual** — never produce export files unprompted. + +Pixel dimensions come from the `viewBox` × scale factor, so the size decision belongs to §11, not to export. If the user needs an exact frame (an OG card, a 1920×1080 slide image), see [`export.md` § Sizing the export](references/export.md). diff --git a/skills/diagram-design/assets/example-import-drawio.html b/skills/diagram-design/assets/example-import-drawio.html new file mode 100644 index 0000000..30c9d53 --- /dev/null +++ b/skills/diagram-design/assets/example-import-drawio.html @@ -0,0 +1,183 @@ + + + + + + Imported from draw.io — Order platform + + + + +

+ + diff --git a/skills/diagram-design/references/export.md b/skills/diagram-design/references/export.md index c22ddf1..b6b91dd 100644 --- a/skills/diagram-design/references/export.md +++ b/skills/diagram-design/references/export.md @@ -92,6 +92,32 @@ Default `device_scale_factor=2` for crisp output. Accept `1` for compact assets `example-architecture.html` → `example-architecture.png`, written next to the source. Honour explicit user-provided paths. +## Sizing the export + +The PNG's pixel dimensions are the SVG's `viewBox` × `device_scale_factor`. So the size decision was already made when the diagram was drawn — see [`output-spec.md` §2](output-spec.md) for the presets. Export only picks the multiplier. + +| Destination | Scale | Result from a 1280×720 `viewBox` | +|---|---|---| +| Docs, README, wiki | 2 | 2560×1440 | +| Slide deck (projected) | 2 | 2560×1440 | +| Print / PDF handout | 3 | 3840×2160 | +| Inline thumbnail, email | 1 | 1280×720 | + +### Hitting an exact pixel size + +When the user needs specific dimensions (an OG card at exactly 1200×630, a slide image at 1920×1080), compute the scale factor instead of guessing — Playwright accepts fractional values: + +``` +scale = target_width / viewBox_width +``` + +A 960-wide `viewBox` at a 1200px target is `scale=1.25`. Two rules: + +- **Never scale below 1** to hit a small target — that soft-focuses the type. Redraw at a smaller preset instead. +- **Never scale past 4** — beyond that you're upscaling a layout that was designed for a smaller canvas; redraw at `slide-16x9` or a print preset. + +If the target aspect ratio doesn't match the `viewBox` aspect ratio, say so and offer to redraw at the matching preset. Padding or cropping a finished diagram to fit a frame is not an export operation — it breaks the 40px safe margin. + ## Edge cases - **Source is `assets/index.html`** (the gallery, multiple SVGs in one file): refuse the export and ask the user which specific diagram file they meant. Don't guess. diff --git a/skills/diagram-design/references/import-drawio.md b/skills/diagram-design/references/import-drawio.md new file mode 100644 index 0000000..e478f91 --- /dev/null +++ b/skills/diagram-design/references/import-drawio.md @@ -0,0 +1,169 @@ +# Import from draw.io + +Turn a `.drawio` file into an editorial-quality diagram at the format, size, and detail level the destination needs. + +**This is a redraw, not a conversion.** You read the source for its *content* — components, relationships, grouping, direction — and then draw a new diagram in this skill's design system. Nothing about the source's geometry, palette, or shape vocabulary carries over. A converter that preserved draw.io's layout would just be draw.io output with different fonts. + +## Trigger + +Load this file when the user points at a `.drawio`, `.drawio.xml`, `.drawio.png`, or `.drawio.svg` file and wants a diagram out of it — "convert this drawio", "redraw this diagram", "make this presentable", "この drawio をきれいにして", or the `/diagram-design:import` slash command. + +--- + +## Step 1 — Extract the IR + +Never read a `.drawio` file with Read. Most are deflate+base64 payloads, and even the readable ones are 10× more XML than signal. Run the extractor: + +```bash +python3 /scripts/drawio_extract.py [--page N|NAME|all] +``` + +`` is `skills/diagram-design/` in this repo, or the skill's own directory when it's installed standalone or as a plugin. If the path isn't obvious, glob for `**/diagram-design/scripts/drawio_extract.py`. + +It handles all four containers draw.io writes — raw XML, compressed `` payloads, PNG with an embedded `mxfile` chunk, and SVG with a `content` attribute — and prints a Markdown digest: node/edge tables with absolute geometry, shape classes, hub degrees, container structure, cycle detection, budget flags, and *collapsible groups* (the first things to merge when compressing). + +Options worth knowing: + +- `--page all` — multi-page files. Default is page 0 only; the header line lists every page with its node/edge counts. +- `--json` — full IR when the digest truncated something you need (every style value, every waypoint). +- `--max-rows N` — digest table length, default 40. + +Read the digest, not the file. If the digest is empty (`0 nodes`), the source is an image-only or encrypted file — see *Edge cases*. + +## Step 2 — Set the three dials + +Before drawing, fix format, size, and detail level per [`output-spec.md`](output-spec.md). Ask once for whatever the user didn't specify, and let the digest inform the options you offer: + +> *"18 nodes in 3 groups. Where's this going — slide, blog post, or hand-off? And should I keep every component or compress to the request path?"* + +The digest's `budget:` line tells you whether the ask is even possible: a source over the node budget cannot go to `slide-16x9` at `faithful` without splitting. Say so at this step rather than after drawing. + +## Step 3 — Pick the target type + +The source's shape vocabulary is a hint, not an instruction. draw.io users reach for rectangles because rectangles are what's on the toolbar. + +| Digest signal | Likely type | Reference | +|---|---|---| +| `lifeline` shapes, tall vertical bars | Sequence | [type-sequence.md](type-sequence.md) | +| `table` / `er` shapes, rows of fields | ER / data model | [type-er.md](type-er.md) | +| ≥2 aligned `swimlane` containers (`type candidates: swimlane`) | Swimlane | [type-swimlane.md](type-swimlane.md) | +| `rhombus` present, single entry point, labeled yes/no edges | Flowchart | [type-flowchart.md](type-flowchart.md) | +| Mostly `ellipse`, self-loops, `has_cycle: True` | State machine | [type-state.md](type-state.md) | +| `icon:aws` / `icon:azure` / `icon:gcp` / `icon:kubernetes` families | Architecture | [type-architecture.md](type-architecture.md) | +| Nested containers, depth ≥2, few edges | Nested | [type-nested.md](type-nested.md) | +| One entry point, no cycle, fan-out only | Tree or Org chart | [type-tree.md](type-tree.md), [type-org-chart.md](type-org-chart.md) | +| Boxes stacked vertically, edges only between neighbours | Layer stack | [type-layers.md](type-layers.md) | +| Dated labels on a single axis | Timeline or Gantt | [type-timeline.md](type-timeline.md), [type-gantt.md](type-gantt.md) | +| Anything else with edges | Architecture | [type-architecture.md](type-architecture.md) | + +The digest's `type candidates` field ranks these mechanically. Override it when the content disagrees — a "flowchart" whose diamonds all ask *"which service?"* is an architecture diagram someone drew with the wrong shapes. Tell the user when you override, in one line. + +**Load the chosen `type-*.md` before drawing.** Its layout conventions win over anything the source did. + +## Step 4 — Build the semantic model + +Work from the digest, not from coordinates. In order: + +1. **Name the story.** One sentence: *"A request enters through the gateway, gets authenticated, and lands in Postgres."* Everything that doesn't serve that sentence is a degrade-ladder candidate. +2. **Apply the detail level.** Walk [`output-spec.md` §3](output-spec.md) degrade ladder until you're under the node ceiling. The digest's *collapsible groups* section is step 3 of that ladder, pre-computed. +3. **Pick 1–2 focal nodes.** The digest's `hubs` ranking (highest degree) is the usual answer, but the focal node is the one the *reader* should look at first — sometimes that's the entry point or the new component, not the busiest one. These get `accent`; everything else does not. +4. **Rewrite every label** at the audience level ([`output-spec.md` §4](output-spec.md)). draw.io labels are written by the author for the author: `svc-auth-prod-v2` becomes `Auth Service`. Preserve proper nouns, expand acronyms once. +5. **Prune edges.** Source graphs carry edges that layout already implies. If A sits above B in a stack and everything flows down, the arrow is noise. Keep edges that carry a label, cross a zone boundary, or run against the dominant direction. + +## Step 5 — Redraw + +Fresh layout on the 4px grid, per the type reference and SKILL.md §6–§7. Explicitly: + +- **Discard source coordinates.** draw.io positions are hand-dragged and land on odd pixels. Lay out from scratch: dominant flow left→right (or top→bottom), zones aligned, even gaps. +- **Discard source colors.** Map them to semantic roles instead: + +| draw.io default fill | Typical meaning | Maps to | +|---|---|---| +| `#dae8fc` / `#6c8ebf` (blue) | generic component | Backend/API — white fill, `ink` stroke | +| `#d5e8d4` / `#82b366` (green) | ok / primary path | `ink` treatment; accent **only** if focal | +| `#ffe6cc` / `#d79b00` (orange) | attention / queue | `ink` treatment; accent only if focal | +| `#f8cecc` / `#b85450` (red) | failure / risk / legacy | Optional/Async — dashed `ink @ 0.20` | +| `#e1d5e7` / `#9673a6` (purple) | external / third-party | External/Cloud — `ink @ 0.03` fill | +| `#f5f5f5` / grey | infrastructure / background | Store/State, or a zone container | +| no fill | unstyled | Backend/API | + + Source color is a *signal about role*, not a color to keep. Six fill colors in the source do not become six fills in the output — the palette is one accent plus the ink ramp (SKILL.md §5). + +- **Map shapes to treatments**, not to lookalikes: + +| Source shape | Draw as | +|---|---| +| `cylinder` | Store/State box (`ink @ 0.05` fill, `muted` stroke) — not a 3-D barrel | +| `rhombus` | Flowchart decision diamond, only in a flowchart; elsewhere a normal box | +| `actor` | Input/User treatment, or the user icon from [primitive-icons.md](primitive-icons.md) | +| `cloud` | External/Cloud treatment | +| `note` | Annotation callout ([primitive-annotation.md](primitive-annotation.md)), max 2 — or drop | +| `icon:aws` / `icon:azure` / `icon:gcp` / `icon:kubernetes` | The matching monochrome icon from [primitive-icons.md](primitive-icons.md), inheriting `currentColor` | +| `image` (custom PNG/vendor logo) | Nearest icon, or a labeled box. Never re-embed the source image. | +| `text` (floating label) | Drop, or fold into a zone label | + +- **Reroute every connector.** Source waypoints are dead weight — the digest reports a waypoint count so you know how tangled the original was, not so you can reproduce it. Rounded orthogonal elbows, fanned attach points, no overlaps: SKILL.md §6 rules 1–5, no exceptions for imported content. +- **Set the `viewBox` from the size preset**, then lay out inside it — don't draw first and crop after. + +## Step 6 — Deliver + +1. Write the `.html`. +2. Run the SKILL.md §9 taste gate **and** the [`output-spec.md` §6](output-spec.md) checklist. +3. Produce `svg` / `png` if the format dial asked for them — via [`export.md`](export.md), from the HTML. +4. Report the fidelity ledger ([`output-spec.md` §5](output-spec.md)). Every import gets one; the user knows the source and will notice what's gone. + +--- + +## Worked example + +[`assets/example-import-drawio.html`](../assets/example-import-drawio.html) is the output of this procedure run on `scripts/fixtures/sample-architecture.drawio` (12 nodes, 8 edges, 2 container groups) at `format=html`, `size=doc-inline`, `detail=balanced`, `audience=mixed`. + +What the run decided, and why: + +| Source | Output | Reason | +|---|---|---| +| `Edge` + `Core Services` swimlane containers | `EDGE` / `CORE SERVICES` zone frames | Containers became zones, not boxes — they group, they don't act | +| Postgres, Redis, Object Store scattered down the right | One `DATA` zone in a bottom row | Regrouping by role removed every connector crossing | +| `Token valid?` decision diamond | The `VERIFY` label on Gateway → Auth | A single decision inside an architecture diagram is an edge label | +| Sticky note "Legacy path, to be retired" | Dropped | Unconnected in the source; step 1 of the degrade ladder | +| `#dae8fc` / `#d5e8d4` / `#e1d5e7` fills | White services, ink-tint stores, one accent | Source color signals role; roles map to the design system | +| API Gateway (degree 4, the digest's top hub) | The one accent node | Highest-degree node was also the story's pivot | + +12 source nodes → 8 drawn, inside the standard §7 budget even at a level that allows 12. + +--- + +## Multi-page files + +Default is page 0. When the file has several pages: + +- **Ask which page** unless the user named one. List them from the digest header — names and node counts. +- `--page all` when they want everything: one HTML file per page, named `-.html`, each independently type-selected. Pages in one draw.io file are frequently different diagram types. +- Don't merge pages into one canvas unless asked. A 3-page file merged is a 40-node fail. + +## Edge cases + +| Situation | Do | +|---|---| +| Digest shows `0 nodes` | The source is an image-only export or encrypted (`` with no readable model). Tell the user; ask for the original `.drawio` or a description. Don't guess from a screenshot. | +| Extractor exits 2 | Report the message verbatim — it names the actual problem (not a draw.io file / malformed XML / no pages). Don't fall back to reading the raw file. | +| `edges_dangling > 0` | Edges whose endpoints were deleted in the source. Drop them silently — they're source rot, not content. | +| Unconnected nodes listed | Usually legends, titles, or abandoned boxes. Drop unless the label says otherwise; mention in the ledger if it looked meaningful. | +| Labels are empty across the board | The source carries meaning in shape and position only. Ask the user what the boxes are — don't invent names. | +| Source has 40+ nodes | Don't offer `faithful`. Propose overview + per-zone detail up front, before drawing anything. | +| Source is someone else's branded diagram | Redraw in the *project's* skin (`style-guide.md`), not the source's. Say so — it's a feature, not a bug. | +| CJK / non-Latin labels | Font fallback per [`output-spec.md` §4](output-spec.md). Don't romanize labels. | + +## Anti-patterns + +| Anti-pattern | Why it fails | +|---|---| +| Reproducing source coordinates | Imports draw.io's hand-dragged layout — off-grid, uneven gaps, the exact thing this skill exists to fix | +| Keeping the source palette | Six pastel fills read as six meanings; the design system has one accent | +| One-to-one node mapping regardless of budget | A 30-node canvas is a wiring diagram nobody reads | +| Keeping every edge because it was in the source | Source graphs carry edges layout already implies | +| Copying labels verbatim | `svc-auth-prod-v2` is a hostname, not a name a reader can use | +| Re-embedding vendor logos from the source | Breaks the self-contained rule and the monochrome icon system | +| Silently dropping components | The user knows the source. Always ship the fidelity ledger. | +| Inventing components to fill a layout | An import is bounded by its source. Gaps get asked about, not filled. | +| Preserving draw.io diagonal connectors | Orthogonal elbows are mandatory (SKILL.md §6 rule 1) regardless of origin | diff --git a/skills/diagram-design/references/output-spec.md b/skills/diagram-design/references/output-spec.md new file mode 100644 index 0000000..b27a6c4 --- /dev/null +++ b/skills/diagram-design/references/output-spec.md @@ -0,0 +1,179 @@ +# Output spec — format × size × detail level + +Three dials decide what a diagram becomes. Set all three **before** drawing — they change the layout, the type ramp, and the node count, so retrofitting them afterwards means redrawing. + +| Dial | Question it answers | Default | +|---|---|---| +| **Format** | Where does this file land? | `html` | +| **Size** | How big is the canvas, and how far away is the reader? | `doc-inline` | +| **Detail level** | Reproduce every element, or compress for the audience? | `balanced` + `mixed` | + +Ask the user for any dial they didn't specify — one question, three options each, not three separate rounds. If they decline to choose, use the defaults above and say which ones you used. + +--- + +## 1. Format + +| Format | Deliverable | Keeps | Drops | +|---|---|---|---| +| `html` | self-contained `.html` (default) | header, diagram, summary cards, footer, live fonts | nothing | +| `svg` | `.svg` next to the source | the `` node, vector text | editorial wrapper; fonts substitute in offline tools | +| `png` | `.png` at `device_scale_factor` | pixels exactly as the browser renders them | vector editability | +| `html+png` | both | — | — | + +Always generate the HTML first — `svg` and `png` are produced *from* it via [`export.md`](export.md). Never hand-author an SVG file directly; the HTML is the source of truth and the only artifact the taste gate (SKILL.md §9) is written against. + +Pick by destination: + +| Destination | Format | Size preset | +|---|---|---| +| Blog post, README, docs site | `html` (embed) or `png` | `doc-inline` | +| Keynote / PowerPoint / Google Slides | `png` @2 | `slide-16x9` | +| Figma / Illustrator / further editing | `svg` | `fit` | +| X / LinkedIn / OG link card | `png` @2 | `social-og` | +| Printed handout, PDF deck | `png` @3 | `print-a4-landscape` | +| Confluence / Notion / internal wiki | `png` @2 | `doc-wide` | + +--- + +## 2. Size + +The preset sets the SVG `viewBox`. Every value below is divisible by 4, so the grid rule in SKILL.md §7 still holds. + +| Preset | viewBox | Aspect | PNG @2 | Type ramp | Use | +|---|---|---|---|---|---| +| `doc-inline` (default) | `0 0 960 600` | 8:5 | 1920×1200 | standard | Body-width diagram in a post or README | +| `doc-wide` | `0 0 1280 720` | 16:9 | 2560×1440 | standard | Full-width docs, wiki pages | +| `slide-16x9` | `0 0 1280 720` | 16:9 | 2560×1440 | presentation | Deck slide, projected | +| `slide-4x3` | `0 0 1024 768` | 4:3 | 2048×1536 | presentation | Legacy deck templates | +| `social-og` | `0 0 1200 632` | ~1.9:1 | 2400×1264 | presentation | Link preview card | +| `social-square` | `0 0 1080 1080` | 1:1 | 2160×2160 | presentation | Feed post, carousel | +| `print-a4-landscape` | `0 0 1120 792` | ~1.41:1 | @3 → 3360×2376 | print | A4 landscape, ~10mm margins at 96dpi | +| `print-letter-landscape` | `0 0 1056 816` | ~1.29:1 | @3 → 3168×2448 | print | US Letter landscape | +| `fit` | derived from content | any | @2 | standard | Vector hand-off; no fixed frame | + +### Deriving `fit` + +Round the content bounding box **up** to the next multiple of 4, then add the fixed chrome: 40px outer margin on every side, plus 60px at the bottom for the legend strip. Never let the content touch the viewBox edge. + +### Type ramp per size class + +Node names shrink relative to the canvas as it grows — resist that. Scale the ramp with the preset so a projected slide stays readable from the back row. + +| Role | standard | presentation | print | +|---|---|---|---| +| Title (Instrument Serif) | 28 | 40 | 32 | +| Node name (Geist 600) | 12 | 16 | 12 | +| Sublabel (Geist Mono) | 9 | 12 | 9 | +| Arrow label (Geist Mono) | 8 | 12 | 8 | +| Eyebrow / tag (Geist Mono) | 8 | 8 | 8 | +| Node box min height | 48 | 64 | 48 | +| Min gap between nodes | 24 | 40 | 24 | + +Presentation ramp implies fewer nodes — 16px names in 64px boxes eat the canvas. If a `slide-16x9` layout won't fit, that's the size dial telling you the detail dial is set too high; drop a level rather than shrinking the type. + +### Safe areas + +- **All presets:** 40px outer margin; legend strip is the bottom 60px and nothing else lives there. +- **`social-og`:** keep the outer 64px clear on every side — link-card crops are unpredictable across platforms. +- **`slide-*`:** keep the bottom 80px clear if the deck template has a footer bar; ask if unsure. + +--- + +## 3. Detail level + +How much of the source survives. This is a *count* dial — it governs how many elements make it through, not how they're worded (that's §4). + +| Level | Nodes | Edges | Sublabels | What survives | +|---|---|---|---|---| +| `faithful` (詳細) | ≤24, zoned | ≤32 | every port, protocol, version | Every distinct component in the source. Only exact duplicates merge. | +| `balanced` (default) | ≤12 | ≤16 | technical sublabel on ≤4 nodes | Components that carry the story; leaf clusters collapse to one node each. | +| `simplified` (簡略) | ≤7 | ≤9 | none | Capabilities and their sequence. Infrastructure disappears. | + +`balanced` and `simplified` sit inside the standard complexity budget (SKILL.md §7). **`faithful` deliberately exceeds it** — that's the trade, and it comes with conditions: + +1. **Zoning is mandatory.** Above 9 nodes, every node belongs to a labeled zone (2–4 zones, hairline-bordered, `paper-2` fill, mono uppercase zone label at top-left). An unzoned 20-node diagram is a wiring diagram, not a schematic. +2. **Connector rules don't relax.** SKILL.md §6 rules 1–5 still apply at 24 nodes. If you can't route it without overlaps, you're over the real ceiling — split. +3. **Above 24 nodes, split.** Produce an overview (zones as nodes, `balanced` grammar) plus one detail diagram per zone. Name them `-overview.html`, `-.html`. Never ship a 40-node single canvas. +4. **Accent stays at 2.** More nodes never buys more focal elements. + +### Degrade ladder + +When the source has more than the level allows, cut in this order and stop as soon as you're under budget. Never cut ad hoc. + +1. **Decorative cells** — sticky notes, free-floating text, title blocks, watermarks, the source's own legend. (Notes worth keeping become annotation callouts — max 2, see [primitive-annotation.md](primitive-annotation.md).) +2. **Exact duplicates** — N identical workers/replicas/shards become one node labeled `Worker ×N`. +3. **Leaf clusters** — a container whose children are all leaves collapses to the container: `Core Services` replaces its three boxes. The extractor lists these under *collapsible groups*. +4. **Degree-1 sinks that don't change the story** — a monitoring hook, a log bucket, an archive tier. +5. **Cross-cutting infrastructure** — logging, metrics, secrets, CI. At `simplified` these go without asking; at `balanced` keep at most one, and only if the diagram is about it. +6. **Still over?** Split into overview + detail. Splitting beats shrinking. + +Anything cut in steps 2–6 goes in the fidelity ledger (§5). Step 1 doesn't need reporting. + +--- + +## 4. Audience level + +Independent of the detail dial: the same 12 nodes get named differently for a platform team than for a steering committee. Detail sets *how many*; audience sets *what they're called*. + +| Audience | Node names | Sublabels | Edge labels | Never | +|---|---|---|---|---| +| `engineer` | exact service / component names | protocol, port, version, image tag | `POST /v2/orders`, `SQL`, `gRPC` | Vague verbs like "connects to" | +| `mixed` (default) | component names, expanded acronyms | technology only where it changes a decision | plain verbs — `verifies`, `writes`, `notifies` | Ports, versions, internal codenames | +| `executive` | capabilities and outcomes | none | business verbs — `approves`, `pays out` | Vendor names, infrastructure, protocols | + +Worked example — the same node through all three: + +| Audience | Node name | Sublabel | +|---|---|---| +| `engineer` | `Auth Service` | `JWT · RS256 · :8443` | +| `mixed` | `Auth Service` | `token check` | +| `executive` | `Sign-in` | — | + +Two rules that hold at every audience level: + +- **Never invent detail to fill a slot.** If the source says `svc-04`, `executive` output says what it does only if you can tell from context — otherwise ask, don't guess a business name. +- **Keep the source's vocabulary for proper nouns.** Renaming `Kafka` to `Message Bus` is fine at `executive`; renaming it to `Event Grid` (a different product) is a factual error. + +### Non-Latin labels + +Geist has no CJK coverage. When labels contain Japanese, Chinese, or Korean text, extend the family on those `` elements — don't swap the whole skin: + +```svg +認証サービス +``` + +For mono sublabels use `'Geist Mono', 'Noto Sans Mono CJK JP', monospace`. CJK glyphs render ~10% wider than Latin at the same size — budget box width accordingly, and prefer 12px names over 8px sublabels for CJK, which goes muddy below 10px. + +--- + +## 5. Fidelity ledger + +Any time output is smaller than input — every `balanced` and `simplified` run, and most `faithful` ones — report what you cut, in chat, after the file path. Short and specific: + +``` +Detail: balanced · 18 source nodes → 9 drawn +Merged: worker-01..06 → "Ingest Worker ×6" +Collapsed: "Observability" group (Grafana, Loki, Tempo) → one node +Dropped: 2 sticky notes, CI pipeline (cross-cutting) +Kept in full: the request path (Client → Gateway → Orders → Postgres) +``` + +The reader of the diagram can't see what's missing. The person who asked for it needs to. + +--- + +## 6. Checklist + +Run alongside the SKILL.md §9 taste gate. + +- [ ] All three dials set — explicitly by the user, or defaulted and stated? +- [ ] `viewBox` matches the size preset exactly, values divisible by 4? +- [ ] Type ramp matches the size class — not the standard ramp on a slide? +- [ ] 40px outer margin honoured (64px for `social-og`)? +- [ ] Node count inside the detail level's ceiling? +- [ ] `faithful` above 9 nodes → zoned, and split above 24? +- [ ] Node names, sublabels, and edge labels all at the same audience level? +- [ ] CJK labels given a font fallback? +- [ ] Fidelity ledger reported for anything cut? +- [ ] Requested non-HTML formats produced via [`export.md`](export.md), not hand-authored? diff --git a/skills/diagram-design/scripts/drawio_extract.py b/skills/diagram-design/scripts/drawio_extract.py new file mode 100644 index 0000000..7a53645 --- /dev/null +++ b/skills/diagram-design/scripts/drawio_extract.py @@ -0,0 +1,789 @@ +#!/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 [--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" + + +def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type] + print(f"drawio_extract: {msg}", file=sys.stderr) + raise SystemExit(2) + + +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 = zlib.decompress(raw, wbits).decode("utf-8", "replace") + 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 = data[pos + 8 : pos + 8 + length] + pos += 12 + length + 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 + if ctype == b"tEXt": + value = rest + elif ctype == b"zTXt": + value = zlib.decompress(rest[1:]) + else: # iTXt: compression flag, method, lang, translated key, text + flag = rest[0:1] + tail = rest[2:].split(b"\x00", 2)[-1] + value = zlib.decompress(tail) if flag == b"\x01" else tail + return unquote(value.decode("utf-8", "replace")) + return None + + +def _svg_embedded_xml(text: str) -> str | None: + match = re.search(r'content\s*=\s*"((?:[^"\\]|\\.)*)"', text[:400000]) + if not match: + return None + return html.unescape(match.group(1)) + + +def load_mxfile(path: Path) -> str: + """Return the ```` (or bare ````) XML for any input.""" + 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 "||", 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 + 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 / 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) + 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 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) + + 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())