data/lobe-icons.json
{
"package": "@lobehub/icons-static-svg",
"version": "1.91.0",
"cdn": "https://unpkg.com/@lobehub/icons-static-svg@1.91.0/icons/",
"icons": [
"ace",
"ace-text",
"adobe",
"adobe-color",
"adobe-text",
"adobefirefly",
"adobefirefly-color",
"adobefirefly-text",
"agentvoice",
"agentvoice-color",
"agentvoice-text",
"agui",
"agui-text",
"ai2",
"ai2-color",
"ai2-text",
"ai21",
"ai21-brand",
"ai21-brand-color",
"ai21-text",
"ai302",
"ai302-color",
"ai302-text",
"ai360",
"ai360-color",
"ai360-text",
"aihubmix",
"aihubmix-color",
"aihubmix-text",
"aimass",
"aimass-color",
"aimass-text",
"aionlabs",
"aionlabs-color",
"aionlabs-text",
"airjelly",
"airjelly-color",
"airjelly-text",
"aistudio",
"aistudio-text",
"akashchat",
"akashchat-color",
"akashchat-text",
"alephalpha",
"alephalpha-text",
"alibaba",
"alibaba-brand",
"alibaba-brand-color",
"alibaba-color",
"alibaba-text",
"alibaba-text-cn",
"alibabacloud",
"alibabacloud-color",
"alibabacloud-text",
"alibabacloud-text-cn",
"amp",
"amp-color",
"amp-text",
"antgroup",
"antgroup-brand",
"antgroup-brand-color",
"antgroup-color",
"antgroup-text",
"antgroup-text-cn",
"anthropic",
"anthropic-text",
"antigravity",
"antigravity-color",
"antigravity-text",
"anyscale",
"anyscale-color",
"anyscale-text",
"apertis",
"apertis-color",
"apertis-text",
"apple",
"apple-text",
"arcee",
"arcee-color",
"arcee-text",
"askverdict",
"askverdict-color",
"askverdict-text",
"assemblyai",
"assemblyai-color",
"assemblyai-text",
"atlascloud",
"atlascloud-text",
"automatic",
"automatic-color",
"automatic-text",
"aws",
"aws-brand",
"aws-brand-color",
"aws-color",
"aws-text",
"aya",
"aya-color",
"aya-text",
"azure",
"azure-color",
"azure-text",
"azureai",
"azureai-color",
"azureai-text",
"baai",
"baai-text",
"baichuan",
"baichuan-color",
"baichuan-text",
"baidu",
"baidu-brand",
"baidu-brand-color",
"baidu-color",
"baidu-text",
"baidu-text-cn",
"baiducloud",
"baiducloud-color",
"baiducloud-text",
"bailian",
"bailian-color",
"bailian-text",
"baseten",
"baseten-text",
"bedrock",
"bedrock-color",
"bedrock-text",
"bfl",
"bfl-text",
"bilibili",
"bilibili-color",
"bilibili-text",
"bilibiliindex",
"bilibiliindex-text",
"bing",
"bing-color",
"bing-text",
"briaai",
"briaai-color",
"briaai-text",
"burncloud",
"burncloud-color",
"burncloud-text",
"bytedance",
"bytedance-brand",
"bytedance-brand-color",
"bytedance-color",
"bytedance-text",
"bytedance-text-cn",
"capcut",
"capcut-text",
"centml",
"centml-brand",
"centml-brand-color",
"centml-color",
"centml-text",
"cerebras",
"cerebras-brand",
"cerebras-brand-color",
"cerebras-color",
"cerebras-text",
"chatglm",
"chatglm-color",
"chatglm-text",
"cherrystudio",
"cherrystudio-color",
"cherrystudio-text",
"civitai",
"civitai-color",
"civitai-text",
"civitai-text-color",
"claude",
"claude-color",
"claude-text",
"claudecode",
"claudecode-color",
"claudecode-text",
"cline",
"cline-text",
"clipdrop",
"clipdrop-text",
"cloudflare",
"cloudflare-color",
"cloudflare-text",
"codebuddy",
"codebuddy-color",
"codebuddy-text",
"codeflicker",
"codeflicker-color",
"codeflicker-text",
"codegeex",
"codegeex-color",
"codegeex-text",
"codex",
"codex-color",
"codex-text",
"cogvideo",
"cogvideo-color",
"cogvideo-text",
"cogview",
"cogview-color",
"cogview-text",
"cohere",
"cohere-color",
"cohere-text",
"colab",
"colab-color",
"colab-text",
"cometapi",
"cometapi-color",
"cometapi-text",
"comfyui",
"comfyui-color",
"comfyui-text",
"commanda",
"commanda-color",
"commanda-text",
"copilot",
"copilot-color",
"copilot-text",
"copilotkit",
"copilotkit-color",
"copilotkit-text",
"coqui",
"coqui-color",
"coqui-text",
"coze",
"coze-text",
"crewai",
"crewai-brand",
"crewai-brand-color",
"crewai-color",
"crewai-text",
"crusoe",
"crusoe-color",
"crusoe-text",
"cursor",
"cursor-text",
"cybercut",
"cybercut-text",
"dalle",
"dalle-color",
"dalle-text",
"dbrx",
"dbrx-brand",
"dbrx-brand-color",
"dbrx-color",
"dbrx-text",
"deepai",
"deepai-text",
"deepcogito",
"deepcogito-color",
"deepcogito-text",
"deepinfra",
"deepinfra-color",
"deepinfra-text",
"deepl",
"deepl-color",
"deepl-text",
"deepmind",
"deepmind-color",
"deepmind-text",
"deepseek",
"deepseek-color",
"deepseek-text",
"devin",
"devin-color",
"devin-text",
"dify",
"dify-color",
"dify-text",
"doc2x",
"doc2x-color",
"doc2x-text",
"docsearch",
"docsearch-color",
"docsearch-text",
"dolphin",
"dolphin-text",
"doubao",
"doubao-color",
"doubao-text",
"dreammachine",
"dreammachine-text",
"elevenlabs",
"elevenlabs-text",
"elevenx",
"elevenx-text",
"essentialai",
"essentialai-color",
"essentialai-text",
"exa",
"exa-color",
"exa-text",
"fal",
"fal-color",
"fal-text",
"fastgpt",
"fastgpt-color",
"fastgpt-text",
"featherless",
"featherless-color",
"featherless-text",
"figma",
"figma-color",
"figma-text",
"fireworks",
"fireworks-color",
"fireworks-text",
"fishaudio",
"fishaudio-text",
"flora",
"flora-text",
"flowith",
"flowith-text",
"flux",
"flux-text",
"friendli",
"friendli-text",
"gemini",
"gemini-color",
"gemini-text",
"geminicli",
"geminicli-color",
"geminicli-text",
"gemma",
"gemma-color",
"gemma-text",
"giteeai",
"giteeai-text",
"github",
"github-text",
"githubcopilot",
"githubcopilot-text",
"glama",
"glama-text",
"glif",
"glif-text",
"glmv",
"glmv-color",
"glmv-text",
"google",
"google-brand",
"google-brand-color",
"google-color",
"googlecloud",
"googlecloud-brand",
"googlecloud-brand-color",
"googlecloud-color",
"goose",
"goose-text",
"gradio",
"gradio-color",
"gradio-text",
"greptile",
"greptile-color",
"greptile-text",
"grok",
"grok-text",
"groq",
"groq-text",
"hailuo",
"hailuo-color",
"hailuo-text",
"haiper",
"haiper-text",
"hedra",
"hedra-text",
"hermesagent",
"hermesagent-text",
"higress",
"higress-color",
"higress-text",
"huawei",
"huawei-color",
"huawei-text",
"huawei-text-cn",
"huaweicloud",
"huaweicloud-color",
"huaweicloud-text",
"huaweicloud-text-cn",
"huggingface",
"huggingface-color",
"huggingface-text",
"hunyuan",
"hunyuan-color",
"hunyuan-text",
"hyperbolic",
"hyperbolic-color",
"hyperbolic-text",
"ibm",
"ibm-text",
"ideogram",
"ideogram-text",
"iflytekcloud",
"iflytekcloud-color",
"iflytekcloud-text",
"inception",
"inception-text",
"inference",
"inference-text",
"infermatic",
"infermatic-color",
"infermatic-text",
"infinigence",
"infinigence-color",
"infinigence-text",
"infinigence-text-cn",
"inflection",
"inflection-text",
"internlm",
"internlm-color",
"internlm-text",
"jimeng",
"jimeng-color",
"jimeng-text",
"jina",
"jina-text",
"junie",
"junie-color",
"junie-text",
"kilocode",
"kilocode-text",
"kimi",
"kimi-color",
"kimi-text",
"kiro",
"kiro-color",
"kiro-text",
"kling",
"kling-color",
"kling-text",
"kluster",
"kluster-color",
"kluster-text",
"kolors",
"kolors-color",
"kolors-text",
"krea",
"krea-text",
"kwaikat",
"kwaikat-text",
"kwaikat-text-color",
"kwaipilot",
"kwaipilot-color",
"kwaipilot-text",
"lambda",
"lambda-text",
"langchain",
"langchain-color",
"langchain-text",
"langfuse",
"langfuse-color",
"langfuse-text",
"langgraph",
"langgraph-color",
"langgraph-text",
"langsmith",
"langsmith-color",
"langsmith-text",
"leptonai",
"leptonai-color",
"leptonai-text",
"lg",
"lg-color",
"lg-text",
"lightricks",
"lightricks-text",
"liquid",
"liquid-text",
"livekit",
"livekit-color",
"livekit-text",
"llamaindex",
"llamaindex-color",
"llamaindex-text",
"llava",
"llava-color",
"llava-text",
"llmapi",
"llmapi-color",
"llmapi-text",
"lmstudio",
"lmstudio-text",
"lobehub",
"lobehub-color",
"lobehub-text",
"longcat",
"longcat-color",
"longcat-text",
"lovable",
"lovable-color",
"lovable-text",
"lovart",
"lovart-text",
"luma",
"luma-color",
"luma-text",
"magic",
"magic-text",
"make",
"make-color",
"make-text",
"manus",
"manus-text",
"mastra",
"mastra-text",
"mcp",
"mcp-text",
"mcpso",
"mcpso-color",
"mcpso-text",
"menlo",
"menlo-color",
"menlo-text",
"meshy",
"meshy-color",
"meshy-text",
"meta",
"meta-brand",
"meta-brand-color",
"meta-color",
"meta-text",
"metaai",
"metaai-color",
"metaai-text",
"metagpt",
"metagpt-text",
"microsoft",
"microsoft-color",
"microsoft-text",
"midjourney",
"midjourney-text",
"minimax",
"minimax-color",
"minimax-text",
"mistral",
"mistral-color",
"mistral-text",
"modelscope",
"modelscope-color",
"modelscope-text",
"monica",
"monica-color",
"monica-text",
"moonshot",
"moonshot-text",
"morph",
"morph-color",
"morph-text",
"moxt",
"moxt-color",
"moxt-text",
"myshell",
"myshell-color",
"myshell-text",
"n8n",
"n8n-color",
"n8n-text",
"nanobanana",
"nanobanana-color",
"nanobanana-text",
"nebius",
"nebius-text",
"newapi",
"newapi-color",
"newapi-text",
"notebooklm",
"notebooklm-text",
"notion",
"notion-text",
"nousresearch",
"nousresearch-text",
"nova",
"nova-color",
"nova-text",
"novelai",
"novelai-text",
"novita",
"novita-color",
"novita-text",
"nplcloud",
"nplcloud-color",
"nplcloud-text",
"nvidia",
"nvidia-color",
"nvidia-text",
"obsidian",
"obsidian-color",
"obsidian-text",
"ollama",
"ollama-text",
"openai",
"openai-text",
"openchat",
"openchat-color",
"openchat-text",
"openclaw",
"openclaw-color",
"openclaw-text",
"opencode",
"opencode-text",
"openhands",
"openhands-color",
"openhands-text",
"openhuman",
"openhuman-text",
"openrouter",
"openrouter-text",
"openwebui",
"openwebui-text",
"palm",
"palm-color",
"palm-text",
"parasail",
"parasail-text",
"perplexity",
"perplexity-color",
"perplexity-text",
"phidata",
"phidata-color",
"phidata-text",
"phind",
"phind-text",
"pika",
"pika-text",
"pixverse",
"pixverse-color",
"pixverse-text",
"player2",
"player2-color",
"player2-text",
"poe",
"poe-color",
"poe-text",
"pollinations",
"pollinations-text",
"ppio",
"ppio-color",
"ppio-text",
"ppio-text-cn",
"prunaai",
"prunaai-color",
"prunaai-text",
"pydanticai",
"pydanticai-color",
"pydanticai-text",
"qingyan",
"qingyan-color",
"qingyan-text",
"qiniu",
"qiniu-color",
"qiniu-text",
"qoder",
"qoder-color",
"qoder-text",
"qwen",
"qwen-color",
"qwen-text",
"railway",
"railway-text",
"recraft",
"recraft-text",
"relace",
"relace-text",
"replicate",
"replicate-brand",
"replicate-text",
"replit",
"replit-color",
"replit-text",
"reve",
"reve-text",
"roocode",
"roocode-text",
"rsshub",
"rsshub-color",
"rsshub-text",
"runway",
"runway-text",
"rwkv",
"rwkv-color",
"rwkv-text",
"sambanova",
"sambanova-color",
"sambanova-text",
"search1api",
"search1api-color",
"search1api-text",
"searchapi",
"searchapi-text",
"sensenova",
"sensenova-brand",
"sensenova-brand-color",
"sensenova-color",
"sensenova-text",
"siliconcloud",
"siliconcloud-color",
"siliconcloud-text",
"sillytavern",
"sillytavern-color",
"sillytavern-text",
"skywork",
"skywork-color",
"skywork-text",
"slock",
"slock-text",
"smithery",
"smithery-color",
"smithery-text",
"snowflake",
"snowflake-color",
"snowflake-text",
"sophnet",
"sophnet-color",
"sophnet-text",
"sora",
"sora-color",
"sora-text",
"spark",
"spark-color",
"spark-text",
"speedai",
"speedai-color",
"speedai-text",
"stability",
"stability-brand",
"stability-brand-color",
"stability-color",
"stability-text",
"statecloud",
"statecloud-color",
"statecloud-text",
"stepfun",
"stepfun-color",
"stepfun-text",
"straico",
"straico-color",
"straico-text",
"streamlake",
"streamlake-color",
"streamlake-text",
"submodel",
"submodel-color",
"submodel-text",
"suno",
"suno-text",
"sync",
"sync-text",
"targon",
"targon-color",
"targon-text",
"tavily",
"tavily-color",
"tavily-text",
"tencent",
"tencent-brand",
"tencent-brand-color",
"tencent-color",
"tencent-text",
"tencent-text-cn",
"tencentcloud",
"tencentcloud-color",
"tencentcloud-text",
"tiangong",
"tiangong-color",
"tiangong-text",
"tii",
"tii-color",
"tii-text",
"together",
"together-brand",
"together-brand-color",
"together-color",
"together-text",
"topazlabs",
"topazlabs-text",
"trae",
"trae-color",
"trae-text",
"tripo",
"tripo-color",
"tripo-text",
"turix",
"turix-text",
"udio",
"udio-color",
"udio-text",
"unstructured",
"unstructured-color",
"unstructured-text",
"upstage",
"upstage-color",
"upstage-text",
"v0",
"vectorizerai",
"vectorizerai-text",
"venice",
"venice-color",
"venice-text",
"vercel",
"vercel-text",
"vertexai",
"vertexai-color",
"vertexai-text",
"vidu",
"vidu-color",
"vidu-text",
"viggle",
"viggle-text",
"vllm",
"vllm-color",
"vllm-text",
"volcengine",
"volcengine-color",
"volcengine-text",
"voyage",
"voyage-color",
"voyage-text",
"wenxin",
"wenxin-color",
"wenxin-text",
"windsurf",
"windsurf-text",
"workersai",
"workersai-color",
"workersai-text",
"worldrouter",
"worldrouter-text",
"xai",
"xai-text",
"xiaomimimo",
"xiaomimimo-text",
"xinference",
"xinference-color",
"xinference-text",
"xpay",
"xpay-color",
"xpay-text",
"xuanyuan",
"xuanyuan-color",
"xuanyuan-text",
"yandex",
"yandex-text",
"yi",
"yi-color",
"yi-text",
"youmind",
"youmind-text",
"yuanbao",
"yuanbao-color",
"yuanbao-text",
"zai",
"zai-text",
"zapier",
"zapier-color",
"zapier-text",
"zeabur",
"zeabur-color",
"zeabur-text",
"zencoder",
"zencoder-color",
"zencoder-text",
"zenmux",
"zenmux-text",
"zeroone",
"zeroone-color",
"zeroone-text",
"zhipu",
"zhipu-color",
"zhipu-text"
]
}data/SHAPE-INDEX-NOTICE.md
# Shape index attribution
`shape-index.json.gz` is a gzipped copy of the shape search index from
[jgraph/drawio-mcp](https://github.com/jgraph/drawio-mcp) (`shape-search/search-index.json`),
which is generated from the official draw.io / diagrams.net client shape
libraries. Both upstream sources are licensed under the **Apache License 2.0**.
- Each entry is `{style, w, h, title, tags, type}` for one palette shape.
- 10,446 shapes spanning AWS, Azure, GCP, Cisco, Kubernetes, UML, BPMN, P&ID,
electrical, flowchart, network, and the general shape sets.
- Used read-only by `scripts/shapesearch.py` to resolve exact official style
strings instead of hand-guessing them.
To refresh against a newer draw.io release, regenerate upstream with
`shape-search/generate-index.js` in the drawio-mcp repo, then re-gzip:
gzip -9 -c search-index.json > data/shape-index.json.gz
references/autolayout.md
# Auto-layout (Graphviz)
Read this when a diagram is **large or layout-heavy** — dependency/call graphs, code/module structure, or roughly **more than ~15 nodes** — where hand-placing `x`/`y` coordinates is slow, error-prone, and overlap-prone.
Instead of computing coordinates by hand in the Generate step, describe the graph as JSON and let `scripts/autolayout.py` place the nodes and route the edges with Graphviz, then continue the normal workflow (Export draft → Self-check → …) on the produced `.drawio`.
For small or carefully-styled diagrams, keep hand-placing — auto-layout trades fine control for scale.
## Dependency
Requires Graphviz `dot` on PATH:
```bash
# macOS
brew install graphviz
# Debian/Ubuntu
sudo apt install graphviz
```
The script exits with a clear message if `dot` is missing — fall back to hand-placed coordinates in that case.
## Usage
```bash
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o diagram.drawio
```
It prints `wrote diagram.drawio (N nodes, M edges)` to stderr and writes a normal `.drawio` file. From there, continue at the **Export draft** step of the main workflow (preview PNG with `--width 2000`, self-check, review loop, final export with `-e` + `repair_png.py`).
## Input format
```json
{
"direction": "TB",
"nodes": [
{"id": "client", "label": "Web Client", "style": "rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;"},
{"id": "gw", "label": "API Gateway", "group": "edge", "groupLabel": "Edge tier"},
{"id": "db", "label": "User DB", "style": "shape=cylinder3;whiteSpace=wrap;html=1;", "width": 120, "height": 80, "group": "data"}
],
"edges": [
{"source": "client", "target": "gw", "label": "HTTPS"},
{"source": "gw", "target": "db"}
]
}
```
**Fields**
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `direction` | no | `TB` | `TB` (top→bottom) or `LR` (left→right) — the layout rank direction |
| `nodes[].id` | **yes** | — | Unique; must not be `0` or `1` (reserved for draw.io root cells) |
| `nodes[].label` | no | the `id` | Display text; auto XML-escaped |
| `nodes[].style` | no | group colour, else blue | Any draw.io style string — reuse the role/shape styles from `diagram-types.md` and the active preset. A styleless node is tinted by its group (see **Containers / grouping**); an explicit style always wins |
| `nodes[].width` / `height` | no | `120` / `60` | Pixels; dot lays out at this real size |
| `nodes[].group` | no | none | Group key, or a `/`-delimited path (`"core/db"`) for **nested** containers — nodes sharing a path are boxed together (see **Containers / grouping**) |
| `nodes[].groupLabel` | no | last path segment | Title shown on the node's deepest container (first node with the path wins) |
| `edges[].source` / `target` | **yes** | — | Must match node ids |
| `edges[].label` | no | empty | Edge text |
## How it places things
- Node positions come from `dot` (hierarchical layered layout), converted to draw.io pixels and snapped to the grid (multiples of 10).
- Edges use `splines=ortho`: dot's orthogonal route is replayed as draw.io waypoints, so edges go **around** nodes instead of through them.
- Apply the active style preset by setting each node's `style` to the preset's role/shape values before calling the script — the script does not know about presets.
## Containers / grouping
Give nodes a `group` key and the script wraps each group in a labeled container (a dashed box with the group title at top) and tells dot to keep that group's nodes together via a Graphviz cluster. Grouped nodes become children of their container (`parent="<container>"`, relative coordinates); ungrouped nodes stay at the top level. This turns a flat hairball into a "boxes of related modules" architecture view.
**Nesting.** A `group` value with `/` separators builds nested containers: `"core/db"` puts the node inside a `db` box that itself sits inside a `core` box. Every path prefix becomes a container, so an arbitrarily deep package tree maps to nested boxes. A node can also sit *directly* in a parent box (`group: "core"`) alongside a sibling sub-box (`group: "core/db"`).
- **Colour by group.** Each top-level group is assigned a colour from the skill's own palette (`styles/built-in/default.json`, cycled in role order: blue → green → orange → purple → yellow → red → grey). A node with no `style` of its own is tinted with its group's colour, and the container's border + title match — so related modules read as a coloured cluster instead of monochrome boxes. A node that carries its own `style` (e.g. from an applied preset) is left untouched. Pass `--mono` to turn colouring off (dashed grey boxes, default-blue nodes — the previous look). Ungrouped graphs are unaffected.
- Each container box is the bounding box of its members and child boxes plus a uniform padding. The dot cluster margin is set to that same padding, so each box equals dot's cluster box — which dot keeps non-overlapping at **any nesting depth**.
- The title sits in the top padding (`verticalAlign=top`); the box title is the path's last segment, or a member's `groupLabel`.
- Containers are visual only (no edges of their own). Edges still connect node→node and route across containers normally.
- If a container's top padding would cross the page origin, the whole diagram is shifted so nothing lands at a negative coordinate.
## Validate before previewing
`scripts/validate.py` is a deterministic structural linter — run it on the produced `.drawio` before the (slower, vision-based) self-check:
```bash
python3 <this-skill-dir>/scripts/validate.py diagram.drawio
```
It catches dangling edge endpoints, duplicate/reserved ids, broken parent references (errors), plus off-grid/negative geometry and overlapping sibling nodes (warnings) — without launching draw.io. Exit status is non-zero on any error (or any warning with `--strict`), so it can gate the workflow. Auto-layout output should always pass clean; a failure means a malformed input graph (e.g. an edge referencing a missing node id).
## Importers — visualize code & infrastructure
Bundled importers turn a codebase or an IaC configuration into a graph JSON ready for autolayout, so "visualize this project" is a two-step pipeline:
| Source | Script | Node = | Edge = |
| --- | --- | --- | --- |
| Python | `scripts/pyimports.py <dir>` | module / package (`ast`) | intra-project `import` / `from` |
| JS / TS | `scripts/jsimports.py <dir>` | source file (`.ts/.tsx/.js/.jsx/.mjs/.cjs`) | resolved relative `import`/`export from`/`require()`/`import()` |
| Go | `scripts/goimports.py <dir>` | package (directory, via `go.mod`) | intra-module package import |
| Rust | `scripts/rustimports.py <dir>` | module (`.rs` file / `mod`) | intra-crate `use crate::` / `super::` / `self::` |
| Python (classes) | `scripts/pyclasses.py <dir>` | class (`ast`) | subclass → base (inheritance) |
| Terraform | `scripts/tfimports.py <dir>` | `resource` / `module` block, rendered as its **official AWS/Azure/GCP icon** | cross-resource reference (`aws_iam_role.x.arn`, `${...}`, `depends_on`) |
| Kubernetes | `scripts/k8simports.py <dir>` | manifest object (kind/name), rendered as its **official K8s kind icon** | Ingress→Service, Service→workload (selector), workload→ConfigMap/Secret/PVC, HPA→target |
| docker-compose | `scripts/composeimports.py <file-or-dir>` | service (name + image box) / named volume (cylinder) | `depends_on` / `links` / `volumes_from` / named-volume mounts |
| Terraform state (**live**) | `terraform show -json \| scripts/tfstate.py -` | **deployed** resource instance, rendered as its **official cloud icon** | recorded dependency (`depends_on` in state) |
| Docker (**live**) | `docker inspect $(docker ps -q) \| scripts/dockerimports.py -` | running container (name + image) / user network (ellipse) / named volume (cylinder) | container→network, container→volume, `links` / compose `depends_on` |
| SQL DDL | `scripts/sqlerd.py <file-or-dir>` | table (column list with PK/FK markers) | foreign key (crow's-foot, labeled with the FK column) |
```bash
python3 <this-skill-dir>/scripts/pyimports.py myproject -o graph.json
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o diagram.drawio
```
Each code importer keeps only **intra-project** edges (third-party/stdlib imports are ignored), shortens node labels (drops the shared package/module/directory prefix; ids stay fully qualified), and shares the same flags: `--direction TB|LR` (default `TB`), `--group`, `--no-reduce`. The IaC importers share `--direction` and `--group` and add `--no-icons`.
- **Python** (`pyimports.py`): if the directory is itself a package (`__init__.py` present), module names are package-qualified so the project's own absolute imports resolve; nested subpackages (`pkg.sub.mod`) are handled.
- **JS/TS** (`jsimports.py`): resolution is path-based (tries the source extensions and directory `index` files); `node_modules` and bare specifiers are skipped. Scanning is regex-based, not a full parser.
- **Go** (`goimports.py`): reads the `module` path from `go.mod`; each directory of `.go` files is one package; `*_test.go` and `vendor/` are skipped.
- **Rust** (`rustimports.py`): each `.rs` file is a module (`mod.rs`/`main.rs`/`lib.rs` name the enclosing module); edges come from `use` paths rooted at `crate::`/`super::`/`self::` (brace groups expanded). `std`/external crates and `target/` are skipped. Regex-based — inline `mod { … }` blocks aren't split out, and 2015-edition bare intra-crate paths aren't resolved.
- **Python classes** (`pyclasses.py`): a finer granularity — one node per class, edges from each subclass to the project base classes it extends, so the result is an auto-generated class hierarchy. Bases are matched by name (preferring the same module); external bases (`object`, third-party) are ignored. With `--group`, classes are boxed by their module, so a deep package tree nests naturally. Inheritance only — function-level call graphs are out of scope (static call resolution in Python is unreliable).
- **Terraform** (`tfimports.py`): parses `.tf` files directly (regex + brace matching, no HCL library). Each resource type is resolved to its official icon through the bundled shape index — AWS `aws4` set, Azure `azure2` set, GCP icon set — with a curated query table for the ~45 most common types and strict tag-AND matching so a partial match never lands on the wrong vendor's icon; unresolvable types fall back to a plain box labeled `name` + type (`--no-icons` forces boxes for all). `--group` boxes resources by service (`aws_s3_* → s3`). Data sources, variables, locals and providers are ignored; heredocs with unbalanced braces are the known parse limit.
- **Kubernetes** (`k8simports.py`): accepts one or more manifest files or a directory. JSON (including `kind: List`, i.e. `kubectl get ... -o json` output) parses with the stdlib alone; `.yaml`/`.yml` needs PyYAML. Kind icons come from the official `mxgraph.kubernetes` set (25 kinds mapped). Edges land only on objects present in the manifest set, matched within the same namespace. `--group` boxes objects by namespace. No `--no-reduce` flag — reference edges are sparse and never reduced.
- **docker-compose** (`composeimports.py`): needs PyYAML. Services become rounded boxes labeled `name` + image (or `build:` context); named volumes declared in the top-level `volumes:` section become cylinders. `--group` boxes services by their first network.
- **Terraform state — live** (`tfstate.py`): reads the JSON that `terraform show -json` prints (live state, or a saved plan) from a file or `-` (stdin). Provider-agnostic; `count`/`for_each` instances are expanded (labeled `name[0]`, `name[1]`, …) and module nesting is preserved. Reuses tfimports' icon resolver, so the same official AWS/Azure/GCP icons and `--no-icons` fallback apply. Edges come from the dependencies Terraform recorded in state (`depends_on`); data sources are skipped. `--group` boxes resources by module; shares `--direction` / `--no-reduce`. This is the **actually-deployed** counterpart to tfimports' declared-config view.
- **Docker — live** (`dockerimports.py`): reads the JSON array `docker inspect` prints (file or `-`). Containers become rounded boxes (name + image) matching the compose look; the user networks they attach to become green ellipses and the named volumes they mount become cylinders (Docker's built-in `bridge`/`host`/`none`/`ingress` networks and bind mounts are ignored as noise). Edges: container→network, container→volume, plus container→container from `links` and the compose `depends_on` label. `--group` boxes containers by compose project (falling back to first network). The **actually-running** counterpart to composeimports' declared view.
- **SQL DDL** (`sqlerd.py`): regex + paren matching, no SQL library. Handles inline and table-level `PRIMARY KEY`/`FOREIGN KEY ... REFERENCES`, quoted identifiers, `schema.table` prefixes (`--group` boxes by schema). Column lines carry `PK`/`FK` markers and types (`--no-types` to hide). Unknown dialect clauses are skipped, never mis-parsed into edges.
## Diffing two diagrams (`drawiodiff.py`)
`drawiodiff.py old.drawio new.drawio -o diff.json` compares two `.drawio` files and emits a colour-coded graph JSON for autolayout — one diagram showing **what changed**: nodes/edges added (green), removed (red, dashed), changed (orange, a matched node whose label moved) or unchanged (grey).
```bash
python3 <this-skill-dir>/scripts/drawiodiff.py old.drawio new.drawio -o diff.json
python3 <this-skill-dir>/scripts/autolayout.py diff.json -o diff.drawio
```
Nodes match by cell **id** by default — ideal for anything the importers or live-infra snapshots produce (their ids are stable semantic keys), so *snapshot → change → snapshot → diff* shows drift directly (e.g. two `tfstate.py` or `k8simports.py` snapshots). Pass `--by-label` to match on the visible label instead, for hand-drawn diagrams whose ids are random. Only leaf vertices and their edges are compared (containers/group cells and edge labels are skipped); the diff is a flat colour-coded view, so original icons are replaced by status colours (labels are kept). Multi-page files are flattened; compressed pages are skipped with a warning (this skill always writes uncompressed XML).
## Architecture time-lapse over git history (`timelapse.py`)
`timelapse.py <dir> --importer pyimports` shows how a codebase's structure grew: it walks the git history of `<dir>`, re-runs the importer at each sampled commit (pulling the tree with `git archive` — the working copy is never touched), lays each out and exports a PNG frame, then assembles **one self-contained HTML player** (frames embedded as base64, play / step / scrub controls, no external files or CDNs).
```bash
python3 <this-skill-dir>/scripts/timelapse.py src --importer pyimports --max-frames 12
# -> architecture-evolution.html (open in any browser)
```
`--importer` is any bundled graph extractor (`pyimports`/`jsimports`/`goimports`/`rustimports`/`pyclasses`/`tfimports`/`k8simports`/`composeimports`/`sqlerd`), run with the same positional `<dir>` it expects, so **point `<dir>` at the module / project / infra root** — extra flags pass through via `--importer-args "--group"`. Commits touching the dir are sampled evenly down to `--max-frames` (always keeping the first and last); a commit where the importer finds nothing (the path did not exist yet) is skipped. It renders one draw.io frame per commit, so it needs git + Graphviz + the draw.io CLI and takes a few seconds per frame. The story is strongest on a package with real **import edges** (they accumulate over time); a flat directory still shows the node count grow.
The tf/k8s importers emit `ranksep`/`nodesep` in the graph JSON automatically (icon labels render *below* the shape, so rows need extra separation).
**`--tune` (autolayout flag)**: lays the graph out in both directions (TB and LR), scores each (through-vertex routes ×20 + edge crossings ×10 + total edge length as tiebreak), and keeps the better one — report on stderr. `validate.py --score` prints the same style of readability score for a finished `.drawio`, for comparing variants.
**Density reduction is on by default** — this is the key to a readable result. Real import graphs are dense (asyncio: 33 modules / ~149 edges); without reduction they render as a hairball. Every importer applies **transitive reduction** (Graphviz `tred` — drops edges already implied by a longer path), which on asyncio cuts ~149 edges to ~46 and turns the hairball into a clean, traceable diagram. Pass `--no-reduce` to keep every edge.
**`--group`** assigns each node a container by its sub-package / directory path, so autolayout boxes related modules together — nested when the path has depth (see **Containers / grouping**). The fastest way to turn a large code graph into a tiered architecture view.
For any other language, produce the same graph JSON from any analyzer (e.g. `dependency-cruiser` for richer JS/TS resolution, `go-callvis` for Go call graphs) and feed it to autolayout the same way.
## Edge routing after auto-layout
Dot already routes edges orthogonally as part of its layout pass (`splines=ortho`), so the result usually needs no further routing.
**There is no CLI flag that reroutes edges without moving nodes.** `--layout` only accepts ELK *node* layout presets (`verticalFlow`, `horizontalFlow`, `verticalTree`, `horizontalTree`, `radialTree`, `organic`) or a JSON layout array — every one of them re-places vertices. Passing an unrecognised value (e.g. `libavoid`) makes draw.io open a modal `Unknown layout:` error dialog, which **hangs a headless run until it is killed**.
When the output still has edges cutting across shapes, fix it at authoring time instead:
```bash
python3 edgeports.py diagram.drawio # spread stacked edges over each perimeter
```
`edgeports.py` handles the common case — several edges leaving the same side of a node all landing on the same point. It is a port assigner, not a router: for an edge crossing an unrelated shape mid-run, add `<Array as="points">` waypoints or increase node spacing (see `xml-authoring.md` "Edge style rules"). draw.io's obstacle-avoiding connector router is editor-side only: open the `.drawio` in draw.io desktop and re-route there.
## Limitations
- **Placement is topological, not semantic** — dot minimises edge crossings, which may put a node in a different column than you'd choose by hand. Re-export with the other `direction`, or hand-tune the produced XML afterwards (it's a normal `.drawio`).
- **Import edges are static** — `pyimports`/`jsimports`/`goimports` read static import statements (not dynamic `importlib`, runtime `require`, or reflection); `pyclasses` resolves inheritance only, not method-level calls.
- **Parallel edges** between the same `(source, target)` pair share one route.
- **Containers don't add edges** — `group`/nesting only boxes nodes for layout; edges remain node→node. For hand-built swimlane/architecture containers with their own connections, see `references/xml-authoring.md` "Containers and groups".
references/derasterize.md
# De-rasterizing an image into an editable diagram
Goal: turn a whiteboard photo, a legacy PNG export, or a Visio screenshot
into an EDITABLE `.drawio` file. Claude's own vision does the extraction;
`scripts/raster2drawio.py` only turns the extracted JSON into XML.
## Workflow
1. **Look at the image.** Read it with your normal vision — no OCR tool
needed. For every box/shape in the picture, note:
- `id` — any short stable slug (`n1`, `api-gw`, …)
- `label` — the text verbatim, exactly as written (fix obvious typos only
if the source is clearly a typo, not a stylistic choice)
- `x`, `y` — estimate the pixel position of the shape's top-left corner
from the image (a rough grid read is fine; exact pixel-matching isn't
the goal)
- `w`, `h` — estimate width/height in pixels (defaults to 120x60 if you
can't tell)
- `shape` — classify: `rect`, `rounded`, `ellipse`, `rhombus`/`diamond`,
`cylinder`, `parallelogram`, `cloud`, `hexagon`
- `fill`, `stroke` — sample the approximate fill/border color as a hex
value (skip if the source is plain black-and-white)
For every arrow/line, note `source`, `target`, the label text if any
(e.g. "Yes"/"No" on a decision arrow), whether it's `dashed`, and whether
it has a visible `arrow` head (false for a plain connecting line).
2. **Write the JSON** to a file:
```json
{"nodes": [{"id": "n1", "label": "API Gateway", "x": 120, "y": 60,
"w": 160, "h": 60, "shape": "rect",
"fill": "#dae8fc", "stroke": "#6c8ebf"}],
"edges": [{"source": "n1", "target": "n2", "label": "HTTPS",
"dashed": false, "arrow": true}]}
```
3. **Convert it**:
```bash
python3 scripts/raster2drawio.py graph.json -o out.drawio
```
If any node is missing `x`/`y`, don't guess coordinates by hand — leave
them out and the script auto-places the whole graph via `autolayout.py`
(Graphviz `dot` required), noting this on stderr.
4. **Continue the standard workflow**: `validate.py` for structural issues,
export a preview PNG, then a vision self-check — compare the rendered
PNG side-by-side against the ORIGINAL image and look for missed shapes,
wrong labels, or misrouted edges. Fix and re-run as needed.
## Honest limitation
Hand sketches with ambiguous, crossing, or arrowhead-less connections are
genuinely ambiguous — expect one review round where you correct a
misattributed edge after seeing the first render next to the original.
references/diagram-types.md
# Diagram Type Presets
When the user requests a specific diagram type, apply the matching preset below for shapes, styles, and layout conventions. These presets set **structural** style keywords (e.g. ERD's `shape=table;childLayout=tableLayout`); a user style preset (see `references/style-presets.md`) layers color/font/edge/extras on top.
Read this file when:
- The user names one of these diagram types (ERD, UML class, sequence, C4, architecture, ML/DL model, flowchart, SysML, BPMN, network topology, cross-functional/swimlane)
- You're choosing shape vocabulary or layout direction for a new diagram
## ERD (Entity-Relationship Diagram)
**From SQL DDL, don't hand-build**: `python3 scripts/sqlerd.py schema.sql -o graph.json` parses `CREATE TABLE` into per-table nodes (PK/FK-marked column lists) + crow's-foot FK edges for autolayout. Hand-build with the styles below when there's no DDL to parse.
| Element | Style | Notes |
|---------|-------|-------|
| Table | `shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;strokeColor=#6c8ebf;fillColor=#dae8fc;` | Each table is a container |
| Row (column) | `shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=12;` | Child of table, `parent=tableId` |
| PK column | Bold text: `fontStyle=1` on the row | Mark with `PK` prefix or key icon |
| FK relationship | Dashed edge: `dashed=1;endArrow=ERmandOne;startArrow=ERmandOne;` | Use ER notation arrows |
| Layout | TB, tables spaced 300px apart | Group related tables vertically |
## UML Class Diagram
| Element | Style | Notes |
|---------|-------|-------|
| Class box | `swimlane;fontStyle=1;align=center;startSize=26;html=1;` | 3-section: title / attributes / methods |
| Separator | `line;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=10;rotatable=0;labelPosition=left;points=[];portConstraint=eastwest;` | Between sections |
| Inheritance | `endArrow=block;endFill=0;` | Hollow triangle arrow |
| Implementation | `endArrow=block;endFill=0;dashed=1;` | Dashed + hollow triangle |
| Composition | `endArrow=diamondThin;endFill=1;` | Filled diamond |
| Aggregation | `endArrow=diamondThin;endFill=0;` | Hollow diamond |
| Layout | TB, classes 250px apart | Interfaces above implementations |
## Sequence Diagram
**Don't hand-place sequence geometry** — `python3 scripts/seqlayout.py seq.json -o out.drawio` computes all lifeline/activation-bar/arrow coordinates deterministically from a participants + messages JSON (schema in the script docstring), using exactly the styles below. Hand-edit the output only for fragments (alt/loop frames), which are out of its scope.
| Element | Style | Notes |
|---------|-------|-------|
| Actor/Object | `shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=1;collapsible=0;recursiveResize=0;outlineConnect=0;portConstraint=eastwest;` | Lifeline with dashed vertical line |
| Sync message | `html=1;verticalAlign=bottom;endArrow=block;` | Solid line, filled arrowhead |
| Async message | `html=1;verticalAlign=bottom;endArrow=open;dashed=1;` | Dashed line, open arrowhead |
| Return message | `html=1;verticalAlign=bottom;endArrow=open;dashed=1;strokeColor=#999999;` | Grey dashed |
| Activation box | `shape=umlFrame;whiteSpace=wrap;` on the lifeline | Narrow rectangle on lifeline |
| Layout | LR, lifelines spaced 200px apart | Time flows top to bottom |
## C4 Model (System Context / Container / Component)
**Don't hand-build** — `python3 scripts/c4.py c4.json -o out.drawio` generates the whole multi-page set (one page per level, drill-down links from parent elements to child pages, Graphviz placement; schema in the script docstring). The styles below are what it emits — for hand-tweaks afterwards:
| Element | Style | Notes |
|---------|-------|-------|
| Person | `shape=mxgraph.c4.person2;html=1;whiteSpace=wrap;fontColor=#ffffff;fillColor=#083F75;strokeColor=#06315C;` | Dark-blue person shape, 200×180 |
| Software System | `rounded=1;arcSize=10;html=1;whiteSpace=wrap;fontColor=#ffffff;fillColor=#1061B0;strokeColor=#0D5091;` | 240×120 |
| External System | same, `fillColor=#8C8496;strokeColor=#736782;` | Grey = outside your control |
| Container | same, `fillColor=#23A2D9;strokeColor=#0E7DAD;` | Mid-blue |
| Component | same, `fillColor=#63BEF2;strokeColor=#2086C9;` | Light-blue |
| Database | `shape=cylinder3;size=15;boundedLbl=1;` + Container colors | Cylinder |
| Relationship | `endArrow=blockThin;endFill=1;html=1;fontSize=11;fontColor=#404040;strokeColor=#828282;labelBackgroundColor=#ffffff;` | Grey thin arrow, label = protocol/action |
| Label format | `Name` ⏎ `[Type: Tech]` ⏎ `description` | The standard three-line C4 label |
| Drill-down | wrap the element in `<UserObject link="data:page/id,<pageId>">` | Click jumps to the child page in draw.io / viewer |
| Layout | TB, one `<diagram>` page per level | Export a single page with `--page-index <n>` (1-based) |
## Architecture Diagram
| Element | Style | Notes |
|---------|-------|-------|
| Layer/tier | `swimlane;startSize=30;` | Containers for grouping: Client / API / Service / Data |
| Service | `rounded=1;whiteSpace=wrap;html=1;` + tier color | Use color palette by tier |
| Database | `shape=cylinder3;whiteSpace=wrap;html=1;` | Green palette |
| Queue/Bus | `rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;` | Yellow — place centrally for hub pattern |
| Gateway/LB | `shape=mxgraph.aws4.resourceIcon;` or `rounded=1;` with orange | Orange palette |
| External | `rounded=1;dashed=1;fillColor=#f5f5f5;strokeColor=#666666;` | Dashed border for external systems |
| Layout | TB or LR by tier count; ≥4 tiers → TB | Hub nodes centered |
## ML / Deep Learning Model Diagram
For neural network architecture diagrams — ideal for papers targeting NeurIPS, ICML, ICLR.
| Element | Style | Notes |
|---------|-------|-------|
| Layer block | `rounded=1;whiteSpace=wrap;html=1;` + type color | Main building block |
| Input/Output | `fillColor=#d5e8d4;strokeColor=#82b366;` | Green |
| Conv / Pooling | `fillColor=#dae8fc;strokeColor=#6c8ebf;` | Blue |
| Attention / Transformer | `fillColor=#e1d5e7;strokeColor=#9673a6;` | Purple |
| RNN / LSTM / GRU | `fillColor=#fff2cc;strokeColor=#d6b656;` | Yellow |
| FC / Linear | `fillColor=#ffe6cc;strokeColor=#d79b00;` | Orange |
| Loss / Activation | `fillColor=#f8cecc;strokeColor=#b85450;` | Red/Pink |
| Skip connection | `dashed=1;endArrow=block;curved=1;` | Dashed curved arrow |
| Tensor shape label | Add shape annotation as secondary label: `value="Conv2D
(B, 64, 32, 32)"` | Use `
` for multi-line |
| Layout | TB (data flows top→bottom), layers 150px apart | Group encoder/decoder as swimlanes |
**Tensor shape convention:** annotate each layer with input/output tensor dimensions in `(B, C, H, W)` or `(B, T, D)` format. Place dimensions as the second line of the label using `
`.
## SysML (Block Definition / Internal Block / Requirement / Parametric)
draw.io ships a native SysML 1.x shape library (`mxgraph.sysml.*`, ~60 shapes) — run `python3 scripts/shapesearch.py "sysml <keyword>"` for any element not listed below. Stereotype labels use guillemets as the first label line: `«block»` (HTML entities for « »). SysML behavioral diagrams (activity, state machine, use case, sequence) reuse the UML presets above; search `shapesearch.py "sysml activity"` / `"sysml state"` for the SysML-specific variants.
### Block Definition Diagram (bdd)
| Element | Style | Notes |
|---------|-------|-------|
| Block | `swimlane;fontStyle=1;align=center;startSize=40;html=1;` | Label `«block»
Name`; compartments (values / parts / operations) like UML class |
| Compartment separator | same `line;...` style as UML Class | Between compartments |
| Composite association | `html=1;endArrow=none;startArrow=diamondThin;startFill=1;` | Filled diamond at the whole (owner) end |
| Reference association | `html=1;endArrow=none;startArrow=diamondThin;startFill=0;` | Hollow diamond |
| Generalization | `edgeStyle=none;html=1;endArrow=block;endFill=0;endSize=12;` | Hollow triangle pointing at the parent block |
| Multiplicity | edge labels `1`, `0..1`, `1..*` near the ends | Offset with edge label geometry |
| Layout | TB, whole block on top, part blocks below, 250px apart | Same convention as UML class |
### Internal Block Diagram (ibd)
| Element | Style | Notes |
|---------|-------|-------|
| Frame | `rounded=0;html=1;verticalAlign=top;align=left;spacingLeft=10;fontStyle=1;container=1;` | Label `ibd [block] Name`; parts are children |
| Part | `rounded=0;whiteSpace=wrap;html=1;` | Label `partName : BlockType` |
| Port | `html=1;shape=mxgraph.sysml.port;sysMLPortType=flowN;` 20×20 | Child of the part, relative geometry pinned to its border |
| Connector | `html=1;endArrow=none;` | Solid line port-to-port |
| Item flow direction | `shape=triangle;fillColor=strokeColor;` 10×10 on the connector + item label | Triangle points along the flow |
### Requirement Diagram (req)
| Element | Style | Notes |
|---------|-------|-------|
| Requirement | `swimlane;fontStyle=1;align=center;startSize=26;html=1;` | Title `«requirement»
Name`; body compartment `id="R1.1"
text="..."` |
| Containment | `edgeStyle=none;html=1;startArrow=sysMLPackCont;startSize=12;endArrow=none;` | Crosshair-circle at the parent end |
| deriveReqt / satisfy / verify / refine | `html=1;endArrow=open;endSize=12;dashed=1;` + edge label `«satisfy»` | Dashed open arrow pointing at the requirement |
| Trace | same dashed style, label `«trace»` | |
| Layout | TB, parent requirements above children, 200px apart | Satisfy/verify sources (blocks, test cases) at the bottom |
### Parametric Diagram (par)
| Element | Style | Notes |
|---------|-------|-------|
| Constraint block | `rounded=1;whiteSpace=wrap;html=1;` | Label `«constraint»
{F = m · a}` |
| Parameter port | `rounded=0;html=1;fontSize=10;` 20×20 on the border | Label = parameter name (`m`, `a`, `F`) |
| Binding connector | `html=1;endArrow=none;` | Solid, no arrows |
| Value property | `rounded=0;whiteSpace=wrap;html=1;` | Label `name : Type` |
| Layout | LR, value properties on the outside, constraints centered | |
## BPMN (Business Process)
draw.io ships ~200 native BPMN 2.0 shapes (`mxgraph.bpmn.*`). The official styles carry a long `points=[...]` connection-point list — run `python3 scripts/shapesearch.py "bpmn <element>"` for the full string; the styles below omit it for brevity and still render correctly.
| Element | Style | Notes |
|---------|-------|-------|
| Pool | `swimlane;html=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;` | Container; label rotated in the left header band |
| Lane | `swimlane;html=1;startSize=30;horizontal=0;collapsible=0;fillColor=none;` | Child of the pool, `parent=poolId`, one per role |
| Task | `shape=mxgraph.bpmn.task2;whiteSpace=wrap;rectStyle=rounded;size=10;html=1;container=1;expand=0;collapsible=0;taskMarker=abstract;` | `taskMarker=user\|service\|script\|manual\|send\|receive\|businessRule` for typed tasks |
| Start event | `shape=mxgraph.bpmn.event;html=1;perimeter=ellipsePerimeter;aspect=fixed;outline=standard;symbol=general;verticalLabelPosition=bottom;verticalAlign=top;align=center;labelBackgroundColor=#ffffff;` 50×50 | `symbol=message\|timer` for message/timer start |
| Intermediate event | same, `outline=throwing` (send) or `outline=catching` (receive) | Double circle |
| End event | same, `outline=end;symbol=general` | Thick circle; `symbol=terminate` for terminate end |
| Gateway | `shape=mxgraph.bpmn.gateway2;html=1;perimeter=rhombusPerimeter;outline=none;symbol=none;gwType=exclusive;verticalLabelPosition=bottom;verticalAlign=top;align=center;labelBackgroundColor=#ffffff;` 50×50 | `gwType=exclusive\|parallel\|inclusive\|complex` |
| Sequence flow | `edgeStyle=elbowEdgeStyle;html=1;endArrow=blockThin;endFill=1;` | Solid, filled thin arrow |
| Conditional flow | same + `startArrow=diamondThin;startFill=0;startSize=10;endSize=6;` | Hollow diamond at source |
| Default flow | same + `startArrow=dash;startFill=0;startSize=6;endSize=6;` | Tick at source |
| Message flow | `dashed=1;dashPattern=8 4;endArrow=blockThin;endFill=1;startArrow=oval;startFill=0;startSize=4;endSize=6;html=1;` | Dashed, only **between** pools |
| Data object | `shape=mxgraph.bpmn.data2;size=15;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;` 40×60 | Dashed dotted-arrow association |
| Annotation | `html=1;shape=mxgraph.flowchart.annotation_2;align=left;labelPosition=right;` | Open bracket + dashed line |
| Layout | LR inside lanes, events/gateways vertically centered on the flow line | Sequence flows never cross pool borders; message flows never stay inside one |
## Network Topology
Generic vocabulary is the `mxgraph.networks` library — one shared style prefix, per-element `shape=`. For **vendor-specific** icons (Cisco `mxgraph.cisco19`/`cisco_safe`, rack `mxgraph.rack`, cloud vendors), run `python3 scripts/shapesearch.py "<vendor> <device>"` instead.
Shared prefix (every node below): `fontColor=#0066CC;verticalAlign=top;verticalLabelPosition=bottom;labelPosition=center;align=center;html=1;outlineConnect=0;fillColor=#CCCCCC;strokeColor=#6881B3;gradientColor=none;gradientDirection=north;strokeWidth=2;`
| Element | Append to prefix | Size |
|---------|------------------|------|
| Router | `shape=mxgraph.networks.router;` | 100×30 |
| Switch | `shape=mxgraph.networks.switch;` | 100×30 |
| Firewall | `shape=mxgraph.networks.firewall;` | 100×100 |
| Load balancer | `shape=mxgraph.networks.load_balancer;` | 100×30 |
| Server | `shape=mxgraph.networks.server;` | 90×100 |
| Storage / NAS | `shape=mxgraph.networks.storage;` / `...nas_filer;` | 100×100 / 100×35 |
| PC / Laptop | `shape=mxgraph.networks.pc;` / `...laptop;` | 100×70 / 100×55 |
| Wireless AP | `shape=mxgraph.networks.wireless_hub;` | 100×100 |
| Internet / WAN | `shape=mxgraph.networks.cloud;fontColor=#ffffff;` | 90×50 |
| Zone (subnet/VLAN/DMZ) | `rounded=1;dashed=1;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontStyle=1;container=1;` | Container; label = CIDR / zone name |
| Physical link | `html=1;endArrow=none;strokeWidth=2;` | Plain line; label = interface/VLAN |
| Logical/VPN link | `html=1;endArrow=none;dashed=1;` | Dashed |
| Layout | TB by tier: Internet → edge (router/firewall) → distribution (switch/LB) → servers/clients | Group each subnet in a zone container; label links with CIDR/port |
## Cross-Functional Flowchart (Swimlane)
A flowchart split by **who does what** — one lane per role/department. Node vocabulary reuses the Flowchart preset below; only the container skeleton differs.
| Element | Style | Notes |
|---------|-------|-------|
| Pool (process) | `swimlane;html=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;` | Outer container, label = process name |
| Lane (role) | `swimlane;html=1;startSize=30;horizontal=0;collapsible=0;fillColor=none;` | Child of pool, one per role/team/system |
| Steps | Flowchart preset styles (Start/End, Process, Decision, I/O) | Each step's `parent` = its lane id; coordinates relative to the lane |
| Handoff edge | `edgeStyle=orthogonalEdgeStyle;html=1;rounded=1;` | Edges crossing lanes are the handoffs — the diagram's point |
| Layout | LR flow inside horizontal lanes; time flows left → right | Keep each step inside its actor's lane; ≥160px horizontal spacing |
## Flowchart (enhanced)
| Element | Style | Notes |
|---------|-------|-------|
| Start/End | `ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;` | Green oval |
| Process | `rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;` | Blue rectangle |
| Decision | `rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;` | Yellow diamond |
| I/O | `shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;` | Orange parallelogram |
| Subprocess | `rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;` + double border | Purple |
| Yes/No labels | `value="Yes"` / `value="No"` on decision edges | Always label decision branches |
| Layout | TB, 200px vertical gap | Decisions branch LR, merge back to center |
references/live-infra.md
# Live infrastructure — draw what's actually running
The IaC importers (`tfimports.py`, `k8simports.py`, `composeimports.py`) draw the
**declared** config. These three recipes draw the **real** state — the resources
actually deployed, the containers actually running, the cluster as it is now.
Every path ends the same way: an importer emits autolayout graph JSON, then
`autolayout.py` renders an editable `.drawio` with the same official icons.
Read this when the user says things like *"draw my running cluster / stack"*,
*"diagram what's actually deployed"*, *"visualize my live AWS/Azure/GCP infra"*,
or *"map the containers I have up right now"*.
> The commands below read local tool state (`terraform`, `docker`, `kubectl`)
> the user already has authenticated. The skill never reaches out to a cloud
> account itself — it only parses the JSON those tools print. If a tool or its
> auth is missing, say so and fall back to the declared-config importer.
## 1. Deployed cloud resources — Terraform state
Provider-agnostic (AWS / Azure / GCP alike). Run from the Terraform working dir:
```bash
terraform show -json | python3 <this-skill-dir>/scripts/tfstate.py - -o graph.json
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o deployed.drawio
```
- Works on live state (above) or a saved plan: `terraform show -json plan.tfplan | …`.
- `count` / `for_each` resources appear as their real instances (`name[0]`, `name[1]`).
- `--group` boxes resources by module; `--no-icons` for plain boxes; `--direction LR`.
- Edges are the dependencies Terraform recorded in state (`depends_on`). A state
with no recorded dependencies still produces a useful **inventory** (every
deployed resource with its official icon) — mention that if edges come out sparse.
- Contrast with `tfimports.py` (the `.tf` files) when the user wants declared-vs-actual.
## 2. Running containers — Docker
```bash
docker inspect $(docker ps -q) | python3 <this-skill-dir>/scripts/dockerimports.py - -o graph.json
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o running.drawio
```
- Containers → rounded boxes (name + image); user networks → green ellipses;
named volumes → cylinders. Built-in `bridge`/`host`/`none`/`ingress` networks
and bind mounts are dropped as noise.
- Edges: container→network, container→volume, and container→container from
`links` and the compose `depends_on` label.
- `--group` boxes containers by their compose project (else first network).
- `docker ps -q` lists only running containers; add `-a` to include stopped ones.
## 3. Live cluster — Kubernetes (via k8simports)
No new script — `k8simports.py` already ingests `kubectl get ... -o json`. Ask for
the resource kinds that make the architecture readable (workloads + what wires them):
```bash
kubectl get deploy,sts,ds,svc,ing,cm,secret,pvc,hpa -n <ns> -o json \
| python3 <this-skill-dir>/scripts/k8simports.py - -o graph.json
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o cluster.drawio
```
- `kubectl get all` alone omits Ingress / ConfigMap / Secret / PVC — list the
kinds explicitly (as above) so the reference edges have endpoints to land on.
- `--group` boxes objects by namespace; add `-A` to `kubectl` for all namespaces.
- Edges are derived within a namespace: Ingress→Service, Service→workload
(selector match), workload→ConfigMap/Secret/PVC, HPA→target.
## After any of them
Continue at the main workflow's **Export draft** step: preview PNG (`--width 2000`),
self-check, review, final export with `-e` + `repair_png.py`. A live snapshot is a
point in time — re-run the pipeline to refresh it.
references/mermaid-authoring.md
# Mermaid authoring → native .drawio
Read this when the diagram is a **standard type with no custom styling needs** and the draw.io CLI is **version ≥ 30** — writing Mermaid text and letting the CLI convert it is faster and safer than hand-placing XML: you only get the *structure* right, layout comes free.
```bash
# .mmd in → laid-out, editable, native .drawio out (draw.io desktop ≥ 30)
drawio -x -f xml -o diagram.drawio diagram.mmd
# then continue the normal workflow (validate → preview PNG → self-check → …)
```
**Version gate (critical):** on draw.io ≤ 29 the `.mmd` input fails with `Export failed`, and the `--layout` flag corrupts argument parsing entirely (like the `-w` pitfall). Resolve the CLI version in workflow step 1 (`drawio --version`); if it prints < 30, skip both this path and `--layout`, and author XML instead (optionally suggest `brew upgrade --cask drawio`).
**Never export a `.mmd` straight to an image:** direct Mermaid → PNG with `-e` crashes current draw.io desktop builds (electron `UnhandledPromiseRejection`, then hang — verified on 30.2.6). Always convert to `.drawio` first (`-f xml`), then run the normal export on the `.drawio` — the two-step path embeds the diagram XML reliably.
## When to prefer which authoring mode
| Author as | Best for | Why |
| --- | --- | --- |
| **Mermaid → CLI convert** | flowchart, state, gantt, timeline, journey, pie, quadrant, sankey, gitGraph, **mindmap**, kanban, requirement, block, xychart, radar, wardley, C4 sketches | structure-only input, free layout, 28 types |
| **XML (this skill's core path)** | anything needing **official vendor icons** (shapesearch/aiicons), **style presets**, swimlanes, precise positions, edge waypoint control, multi-page/drill-down | Mermaid can't express draw.io styles/shapes |
| **Bundled generators** | code/IaC/SQL imports, sequence (seqlayout), C4 with drill-down (c4.py) | deterministic, data-driven |
Routing note: this converts Mermaid **into a `.drawio` deliverable**. If the user wants Mermaid text that lives in git / renders in Markdown, route to the **mermaid** skill instead (see "When to use / when NOT to use").
## Mermaid quirks that matter for draw.io's parser
Condensed from the upstream reference (jgraph/drawio-mcp `shared/mermaid-reference.md`, Apache-2.0):
- The **first non-directive line's keyword selects the type** — a misspelled header yields a blank diagram. Common: `flowchart TD`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`, `erDiagram`, `gantt`, `mindmap`, `timeline`, `journey`, `pie`, `gitGraph`, `quadrantChart`, `sankey-beta`, `kanban`, `c4Context`.
- **Node IDs are identifiers** (`A`, `node_1`) — no spaces, no trailing punctuation, avoid reserved words (`end`, `class`, `subgraph`). Display text goes in brackets/quotes: `A["User's Account"]`.
- **One statement per line**; quote labels containing `:`, `-`, parentheses, or non-ASCII (use `"`, not `'`).
- Only `<br>`, `<b>`, `<i>`, `<u>` are reliable HTML in labels; hex colors only (`#fff`, never `rgb()`).
- Styling: `style A fill:#f9f,stroke:#333`, reusable `classDef x fill:#dfd` + `A:::x`, edge `linkStyle 0 stroke:#f00`.
- **Never apply `--layout` to a Mermaid-converted file** — it is already laid out.
- Match label language to the user's language.
After converting, treat the `.drawio` as the artifact (delete the `.mmd`) and continue at the **validate → export draft** steps as usual. The converted file uses `UserObject`-wrapped cells — `validate.py` handles those.
## ELK `--layout` pass (XML-authored diagrams, CLI ≥ 30)
For XML you authored with rough (or all-zero) positions, the CLI can run the editor's ELK layouts — an alternative to `autolayout.py` when Graphviz is unavailable, and the better choice for **organic/radial** shapes (networks, mind-map-like graphs) that `dot` lays out poorly:
```bash
# in-place re-layout (reading and overwriting the same path is supported)
drawio -x -f xml --layout verticalFlow -o diagram.drawio diagram.drawio
# or layout + export in one call
drawio -x -f png -e -b 10 --layout verticalFlow -o diagram.drawio.png diagram.drawio
```
| Preset | Layout |
| --- | --- |
| `verticalFlow` / `horizontalFlow` | layered — flowcharts, pipelines |
| `verticalTree` / `horizontalTree` / `radialTree` | trees — hierarchies, org charts |
| `organic` | force-directed — networks, mind maps |
Finer control: pass a JSON array instead of a preset — `--layout '[{"layout":"elkLayered","config":{"elk.direction":"RIGHT","elk.spacing.nodeNode":40}}]'` (algorithms: `elkLayered`, `elkTree`, `elkRadial`, `elkOrganic`, `elkStress`, `elkBox`).
Choosing between layout engines: `autolayout.py` (Graphviz) understands this skill's graph-JSON pipeline (importers, groups→clusters, `--tune`, palette tinting) — prefer it when that pipeline is in play. Use `--layout` when Graphviz is missing, when re-laying-out an existing `.drawio`, or for organic/radial topologies.
references/pr-bot.md
# PR Diagram Bot — reviewing `.drawio` changes as pictures
Goal: in CI, for every `.drawio` file a pull request touches, render the base
version, the head version, and a colour-coded diff, then post them as a
sticky PR comment (and the job summary) so reviewers see pictures instead of
raw XML diffs.
Read this when you're setting up (or troubleshooting) automated PR diagram
review for a repo.
## Pieces
- `scripts/prdiff.py` — the script. `changed_drawios()` finds what changed
(`git diff --name-status`); for each file it exports base/head PNGs via the
draw.io CLI and, for modified files, chains `drawiodiff.py` ->
`autolayout.py` -> CLI export into a third diff PNG; `render_markdown()`
turns all of that into one Markdown report.
- `.github/actions/drawio-diff/action.yml` — a composite action that checks
out full history, installs draw.io + Graphviz, runs `prdiff.py`, uploads
the PNGs + report as a build artifact, writes the report to
`$GITHUB_STEP_SUMMARY`, and posts/updates a sticky PR comment.
- `.github/workflows/drawio-pr-diff.example.yml` — a template workflow that
wires the action to `pull_request` events. It ships as `.example.yml` and
gated with `if: false` so it does **nothing** until you copy and adapt it
in your own repo (see the comment at the top of that file).
## Adopting it in your own repo
1. Copy `.github/actions/drawio-diff/` and `skills/drawio-skill/` (or at
least `scripts/prdiff.py`, `scripts/drawiodiff.py`, `scripts/autolayout.py`)
into your repo.
2. Copy `.github/workflows/drawio-pr-diff.example.yml` to
`.github/workflows/drawio-pr-diff.yml`, drop the `if: false` guard, and
uncomment the `pull_request: paths: ["**/*.drawio"]` trigger.
3. Give the job `permissions: pull-requests: write` (already set in the
example) — the sticky comment step needs it; `contents: read` covers the
checkout.
4. Push a PR that touches a `.drawio` file and watch it run.
## Runner tooling
The draw.io desktop CLI is Electron-based, so headless CI needs the same
setup documented in `docs/CI.md` "Option A" of this repo: Graphviz (`dot`,
for `autolayout.py`) and a virtual display (`xvfb-run`). The composite action
installs both by default (latest `drawio-desktop` `.deb` + `apt-get
graphviz xvfb`); pass `skip-tool-install: true` if your runner/container
already provides `drawio` and `dot` on PATH.
If the draw.io CLI is missing (or fails), `prdiff.py` does **not** hard-fail
the run — the Markdown report still lists every changed file and its status,
just without images, with a note that image export was skipped. `git`
missing (or `--repo` not being a git repository) IS fatal, since without git
there is nothing to diff.
## How the sticky comment works
The action tags its comment body with an HTML marker
(`<!-- drawio-pr-diff-bot -->`) and, before posting, searches the PR's
existing comments (via `gh api .../issues/<n>/comments`) for one starting
with that marker. If found, it `PATCH`es that comment in place; otherwise it
creates a new one with `gh pr comment`. This keeps one running comment per PR
instead of a new comment on every push.
## Running it locally
```
python3 skills/drawio-skill/scripts/prdiff.py --base origin/main --head HEAD \
--out-dir drawio-pr -o drawio-pr/report.md
```
`--base`/`--head` are any git refs or SHAs (`--head` defaults to `HEAD`);
`--repo` points at a different working tree (default: current directory).
Open `drawio-pr/report.md` to preview exactly what the PR comment will say.
references/shapes.md
# Shape vocabulary & search
Read this when a diagram needs a **specific shape** — a cloud-provider icon
(AWS/Azure/GCP), a network/Cisco/Kubernetes symbol, a UML/BPMN/ER element, an
electrical or P&ID part — or any time you'd otherwise *guess* a `style=` string.
There are two ways to get a style:
1. **Search the official shape index** (`scripts/shapesearch.py`) — 10,446 real
draw.io palette shapes with their exact `style`, `w`, `h`. Use this for
branded/vendor icons and anything non-trivial. **Always prefer a searched
style over a hand-written `shape=mxgraph.*` guess** — guessed stencil names
silently render as a blank box if the name is wrong.
2. **The cheatsheet below** — the common built-in shapes whose style strings are
short and stable enough to write by hand (rectangles, flowchart symbols,
UML primitives, containers, edges).
## Searching shapes
```bash
python3 <this-skill-dir>/scripts/shapesearch.py "aws lambda" --limit 5
python3 <this-skill-dir>/scripts/shapesearch.py "uml actor" --json
```
- Query is space-separated keywords; matching is tag-based with Soundex
fuzziness and `camelCase`/`digit` splitting (`"pid2valve"` → `pid valve`).
- Prints each match as `Title (WxH)` followed by its full `style=` string. With
`--json`, emits `[{style,w,h,title}]` for programmatic use.
- Copy the `style` verbatim into an `mxCell`, and use the reported `w`/`h` as the
`mxGeometry` width/height (vendor icons are drawn at a fixed aspect ratio).
- Results are ranked by tag relevance, with shapes whose **title** contains the
query terms bubbled to the top of each score tier. Ranking is still a
heuristic, though, and many shapes share a title (three `Lambda` variants:
`aws3`/`aws4`/`aws3d`) — so run with `--limit 5` and pick the row whose title
and size match what you actually want rather than blindly taking #1.
```xml
<mxCell id="2" value="Lambda" style="<paste the searched style here>" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="78" height="78" as="geometry"/>
</mxCell>
```
Covered libraries: AWS (`aws3`/`aws4`), Azure, GCP, Cisco, Kubernetes, UML,
BPMN, ER, electrical, P&ID, mockup/wireframe, flowchart, network, and the
general/basic sets. The bundled index (`data/shape-index.json.gz`) is the
upstream draw.io shape data — see `data/SHAPE-INDEX-NOTICE.md` for attribution.
## AI / LLM brand logos
draw.io's bundled libraries have **no** modern AI/LLM brand logos, so an "LLM app
architecture" otherwise renders as generic boxes. `scripts/aiicons.py` resolves a
brand name (OpenAI, Claude, Gemini, Mistral, Llama, HuggingFace, Ollama,
LangChain, …321 brands) to a draw.io `image` style backed by the
[lobe-icons](https://github.com/lobehub/lobe-icons) set (MIT).
```bash
python3 <this-skill-dir>/scripts/aiicons.py "claude" --json # CDN reference
python3 <this-skill-dir>/scripts/aiicons.py "openai" --embed # self-contained
python3 <this-skill-dir>/scripts/aiicons.py --list # all brands
```
- Picks the `-color` variant when it exists, else the mono logo (e.g. OpenAI is
mono-only). Returns a square `image` style; use the reported `--size` (default
48) for both width and height.
- **Default references the icon by CDN URL** — the SVG lives on unpkg, not in
this repo, so **draw.io needs network access when the diagram is rendered or
opened**; an offline export draws a blank box. Pass `--embed` to fetch the SVG
once and inline it as a data URI (portable, renders offline, larger XML).
- Logos are trademarks of their respective owners, referenced for identification
only — the same basis on which draw.io ships AWS/Azure icons.
- **Data stores** common in RAG/LLM apps that lobe lacks (Qdrant, Redis,
Postgres, Mongo, Elasticsearch, Milvus, Supabase, Neo4j, ClickHouse, Kafka,
Snowflake, Databricks, …) resolve via the [simple-icons](https://simpleicons.org)
CDN (CC0) as an automatic fallback — same command, same output shape. A brand
in neither set has no logo; use a cylinder (`shape=cylinder3;`, see below) or
`scripts/shapesearch.py "<name> database"`.
## Cheatsheet — hand-writable styles
These are stable enough to write without searching. Combine with `whiteSpace=wrap;html=1;`.
### Common shapes (`shape=` keyword)
| Need | style |
|---|---|
| Rectangle / rounded box | `rounded=0;` / `rounded=1;` |
| Circle / ellipse | `ellipse;` (`aspect=fixed;` for a true circle) |
| Diamond (decision) | `rhombus;` |
| Cylinder (database) | `shape=cylinder3;` |
| Cloud | `cloud;` |
| Cube (3D) | `shape=cube;` |
| Sticky note | `shape=note;` |
| Document (curled bottom) | `shape=document;` |
| Folder | `shape=folder;` |
| Card (cut corner) | `shape=card;` |
| Process (double border) | `shape=process;` |
| Step / chevron | `shape=step;` |
| Parallelogram (I/O) | `shape=parallelogram;perimeter=parallelogramPerimeter;` |
| Trapezoid | `shape=trapezoid;perimeter=trapezoidPerimeter;` |
| Hexagon | `shape=hexagon;perimeter=hexagonPerimeter2;` |
| Manual input | `shape=manualInput;` |
| Data storage | `shape=dataStorage;` |
| Off-page connector | `shape=offPageConnector;` |
| Delay | `shape=delay;` |
| OR / XOR gate | `shape=or;` / `shape=xor;` |
| Block arrow | `shape=singleArrow;` / `shape=doubleArrow;` |
| Callout (speech bubble) | `shape=callout;` |
### UML primitives
| Element | style |
|---|---|
| Actor (stick figure) | `shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;` |
| Boundary | `shape=umlBoundary;` |
| Control | `shape=umlControl;` |
| Entity | `shape=umlEntity;` |
| Lifeline | `shape=umlLifeline;perimeter=lifelinePerimeter;container=1;` |
| Frame | `shape=umlFrame;` |
| Provided interface (lollipop) | `shape=lollipop;direction=south;` |
| Required interface | `shape=requires;direction=north;` |
| Component | `shape=component;` |
### Containers (parent-child; children use relative coords)
| Type | style | When |
|---|---|---|
| Invisible group | `group;pointerEvents=0;` | No border, no own connections |
| Titled swimlane | `swimlane;startSize=30;` | Visible title bar / has connections |
| Any shape as container | append `container=1;pointerEvents=0;` | Box without own connections |
### Edges
| Need | add to style |
|---|---|
| Orthogonal routing | `edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;` |
| Curved | `curved=1;` |
| No arrowhead | `endArrow=none;` |
| Open/thin arrow | `endArrow=open;` / `endArrow=classicThin;` |
| Dashed | `dashed=1;` (pattern via `dashPattern=8 8;`) |
| Flow animation | `flowAnimation=1;` |
| Label background | `labelBackgroundColor=#ffffff;` |
### Useful property knobs
- `fontStyle` is a bitmask: `1`=bold, `2`=italic, `4`=underline (add to combine: `3`=bold+italic).
- `direction=north|south|east|west` rotates a shape in 90° steps; `rotation=<deg>` for free rotation.
- `gradientColor=#RRGGBB;` + `gradientDirection=north;` for a gradient fill.
- `sketch=1;` gives a hand-drawn look (set globally via a style preset instead when possible).
For richer per-shape detail, the upstream source is jgraph/drawio-mcp's
`shared/style-reference.md` (Apache-2.0).
references/style-extraction.md
# Style Extraction — agent reference
Loaded on demand by `SKILL.md` when the user asks to learn a style ("learn my style from `<path>` as `<name>`") or when the agent needs to render a sample after extraction.
## Sample diagram (for approval render)
After extracting a candidate preset, render this seven-node sample using the candidate's palette/shapes/fonts/edges. Each role appears exactly once; six edges, one dashed, exercise `edges.arrow`, `edges.style`, and `edges.dashedFor`.
**Layout (TB):**
- Row 1 (y=40): `gateway` centered at x=340
- Row 2 (y=180): `security` (x=80), `service` (x=340), `queue` (x=600)
- Row 3 (y=340): `database` (x=80), `external` (x=340), `error` (x=600)
**Template — substitute `{{...}}` placeholders from the candidate preset.**
The vertex style for role `R` is built as:
`<shapes[R]>;whiteSpace=wrap;html=1;fillColor=<palette[roles[R]].fillColor>;strokeColor=<palette[roles[R]].strokeColor>;fontFamily=<font.fontFamily>;fontSize=<font.fontSize>`
- If `extras.sketch=true`, append `;sketch=1` to every vertex style AND every edge style.
- If `extras.globalStrokeWidth !== 1` (i.e., any value other than the drawio default of 1, including `0.5`), append `;strokeWidth=<n>` to every vertex style AND every edge style.
The edge style is built as:
`<edges.style>;<edges.arrow>`
- Per-edge routing keys (`exitX/entryX/...`) are added as literals below.
- Edge 15 exercises `edges.dashedFor`:
- If `edges.dashedFor` is **non-empty**, use its first entry as the edge's `value` (label) AND append `;dashed=1` to the edge style.
- If `edges.dashedFor` is empty (`[]`), use the label `cross-call` and do NOT append `;dashed=1` — the preset has no dashed convention, so the sample must not fake one.
**Placeholder expansion (applied when filling the XML):**
- `{{VSTYLE:<role>}}` expands to the vertex-style formula above with `R = <role>`. Write the result as a literal string; do not URL-encode.
- `{{ESTYLE}}` expands to the edge-style formula above.
- `{{EDGE15_LABEL}}` and `{{EDGE15_DASH}}` follow the Edge-15 rule above.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="drawio" version="26.0.0">
<diagram name="Preset Sample">
<mxGraphModel>
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Row 1: gateway -->
<mxCell id="2" value="Gateway" style="{{VSTYLE:gateway}}" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="160" height="60" as="geometry" />
</mxCell>
<!-- Row 2: security | service | queue -->
<mxCell id="3" value="Auth" style="{{VSTYLE:security}}" vertex="1" parent="1">
<mxGeometry x="80" y="180" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="4" value="Service" style="{{VSTYLE:service}}" vertex="1" parent="1">
<mxGeometry x="340" y="180" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="5" value="Queue" style="{{VSTYLE:queue}}" vertex="1" parent="1">
<mxGeometry x="600" y="180" width="160" height="60" as="geometry" />
</mxCell>
<!-- Row 3: database | external | error -->
<mxCell id="6" value="Database" style="{{VSTYLE:database}}" vertex="1" parent="1">
<mxGeometry x="80" y="340" width="160" height="70" as="geometry" />
</mxCell>
<mxCell id="7" value="External API" style="{{VSTYLE:external}}" vertex="1" parent="1">
<mxGeometry x="340" y="340" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="8" value="Error Sink" style="{{VSTYLE:error}}" vertex="1" parent="1">
<mxGeometry x="600" y="340" width="160" height="60" as="geometry" />
</mxCell>
<!-- Edges -->
<mxCell id="10" value="" style="{{ESTYLE}};exitX=0.25;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="11" value="" style="{{ESTYLE}};exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="12" value="" style="{{ESTYLE}};exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0" edge="1" parent="1" source="2" target="5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="13" value="" style="{{ESTYLE}};exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0" edge="1" parent="1" source="4" target="7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="14" value="" style="{{ESTYLE}};exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="4" target="6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="15" value="{{EDGE15_LABEL}}" style="{{ESTYLE}}{{EDGE15_DASH}};exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="4" target="8">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
```
### Rendering the sample
1. Write the filled XML to `/tmp/drawio-preset-<name>.drawio`.
2. Run the same `drawio -x -f png -e -s 2 -o <preset-name>-sample.png <tmp>.drawio` command the main workflow uses (substitute the binary name you resolved in SKILL.md Step 1 if it isn't `drawio`).
3. Repair the IEND chunk: `python3 <this-skill-dir>/scripts/repair_png.py <preset-name>-sample.png` — the `-e` flag truncates the PNG the same way the main workflow's step 7 does, so the sample needs the same fix to be readable.
4. Save the PNG as `./preset-<name>-sample.png` (the user's working directory).
5. Show the user: preset summary table + PNG path + provenance/confidence line.
### Approval loop
- "save" / "looks good" → write candidate to `~/.drawio-skill/styles/<name>.json`; delete tempfile and sample PNG.
- "change <field> to <value>" → edit the in-memory candidate; re-render; re-ask.
- "cancel" → delete tempfile and sample PNG; no save.
### If sample render fails (draw.io CLI missing / export error)
Still show the summary table and the provenance line. Note: *"Could not render sample PNG (CLI unavailable). Save anyway on your OK."* Do not block.
## XML extraction path
Input: a `.drawio` file path. Output: candidate preset JSON. Deterministic, no LLM inference.
### Steps
1. **Parse the file.** Read the XML, collect every `<mxCell>` with a `style=` attribute, split into vertices (`vertex="1"`) and edges (`edge="1"`).
2. **Tokenize each `style=` string** on `;`. Each element is either `key=value` or a bare keyword (e.g., `rhombus`, `ellipse`, `rounded=1`).
3. **Extract palette.** For every vertex, take the `(fillColor, strokeColor)` pair (skip vertices with neither). Count frequency. Keep the top ≤7 pairs.
4. **Extract shape vocabulary + role mapping.** For each vertex determine a shape class by precedence:
`cylinder3 > ellipse > rhombus > swimlane > rounded=1 > rounded=0`.
Then infer the semantic role from the vertex's shape class and its `value` (label) attribute. **Evaluate the rules below in order; first match wins.**
- `cylinder3` → `database`
- `rhombus` → `decision`
- `swimlane` → `container`
- `dashed=1` present + **grey-family fill** (hex where the R, G, and B channels all fall within ±16 of each other, i.e., near-achromatic) → `external`
- label matches `/queue|bus|kafka|rabbit/i` → `queue`
- label matches `/gateway|api|lb|load/i` → `gateway`
- label matches `/auth|login|jwt|oauth/i` → `security`
- label matches `/error|fail|alert/i` → `error`
- everything else → `service`
For each **role that has a canonical palette slot** — `service`, `database`, `queue`, `gateway`, `error`, `external`, `security` — the most frequent `(role, color-pair)` mapping wins. The pair goes into the role's canonical palette slot:
`service→primary, database→success, queue→warning, gateway→accent, error→danger, external→neutral, security→secondary`.
Set `roles[role]` to that slot name.
**Decision and container shapes do not get a `roles[...]` entry** — they are recorded only in `shapes.decision` and `shapes.container`. Any color pairs observed on decision/container vertices still participate in the palette (they can fill leftover slots) but are not tied to a semantic role.
Leftover color pairs (not claimed by any role-slot mapping) fill remaining empty palette slots in descending-frequency order.
Record the shape class string used per role in `shapes[role]`. The six named shape keys are `service`, `database`, `queue`, `decision`, `external`, `container` — `gateway`, `error`, and `security` roles inherit `shapes.service` and do not get their own `shapes[...]` entry. Example: `shapes.database = "shape=cylinder3"`.
5. **Extract fonts.** Compute modal `fontFamily` and `fontSize` across vertices; emit them as `font.fontFamily` and `font.fontSize`. Also track `fontStyle` per vertex as a **working variable** (not an output field — the schema has no top-level `font.fontStyle`). If a distinguishable subset of vertices uses a larger `fontSize` combined with `fontStyle=1` (bold), treat that subset as titles: set `font.titleFontSize` to their modal size and `font.titleBold: true`. Otherwise omit both title fields.
6. **Extract edge defaults.** Take the modal edge style string, but strip these per-edge coordinate keys before counting: `entryX`, `entryY`, `exitX`, `exitY`, `entryDx`, `entryDy`, `exitDx`, `exitDy`. Record arrow style from `endArrow`/`endFill` separately in `edges.arrow`.
If any edges have `dashed=1`, collect their `value` (label) attributes. If ≥2 share a common token (e.g., all are labeled "async" or "optional"), add that token to `edges.dashedFor`.
7. **Extract extras.** `sketch=1` seen on any vertex or edge → `extras.sketch = true`. Modal `strokeWidth` across vertices → `extras.globalStrokeWidth` (default `1`).
8. **Set provenance.**
```json
{
"source": { "type": "xml", "path": "<input absolute path>", "extracted_at": "YYYY-MM-DD" },
"confidence": "high"
}
```
### XML edge cases
| Situation | Behavior |
|---|---|
| Source has <3 distinct color pairs | Leave unfilled slots as `null`. Downgrade `confidence` to `"medium"`. Summary warns the user. |
| Source has >7 color pairs | Keep the top 7 by frequency. Summary warns that some colors were dropped. |
| Non-standard `shape=` keywords (e.g., `shape=mxgraph.aws4.*`) | These do not match the Step 4 precedence ladder, so the vertex falls through to `rounded=0` for shape-class purposes. Iconography is lost; color, label, and edge style are still captured. Role inference still runs via the label-regex rules. Summary notes: *"Non-standard shape library detected — iconography not preserved in preset (color and label captured)."* |
| Non-English labels | The English-keyword regexes in step 4 will mostly miss; most vertices collapse to `service`. Palette/shapes/font/edges still captured correctly (they don't depend on label text). `confidence` stays `"high"`. Summary notes: *"Role labels not in English — `service`/`database`/`decision`/`container`/`external` inferred from shape class; other roles not mapped."* |
| File has no `<mxCell vertex="1">` at all | Stop. Refuse to save. Message: *"Nothing to learn from — source file has no shapes."* |
## Image extraction path
Input: path to a PNG/JPG (or any vision-readable image format). Output: candidate preset JSON. Inference-based; `confidence: "medium"` at best.
**Prerequisite:** the agent's vision capability must be available (same mechanism the main workflow's self-check uses). If vision is not available, stop and tell the user:
*"Image-based learning needs a vision-enabled model (Claude Sonnet or Opus). Re-run on such a model, or provide the `.drawio` source file instead."*
### Steps
1. **Read the image.** Use the agent's vision input — the same path the main workflow's step 5 uses to read exported PNGs during self-check.
2. **Extract palette by visual inspection.** Identify distinct fill-color regions on shape bodies.
For each distinct fill:
- `fillColor` — quantize each RGB channel to the nearest multiple of 16. If the resulting HSL lightness is below 0.75, raise it to 0.85 (keep hue and saturation; set L=0.85; HSL→RGB round-trip). Emit as `#RRGGBB`. Drawio-standard pastels occupy L≈0.85–0.96; below 0.75 reads as "too dark for a fill color" and this step lifts it back into that range.
- `strokeColor` — read the matching border. If unreadable, derive from fill by darkening ~25% (match HSL, drop L by 0.25).
Map each `(fillColor, strokeColor)` pair to a named slot using this decision order:
1. **Grey check first.** If the fill has R, G, and B channels all within ±16 of each other (same definition as the XML path's grey-family rule), OR HSL saturation < 0.20, classify as `neutral`. This check wins regardless of hue angle.
2. **Hue band otherwise.** Use these explicit HSL hue ranges:
- 180°–260° → `primary` (blue)
- 80°–170° → `success` (green)
- 45°–65° → `warning` (yellow)
- 20°–44° → `accent` (orange)
- 0°–19° or 320°–360° → `danger` (red/pink)
- 260°–320° → `secondary` (purple)
3. **No band matched** (gap regions at 65°–80° or 170°–180°) → spill to the nearest band by angular distance.
**Collision rule.** If ≥2 distinct fills land in the same slot, sort them by total pixel area covered in the image (descending). The largest keeps the canonical slot. Remaining fills spill to the **nearest empty slot** measured by hue-band angular distance — first to adjacent bands on either side, then farther out. If every slot is already filled, drop the extras and warn in the summary.
3. **Extract shape vocabulary.** Classify every visible shape by silhouette:
- rounded rectangle → `rounded=1`
- sharp rectangle → `rounded=0`
- circle / oval → `ellipse`
- diamond → `rhombus`
- cylinder (rectangle with curved top/bottom) → `shape=cylinder3`
- titled container (header bar + nested children inside) → `swimlane;startSize=30`
- dashed-bordered rectangle → `rounded=1;dashed=1`
Role assignment uses the **same label-text + shape rules as the XML path step 4**. Visible labels are read via vision.
4. **Extract fonts.** Best-effort. Distinguishable categories:
- clearly serif → `fontFamily: "Georgia"`
- clearly monospaced → `fontFamily: "Courier New"`
- otherwise → `fontFamily: "Helvetica"`
Size by relative appearance:
- small → `fontSize: 11`
- medium → `fontSize: 12`
- large → `fontSize: 14`
If titles/container headers are distinctly larger or bolder → set `titleFontSize` accordingly and `titleBold: true`.
5. **Extract edge defaults.**
- Right-angle orthogonal arrows → `edges.style = "edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1"`.
- Curved arrows → append `;curved=1` to `edges.style`.
- Filled triangle arrowheads → `edges.arrow = "endArrow=classic;endFill=1"`.
- Open V-shaped arrowheads → `edges.arrow = "endArrow=open;endFill=0"`.
- Any dashed arrows near labels like "optional", "async", "fallback", "secondary" → add those label tokens to `edges.dashedFor`.
6. **Extract extras.**
- Visibly hand-drawn / rough / sketch look (wavy strokes, uneven fills) → `extras.sketch = true`.
- Heavy strokes (clearly >1.5× normal) → `extras.globalStrokeWidth = 2`.
- Otherwise default: `extras = { "sketch": false, "globalStrokeWidth": 1 }`.
7. **Set provenance and confidence.**
```json
{
"source": { "type": "image", "path": "<input absolute path>", "extracted_at": "YYYY-MM-DD" },
"confidence": "medium"
}
```
Adjustments:
- <3 distinct shapes identifiable → `confidence: "low"`.
- Image path stays at `"medium"` by default. The only path to `"high"` is a strictly-verifiable signal: the source image was exported from drawio itself (recognizable drawio default chrome, grid, or a visible drawio watermark), **and** all seven palette slots are filled, **and** all seven roles are labeled. This preserves the semantic gap between inference-based (image) and parse-based (XML) provenance.
### Image edge cases
| Situation | Behavior |
|---|---|
| Vision unavailable | Stop as described above — do not fall back to guessing. |
| Image has <3 identifiable shapes | Continue; mark `confidence: "low"`; summary explicitly warns the user that the preset is a loose approximation. |
| Image has no visible labels | Role assignment collapses to shape-class only: cylinders → `database`, diamonds → `decision`, swimlanes → `container`, dashed-bordered rectangles with grey fill → `external`, everything else → `service`. Palette/font/edges still captured. Summary notes: *"No labels readable — semantic roles beyond shape-class not inferred."* |
| Two palette slots would land in the same hue family | Keep the more frequent one in its canonical slot; spill the other to the adjacent empty slot (rule in step 2). |
| Image has more than 7 distinct fills | Keep the 7 most area-covering fills per the Step 2 collision rule. Summary warns that some colors were dropped. |
references/style-presets.md
# Style Presets — Learn, Apply, Manage
A **style preset** is a named JSON file capturing a user's visual preferences — palette, shape vocabulary, fonts, edge style. When a preset is active, it fully replaces the built-in conventions in SKILL.md's color/shape/edge tables.
Read this file when:
- The user asks to "learn", "save", "remember", or "extract" a style from a file
- The user wants to manage existing presets (list, set default, delete, rename)
- You've resolved an active preset in Step 0 and need the application rules
- You need to validate a preset file before loading it
## Locations and lookup order
1. `~/.drawio-skill/styles/<name>.json` — user presets (survive `git pull`).
2. `<this-skill-dir>/styles/built-in/<name>.json` — built-ins shipped with the skill (`default`, `corporate`, `handdrawn`, `colorblind-safe` — Okabe-Ito palette, distinguishable under color-vision deficiency, thicker strokes; `dark` — dark fills + page background, light strokes, needs the `extras.fontColor`/`edgeColor`/`background` rules below).
A user preset shadows a built-in of the same name.
Only user presets can have `"default": true`. When the user says *"make `<built-in-name>` my default"*, copy the built-in JSON to `~/.drawio-skill/styles/<name>.json` first, then set `default: true` on the copy — leave the shipped built-in untouched.
**Name normalisation:** always lowercase the user-provided name before writing or looking up files (the preset schema enforces lowercase; uppercase names will fail validation).
## Applying a preset
> **Existing diagrams:** these rules apply at generation time. To re-theme a `.drawio` that already exists, run `python3 scripts/restyle.py diagram.drawio --preset <name>` — it applies the palette (hue-mapped), font, and extras without touching layout or edge routing.
When SKILL.md's Step 0 identified a preset, it fully replaces the built-in palette, shape keywords, edge defaults, and font for this diagram — do not mix values from the built-in color table.
**Color lookup.** For each role a shape plays (service / database / queue / gateway / error / external / security), resolve `preset.roles[role]` to a slot name, then `preset.palette[<slot>]` to the `(fillColor, strokeColor)` pair. If `roles[role]` is unset or the resolved slot is `null`, follow this fallback ladder:
1. Try the role's canonical slot (`service→primary`, `database→success`, `queue→warning`, `gateway→accent`, `error→danger`, `external→neutral`, `security→secondary`).
2. If that slot is also empty, pick the most-populated non-null slot in the preset.
3. Never reach into the built-in color table — the preset is authoritative.
**Decision and container shapes** are not in `preset.roles` — they have shape vocabulary (`preset.shapes.decision`, `preset.shapes.container`) but no role-to-slot mapping. Pick their colors as follows:
- **Decision** (rhombus) → use `preset.palette.warning` (the canonical yellow slot in the built-in conventions). If `warning` is empty, apply the slot-fallback ladder above starting from `warning`.
- **Container** (swimlane) → use the palette slot matching the tier/grouping the container represents (e.g. a "Services" tier container uses `primary`; a "Data" tier uses `success`). If no tier signal is available, default to `primary`.
**Shape keywords.** Use `preset.shapes[role]` as the **prefix** of the vertex style string (before `whiteSpace=wrap;html=1;...`). Example: for a database role, if `preset.shapes.database = "shape=cylinder3"`, the vertex style starts `shape=cylinder3;whiteSpace=wrap;html=1;fillColor=...`. The six named shape keys are `service`, `database`, `queue`, `decision`, `external`, `container`. Roles `gateway`, `error`, and `security` reuse `preset.shapes.service` unless the preset explicitly populates a key with their name.
**Edges.** Use `preset.edges.style` as the base edge style string. Append `preset.edges.arrow`. Per-edge routing keys (`exitX/exitY/entryX/entryY/...`) are still added by the usual routing rules in SKILL.md. If the flow between two shapes matches a token from `preset.edges.dashedFor` (either because the user's prompt used that word, or because one end of the edge plays a role whose typical relation is "optional"), append `;dashed=1` to the edge style.
**Fonts.** Append `fontFamily=<preset.font.fontFamily>;fontSize=<preset.font.fontSize>` to every vertex style. Container headers and swimlane titles additionally get `fontSize=<preset.font.titleFontSize>;fontStyle=1` when `preset.font.titleBold` is `true`.
**Extras.**
- `preset.extras.sketch === true` → append `sketch=1` to every vertex style and every edge style.
- `preset.extras.globalStrokeWidth !== 1` (any value other than the drawio default of 1, including `0.5`) → append `strokeWidth=<n>` to every vertex style and every edge style.
- `preset.extras.fontColor` (present) → append `fontColor=<hex>` to every vertex and container style. Required for dark palettes — without it, dark fills render unreadable black text.
- `preset.extras.edgeColor` (present) → append `strokeColor=<hex>;fontColor=<hex>` to every edge style (edges otherwise default to black, invisible on dark backgrounds).
- `preset.extras.background` (present) → set `background="<hex>"` on the `<mxGraphModel>` element, and export PNG **without** `-t` (transparent) so the background is actually painted — a dark diagram on a transparent PNG looks broken in white viewers.
**Interaction with diagram-type presets** (ERD / UML / Sequence / ML / Flowchart). Diagram-type presets set structural style keywords that the user preset must preserve (e.g. ERD tables rely on `shape=table;startSize=30;container=1;childLayout=tableLayout;...`). The rule: keep the diagram-type preset's structural keywords, then layer the user preset's color / font / edge / extras on top. When a diagram-type preset hardcodes a color (`fillColor=#dae8fc`, etc.) that conflicts with the user preset, the user preset's color wins. Exception: `fillColor=none` is structural — do not replace it with a palette color.
## Learn flow
**Triggers:** "learn my style from `<path>` as `<name>`", "save this as `<name>` style", "remember this style as `<name>`".
**Dispatch by file extension:**
- `.drawio`, `.xml` → XML path
- `.png`, `.jpg`, `.jpeg`, `.svg` (rasterized flat image) → image path
**Steps:**
1. **Load the extraction reference.** Read `references/style-extraction.md` into context.
2. **Extract** following the XML path or image path procedure in the reference.
3. **Normalize and build candidate.** Convert the user-provided preset name to lowercase. Use this normalized name for ALL file paths in this flow. Build the candidate preset JSON and write it to `/tmp/drawio-preset-<name>.json` (where `<name>` is the already-normalized name). Do **not** save to `~/.drawio-skill/styles/<name>.json` yet.
4. **Render a sample** using the sample-diagram skeleton in `references/style-extraction.md`, parameterized by the candidate preset. Export PNG to `./preset-<name>-sample.png` using the same `drawio -x -f png -e -s 2 -o ./preset-<name>-sample.png /tmp/drawio-preset-<name>.drawio` command the main workflow uses, then run `repair_png.py` on it (see the Rendering the sample steps in `style-extraction.md`).
5. **Show the user:**
- Preset summary table (palette hex values, shapes per role, font, edge style, extras).
- The sample PNG path (and embed the image if the environment supports it).
- Provenance line: `source.type`, `source.path`, `extracted_at`, `confidence`.
6. **Wait for approval:**
- "save" / "looks good" → write candidate to `~/.drawio-skill/styles/<name>.json`. Create `~/.drawio-skill/styles/` if it doesn't exist. Delete tempfile and sample PNG.
- "change `<field>` to `<value>`" → edit the in-memory candidate, re-render, re-ask.
- "cancel" / "abort" / "no" → delete tempfile and sample PNG; nothing saved.
**Error behavior:**
| Failure | Behavior |
|---|---|
| Source path does not exist | Stop; report path not found. |
| XML parse fails | Stop; report the parse error; suggest opening the file in drawio desktop to repair. |
| Image vision unavailable | Stop; tell user to re-run on a vision-capable model or provide the `.drawio` file. |
| Extraction yields 0 vertices / shapes | Stop; refuse to save. |
| Extraction yields <3 distinct color pairs | Continue; mark `confidence: "low"` (image) or `"medium"` (XML); warn in summary. |
| Preset name collides with existing user preset | Ask: overwrite, or pick a new name. |
| Preset name collides with a built-in preset | Save to user dir (shadows the built-in); warn once. |
| Sample render fails | Still show summary; note "could not render sample — saving on your OK anyway". Do not block. |
## Management operations
All operations are natural language — no slash commands.
*Apply name normalisation (lowercase) to all `<name>`, `<a>`, `<b>` arguments before any file operation.*
| User says | Agent does |
|---|---|
| "list my styles", "what styles do I have", "show me my style presets" | Read `~/.drawio-skill/styles/` and `<this-skill-dir>/styles/built-in/`. Print a table: `name`, `location` (user/built-in), `source.type`, `confidence`, `default` flag. Built-ins shadowed by a user preset are marked so. |
| "show my `<name>` style", "what's in `<name>`" | Print the preset JSON (pretty-printed) + a one-line summary (source, confidence, is-default). |
| "make `<name>` the default", "set `<name>` as default" | If `<name>` is a user preset: set `default: true` on it; clear `default` on any other user preset that had it; save both files. If `<name>` is a built-in: copy `<this-skill-dir>/styles/built-in/<name>.json` → `~/.drawio-skill/styles/<name>.json` first, then set `default: true` on the copy. Never mutate the shipped built-in. |
| "remove default", "unset default" | Clear `default: true` from whichever user preset has it. |
| "delete `<name>`", "remove `<name>`" | Confirm first. Then `rm ~/.drawio-skill/styles/<name>.json`. Refuse to delete files under `<this-skill-dir>/styles/built-in/` — suggest shadowing with a user preset of the same name. |
| "rename `<a>` to `<b>`" | `mv ~/.drawio-skill/styles/<a>.json ~/.drawio-skill/styles/<b>.json`, then update the `name` field inside. Fails if `<a>` is a built-in (offer to copy-then-rename instead). |
| "learn my style from `<path>` as `<name>`" | Dispatch to the Learn flow above. |
## Preset file validation
When loading any preset (for generation or management), do a lightweight structural check:
- Required top-level fields present (`name`, `version`, `palette`, `roles`, `shapes`, `font`, `edges`).
- `version === 1`.
- Every populated palette slot has both `fillColor` and `strokeColor` as `#RRGGBB`.
- `confidence` ∈ {`"low"`, `"medium"`, `"high"`} if present.
On validation failure:
- **During generation:** warn the user, fall back to built-in conventions for this one diagram, do not mutate the file.
- **During learn:** refuse to save the candidate; report which field failed.
references/toolbox.md
# Toolbox — every bundled script, by use-case
A map of the 31 bundled scripts grouped by what you're trying to do. The
per-task routing table in `SKILL.md` says *when* to reach for each; this says
*how they fit together*. Read it when you're not sure which script a request
maps to, or you want to chain several.
The recurring backbone is one pipeline — an **extractor** emits graph JSON, then
`autolayout.py` places it, then `validate.py` lints it, then the draw.io CLI
exports it:
```
<extractor> → graph.json → autolayout.py → diagram.drawio → validate.py → (export PNG/SVG/PDF)
```
## Quick decision guide
| I have… | I want… | Use |
|---|---|---|
| a description in words | a styled diagram | hand-write XML (`references/xml-authoring.md`) or `autolayout.py` |
| a big/complex graph | it laid out for me | `autolayout.py` (`--tune` picks direction) |
| a Python/JS/Go/Rust project | its module/class structure | `pyimports` · `jsimports` · `goimports` · `rustimports` · `pyclasses` |
| Terraform/K8s/compose files | the **declared** architecture | `tfimports` · `k8simports` · `composeimports` |
| a running cluster/stack/cloud | what's **actually deployed** | `tfstate` · `dockerimports` · `k8simports -` |
| a SQL schema | an ER diagram | `sqlerd` |
| an OpenAPI / Swagger spec | an API diagram (by method) | `openapiimports` |
| CI workflows (GH Actions / GitLab) | the pipeline as a DAG | `ciimports` |
| a diagram + a metrics file | it coloured by the data | `heatmap` |
| a sequence of interactions | a UML sequence diagram | `seqlayout` |
| a system at 3 zoom levels | a C4 model with drill-down | `c4` |
| two diagrams / two snapshots | what changed (drift) | `drawiodiff` |
| a repo's git history | how its architecture grew | `timelapse` |
| a `.drawio` | a shareable interactive viewer | `drawiohtml` (→ HTML: pan/zoom/search/tabs) |
| a `.drawio` | a written description | `explain` (→ Markdown) |
| a `.drawio` | a slide deck | `drawio2pptx` (→ PPTX) |
| a `.drawio` | an animated data-flow | `svgflow` (→ SVG) |
| a `.drawio` | diagrams-as-code | `drawio2mermaid` (→ Mermaid) |
| a `.drawio` | the same diagram in another language | `relabel` (extract → translate → apply) |
| a `.drawio` | it re-themed (dark / corporate preset) | `restyle` |
| a shape/icon need | the exact style string | `shapesearch` · `aiicons` (AI/LLM logos) |
| a photo/screenshot of a diagram | an editable `.drawio` | `raster2drawio` (your vision → JSON → draw.io) |
| ONE `.drawio` | it building itself, as a video/GIF | `buildup` (→ HTML player; `--gif`) |
| a big/sprawling diagram | a boardroom exec summary + drill-down | `compress` |
| a decision-tree flowchart | a click-through triage app | `runbook` (→ HTML, no CLI) |
| a PR touching `.drawio` | rendered before/after/diff for reviewers | `prdiff` (+ GitHub Action) |
| a pipeline / journey / subsystem map | it drawn as a metro / subway map | `tubemap` (coloured lines, octilinear, interchanges) |
## 1. Author & place
- **`autolayout.py`** — graph JSON → placed `.drawio` (Graphviz `dot`; orthogonal routing, `--group` containers, `--tune` best direction). The hub every extractor feeds. See `references/autolayout.md`.
- **`seqlayout.py`** — participants + messages JSON → sequence diagram with computed lifelines/activation bars (no Graphviz).
- **`c4.py`** — levels JSON → one multi-page `.drawio` (Context→Container→Component) with click-to-drill-down links.
- **`tubemap.py`** — metro JSON (coloured lines + grid-placed stations) → a London-Underground-style **tube map**: octilinear (H/V/45°) routing, white interchange circles, station stops. No Graphviz. See `references/tubemap.md`.
- **`shapesearch.py`** — search 10k+ official shapes for their exact `style=` string. **`aiicons.py`** — draw.io `image` styles for AI/LLM brand logos.
- **`raster2drawio.py`** — a vision-extracted image graph JSON (from a whiteboard photo / legacy PNG / Visio screenshot) → editable `.drawio` honouring the read coordinates; missing positions fall back to `autolayout.py`. See `references/derasterize.md`.
## 2. Code → diagram
- **`pyimports` · `jsimports` · `goimports` · `rustimports`** — a project's intra-module import graph (transitive-reduced; `--group` boxes by sub-package).
- **`pyclasses.py`** — a Python class-inheritance graph.
All emit graph JSON → `autolayout.py`.
## 3. Infrastructure → diagram (declared config)
- **`tfimports.py`** — Terraform `.tf` → resources as official AWS/Azure/GCP icons.
- **`k8simports.py`** — K8s manifests → objects as official kind icons (edges: Ingress→Service→workload→ConfigMap/Secret/PVC).
- **`composeimports.py`** — docker-compose → service boxes + volume cylinders.
- **`sqlerd.py`** — SQL DDL (`CREATE TABLE`) → ERD with crow's-foot FK edges.
- **`ciimports.py`** — GitHub Actions (`.github/workflows/*.yml`) and/or `.gitlab-ci.yml` -> pipeline DAG: job nodes (runner, `matrix xN`, reusable-workflow calls in purple), `needs:` edges, an `on:` trigger node per workflow, jobs boxed per workflow / per GitLab stage.
- **`openapiimports.py`** — OpenAPI 3 / Swagger 2 spec → API diagram: one node per operation (coloured by HTTP method) + one per component schema, with edges to the schemas each operation uses and between nested schemas. `--group` by tag.
## 4. Live infrastructure → diagram (actually running)
The **actual** counterpart to §3 — see `references/live-infra.md`.
- **`tfstate.py`** — `terraform show -json | tfstate.py -` → deployed resources (provider-agnostic; expands `count`/`for_each`).
- **`dockerimports.py`** — `docker inspect $(docker ps -q) | dockerimports.py -` → running containers + networks + volumes.
- **`k8simports.py -`** — `kubectl get all,ing,cm,secret,pvc -o json | k8simports.py -` → live cluster.
## 5. Compare & evolve
- **`drawiodiff.py`** — diff two `.drawio` (or two live snapshots) → colour-coded graph (added=green, removed=red, changed=orange). Pairs with §4 for drift.
- **`timelapse.py`** — re-run an extractor across git history → a self-contained HTML player of how the architecture grew.
- **`heatmap.py`** — recolour any `.drawio` by a metrics file (CSV/JSON): each node shaded low→high on a gradient by its value (`--palette`, optional `--size`, auto legend). Turns a static architecture into a cost / latency / traffic / error-rate heat map.
- **`buildup.py`** — reveal ONE diagram's cells in dependency order (topological over its edges) → self-contained HTML player (embedded PNG frames, play/pause/step/scrub); optional `--gif`. Needs the draw.io CLI.
- **`compress.py`** — big `.drawio` → 2-page executive summary. Pure-Python label-propagation clustering (no networkx), one auto-named node per cluster with a drill-down link to the full original on page 2, aggregated cross-cluster edges. Needs Graphviz.
- **`prdiff.py`** — for every `.drawio` changed between two git refs, render base/head/`drawiodiff`-diff PNGs + a Markdown report for a PR comment; ships a composite GitHub Action (`.github/actions/drawio-diff/`). See `references/pr-bot.md`.
## 6. Diagram → other formats (reverse / interop)
The skill runs both directions — these turn a `.drawio` back into something else:
- **`drawiohtml.py`** — → a self-contained **interactive HTML viewer**: every page inlined as SVG with tabs, drag-pan, wheel-zoom, node search, and working drill-down links (C4 `data:page/id` links switch tabs). Share one file; no draw.io, no server.
- **`explain.py`** — → structured **Markdown** (components by tier, relations, per-page C4).
- **`drawio2pptx.py`** — → a 16:9 **PowerPoint** deck, one page per slide (needs `python-pptx`).
- **`svgflow.py`** — → an **animated SVG** (edges flow as marching ants); renders on GitHub.
- **`drawio2mermaid.py`** — → **Mermaid** `flowchart` text (diagrams-as-code GitHub renders).
- **`runbook.py`** — a flowchart/decision-tree → a self-contained **click-through HTML runbook** (current-step text, per-edge choice buttons, breadcrumb, Back/Restart). Reads the XML directly — no draw.io CLI needed.
## 7. Utilities & quality
- **`relabel.py`** — swap every label via a JSON map, layout untouched — `--extract` dumps an identity map of all labels (vertices, edges, UserObjects, page names), translate the values, `--map` applies them. Built for bilingual (EN/CN) variants of one diagram.
- **`restyle.py`** — apply a style preset (user or built-in, e.g. `dark`) to an existing `.drawio`: palette remap by hue, font, dark-theme extras, page background. Layout, shapes, and edge routing stay put.
- **`validate.py`** — deterministic structural lint (dangling edges, dup/reserved ids, overlaps; `--score` for layout readability). Run before exporting.
- **`repair_png.py`** — fix draw.io's truncated IEND chunk after every `-e` PNG export (issue #8).
- **`encode_drawio_url.py`** — encode a `.drawio` into a diagrams.net browser URL when the CLI is unavailable (`--edit` for an editable editor URL).
references/troubleshooting.md
# Troubleshooting — Common Mistakes
Read this when something looks wrong in the output (rendering, export, layout, edges) or when a CLI invocation fails. Most rows have a one-line fix.
| Mistake | Fix |
|---------|-----|
| Missing `id="0"` and `id="1"` root cells | Always include both at the top of `<root>` |
| Shapes not connected | `source` and `target` on edge must match existing shape `id` values |
| Self-closing edge `mxCell` (`<mxCell ... edge="1" />`) | Use the expanded form with `<mxGeometry relative="1" as="geometry" />` child — self-closing edges won't render |
| `--` inside XML comments | Illegal per XML spec — use single hyphens or rephrase |
| Special characters in `value` | Use XML entities: `&` `<` `>` `"` |
| Literal `\n` in label text | Use `
` for line breaks in `value` attributes |
| Overlapping shapes | Scale spacing with complexity (200–350px); leave routing corridors |
| Edges crossing through shapes | Add waypoints, distribute entry/exit points, or increase spacing |
| Arrowhead overlaps bend | Final edge segment before target must be ≥20px — increase spacing or add waypoints |
| Iteration loop never ends | After 5 rounds, suggest user open .drawio in draw.io desktop for fine-tuning |
| `command not found: draw.io` after `brew install --cask drawio` | Homebrew installs the binary as `drawio` (no dot). Use `drawio --version`, not `draw.io --version`. The dot-name only exists inside the `.app` bundle (`/Applications/draw.io.app/Contents/MacOS/draw.io`) and on Windows (`draw.io.exe`). |
| Export command not found on macOS | Try full path `/Applications/draw.io.app/Contents/MacOS/draw.io` |
| Vision returns "Unable to resize image — dimensions exceed the 2576x2576px limit" | The preview PNG is too large for Claude's vision API. Re-export with `--width 2000` instead of `-s 2` (the flag is `--width`; there is no short `-w` — passing `-w 2000` silently breaks input-file parsing and drawio errors with "input file/directory not found"). For very tall-narrow diagrams that still overshoot, use `--height 2000` instead. |
| Linux: blank/error output headlessly | Prefix command with `xvfb-run -a` |
| Linux: `--no-sandbox` placed before input file (parsed as filename) | Move `--no-sandbox` to the very end of the command (drawio-desktop#249, #1056) |
| Linux: `Failed to get 'appData' path` / `Home directory not accessible` | `export HOME=/tmp` before invoking drawio (drawio-desktop#127) |
| Linux server: segfault / EGL / MESA `failed to load driver` errors | Add `--disable-gpu` (suppresses Chromium GL init when no GPU available) |
| PDF export fails | Ensure Chromium is available (draw.io bundles it on desktop) |
| Background color wrong in CLI export | Known CLI bug; add `--transparent` flag or set background via style |
| Vision returns 400 "Could not process image" on draft PNG | Re-export the preview without `-e` (issue #8). Root cause is a truncated IEND chunk in `-e` PNGs, not the `zTXt` chunk itself — but skipping `-e` for the preview is the simplest fix. |
| Final `-e` PNG won't open in image viewers / vision APIs | Run `python3 <this-skill-dir>/scripts/repair_png.py <path>`. draw.io CLI emits `-e` PNGs with an 8-byte truncation at IEND. SVG/PDF unaffected. |
| WSL2: `drawio` / `draw.io` not found | The CLI lives on the Windows side. Use the Windows desktop exe via `/mnt/c`: `"/mnt/c/Program Files/draw.io/draw.io.exe"` (or per-user `"/mnt/c/Users/<you>/AppData/Local/Programs/draw.io/draw.io.exe"`). |
| WSL2: opening an exported file fails with a `/mnt/c/...`-style path | `cmd.exe` can't resolve WSL paths — convert first: `cmd.exe /c start "" "$(wslpath -w diagram.drawio.png)"`. The empty `""` after `start` is the (required) window title. |
| Browser URL opens to a blank/empty diagram (Windows/WSL2) | `cmd.exe`'s `start` treats `&` as a separator and drops everything after `#` — so the `#R…`/`#create=…` fragment (the whole diagram) is lost. Never pass the URL straight to `start`. Write a `.url` shortcut file and open *that* (see "WSL2 / Windows" below). |
## WSL2 / Windows specifics
**Locate the CLI.** Detect WSL2 with `grep -qi microsoft /proc/version`. On WSL2 the
export CLI is the Windows desktop exe, reached through `/mnt/c` (quote the path —
it contains a space):
```bash
"/mnt/c/Program Files/draw.io/draw.io.exe" --version
# per-user install fallback:
"/mnt/c/Users/$USER/AppData/Local/Programs/draw.io/draw.io.exe" --version
```
**Open a file.** Convert the WSL path to a Windows path first; `cmd.exe` cannot
follow `/mnt/c/...`:
```bash
cmd.exe /c start "" "$(wslpath -w diagram.drawio.png)"
```
**Open a browser-fallback URL.** `cmd.exe /c start` strips the URL fragment
(`&` ends the command, `#…` is dropped) — and the fragment carries the entire
diagram. Write a `.url` shortcut and open it instead, so the URL survives intact:
```bash
URL=$(python3 <this-skill-dir>/scripts/encode_drawio_url.py --edit diagram.drawio)
TMP=$(mktemp --suffix=.url)
printf '[InternetShortcut]\r\nURL=%s\r\n' "$URL" > "$TMP"
cmd.exe /c start "" "$(wslpath -w "$TMP")"
```
On native Windows the same `.url`-file trick applies (`start "" "%TEMP%\d.url"`).
On macOS/Linux just `open "$URL"` / `xdg-open "$URL"` — no workaround needed.
references/tubemap.md
# Tube-Map Mode — a graph as a metro map
`scripts/tubemap.py` restyles a graph as a **London-Underground-style metro map**:
thick coloured lines, octilinear routing (horizontal / vertical / 45° only), white
interchange circles, small station stops, and offset labels — the instantly-readable
transit-map aesthetic.
Read this when the user asks for a "metro map", "subway map", "tube map", or wants a
system / pipeline / journey drawn as coloured transit lines instead of a boxes-and-
arrows diagram.
```bash
python3 <this-skill-dir>/scripts/tubemap.py metro.json -o metro.drawio
# then the normal workflow: validate.py → preview PNG → self-check → export
```
## When it fits
A tube map reads best when the graph is a set of **overlapping paths** that share a few
**interchange** nodes — pipelines, user journeys, request flows, a product's subsystems,
a roadmap of parallel workstreams. It is *not* the right choice for a dense mesh or a
strict hierarchy (use `autolayout.py` / a diagram-type preset for those).
## Input schema
You (the model) compose the metro JSON — from a system description, or by reading an
existing diagram's structure and grouping its edges into a handful of named "lines".
```json
{
"stations": {
"nl": {"label": "Natural language", "gx": 0, "gy": 2},
"layout": {"label": "Auto-layout", "gx": 4, "gy": 2, "interchange": true},
"drawio": {"label": ".drawio", "gx": 6, "gy": 2, "interchange": true}
},
"lines": [
{"name": "Author", "color": "#0098d4", "stations": ["nl", "layout", "drawio"]},
{"name": "Import", "stations": ["code", "extract", "layout"]}
]
}
```
| Field | Required | Notes |
|---|---|---|
| `stations.<id>.label` | no (defaults to id) | Station name; XML-escaped automatically |
| `stations.<id>.gx` / `gy` | **yes** | Integer **grid** coordinates (not pixels); `--grid` sets the pitch (default 110px) |
| `stations.<id>.interchange` | no | `true` → white-fill black-ring circle (a transfer station); else a small stop |
| `lines[].name` | no | For your own reference |
| `lines[].color` | no | `#rrggbb`; a line without one gets the next colour from the built-in tube palette |
| `lines[].stations` | **yes** | Ordered station ids the line passes through; a station id may appear on several lines (that's an interchange) |
## The one layout rule: keep segments octilinear
For the crispest map, place each line's consecutive stations so they are **aligned
horizontally, vertically, or on a 45° diagonal** (same `gx`, same `gy`, or equal
`|Δgx| == |Δgy|`). When two stations aren't aligned, the script inserts **one** bend —
it runs the 45° diagonal for the shorter delta, then a straight axis segment into the
target — so the line still reads as a metro line rather than an arbitrary curve. Mark the
nodes where lines cross as `interchange: true`.
## Limitation
`tubemap.py` honours the grid coordinates you give it — it does **not** solve the
(NP-hard) octilinear metro-layout problem for you. Placing the stations on a sensible
grid is the authoring step; the script does the drawing, routing, markers, and colours.
Parallel lines sharing the exact same segment are drawn on top of each other (offset them
by a grid row if you need both visible).
references/xml-authoring.md
# Authoring .drawio XML
Read this **before hand-writing any `.drawio` XML** (workflow step 3). Skip it when a bundled generator writes the XML for you (`autolayout.py` + importers, `seqlayout.py`).
### File skeleton
```xml
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="drawio" version="26.0.0">
<diagram name="Page-1">
<mxGraphModel>
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- user shapes start at id="2" -->
</root>
</mxGraphModel>
</diagram>
</mxfile>
```
**Rules:**
- `id="0"` and `id="1"` are required root cells — never omit them
- User shapes start at `id="2"` and increment sequentially
- All shapes have `parent="1"` (unless inside a container — then use container's id)
- All text uses `html=1` in style for proper rendering
- **Never use `--` inside XML comments** — it's illegal per XML spec and causes parse errors
- Escape special characters in attribute values: `&`, `<`, `>`, `"`
- **Multi-line text in labels:** use `
` for line breaks inside `value` attributes (not literal `\n`). Example: `value="Line 1
Line 2"`
### Shape types (vertex)
| Style keyword | Use for |
| -------------- | --------- |
| `rounded=0` | plain rectangle (default) |
| `rounded=1` | rounded rectangle — services, modules |
| `ellipse;` | circles/ovals — start/end, databases |
| `rhombus;` | diamond — decision points |
| `shape=mxgraph.aws4.resourceIcon;` | AWS icons |
| `shape=cylinder3;` | cylinder — databases |
| `swimlane;` | group/container with title bar |
For **vendor/branded icons** (AWS/Azure/GCP/Cisco/Kubernetes) and any non-trivial shape, don't guess the `shape=mxgraph.*` name — a wrong name renders as a blank box. Run `python3 <this-skill-dir>/scripts/shapesearch.py "<keywords>"` to get the exact official style + size, or see `references/shapes.md` for the hand-writable cheatsheet. For **AI/LLM brand logos** (OpenAI, Claude, Gemini, …), which draw.io has none of, use `python3 <this-skill-dir>/scripts/aiicons.py "<brand>"`.
### Required properties
```xml
<!-- Rectangle / rounded box -->
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="160" height="60" as="geometry" />
</mxCell>
<!-- Cylinder (database) -->
<mxCell id="3" value="DB" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontColor=#333333;" vertex="1" parent="1">
<mxGeometry x="350" y="100" width="120" height="80" as="geometry" />
</mxCell>
<!-- Diamond (decision) -->
<mxCell id="4" value="Check?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="100" y="220" width="160" height="80" as="geometry" />
</mxCell>
```
### Containers and groups
For architecture diagrams with nested elements, use draw.io's parent-child containment — do **not** just place shapes on top of larger shapes.
| Type | Style | When to use |
| ------ | ------- | ------------- |
| **Group** (invisible) | `group;pointerEvents=0;` | No visual border needed, container has no connections |
| **Swimlane** (titled) | `swimlane;startSize=30;` | Container needs a visible title bar, or container itself has connections |
| **Custom container** | Add `container=1;pointerEvents=0;` to any shape | Any shape acting as a container without its own connections |
**Key rules:**
- Add `pointerEvents=0;` to container styles that should not capture connections between children
- Children set `parent="containerId"` and use coordinates **relative to the container**
```xml
<!-- Swimlane container -->
<mxCell id="svc1" value="User Service" style="swimlane;startSize=30;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="300" height="200" as="geometry"/>
</mxCell>
<!-- Child inside container — coordinates relative to parent -->
<mxCell id="api1" value="REST API" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="svc1">
<mxGeometry x="20" y="40" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="db1" value="Database" style="shape=cylinder3;whiteSpace=wrap;html=1;" vertex="1" parent="svc1">
<mxGeometry x="160" y="40" width="120" height="60" as="geometry"/>
</mxCell>
```
### Connector (edge)
**CRITICAL:** Every edge `mxCell` must contain a `<mxGeometry relative="1" as="geometry" />` child element. Self-closing edge cells (`<mxCell ... edge="1" ... />`) are **invalid** and will not render. Always use the expanded form.
```xml
<!-- Directed arrow — always include rounded, orthogonalLoop, jettySize for clean routing -->
<mxCell id="10" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- Arrow with label + explicit entry/exit points to control direction -->
<mxCell id="11" value="HTTP/REST" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- Arrow with waypoints — use when edge must route around other shapes -->
<mxCell id="12" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="3" target="5">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="500" y="50" />
</Array>
</mxGeometry>
</mxCell>
```
**Edge style rules:**
- **Animated connectors:** add `flowAnimation=1;` to any edge style to show a moving dot animation along the arrow. Works in SVG export and draw.io desktop — ideal for data-flow and pipeline diagrams. Example: `style="edgeStyle=orthogonalEdgeStyle;flowAnimation=1;rounded=1;..."`
- **Always** include `rounded=1;orthogonalLoop=1;jettySize=auto` — these enable smart routing that avoids overlaps
- Pin `exitX/exitY/entryX/entryY` on every edge when a node has 2+ connections — distributes lines across the shape perimeter. `scripts/edgeports.py <file>` does this for a whole diagram: it picks the side facing each peer and spreads that side's edges over even slots ordered by the far endpoint, so they don't stack or cross at the boundary. It skips ends you pinned by hand and is idempotent
- Add `<Array as="points">` waypoints when an edge must detour around an intermediate shape
- **Leave room for arrowheads:** the final straight segment between the last bend and the target shape must be ≥20px long. If too short, the arrowhead overlaps the bend and looks broken. Fix by increasing node spacing or adding explicit waypoints
- **libavoid obstacle-avoiding routing (editor-side, draw.io ≥ 30):** draw.io has a newer connector router that recomputes edge paths to run *around* shapes (fanning out parallel edges) without moving any node. It runs interactively in the draw.io desktop editor (or via jgraph's MCP app-server `routing:"libavoid"`) — it is **not** a headless CLI flag. Passing `--layout libavoid` opens a modal `Unknown layout:` error dialog and hangs the run (jgraph's own drawio-mcp plugin docs claim the CLI flag works — verified hang on 30.2.6, don't trust it); the CLI `--layout` values are ELK *node* layout presets, a different thing (see `mermaid-authoring.md`). For CLI-authored files keep the orthogonal rules above; if a dense diagram still has crossings after export, open the `.drawio` in draw.io desktop once and let libavoid re-route. Don't stack it on an ELK `--layout` pass — pick one router, not both.
### Distributing connections on a shape
When multiple edges connect to the same shape, assign different entry/exit points to prevent stacking:
| Position | exitX/entryX | exitY/entryY | Use when |
| ---------- | ------------- | ------------- | ---------- |
| Top center | 0.5 | 0 | connecting to node above |
| Top-left | 0.25 | 0 | 2nd connection from top |
| Top-right | 0.75 | 0 | 3rd connection from top |
| Right center | 1 | 0.5 | connecting to node on right |
| Bottom center | 0.5 | 1 | connecting to node below |
| Left center | 0 | 0.5 | connecting to node on left |
**Rule:** if a shape has N connections on one side, space them evenly (e.g., 3 connections on bottom → exitX = 0.25, 0.5, 0.75)
### Color palette (fillColor / strokeColor)
*Used only when no user style preset is active (see `references/style-presets.md` → "Applying a preset").*
| Color name | fillColor | strokeColor | Use for |
| ----------- | ----------- | ------------- | --------- |
| Blue | `#dae8fc` | `#6c8ebf` | services, clients |
| Green | `#d5e8d4` | `#82b366` | success, databases |
| Yellow | `#fff2cc` | `#d6b656` | queues, decisions |
| Orange | `#ffe6cc` | `#d79b00` | gateways, APIs |
| Red/Pink | `#f8cecc` | `#b85450` | errors, alerts |
| Grey | `#f5f5f5` | `#666666` | external/neutral |
| Purple | `#e1d5e7` | `#9673a6` | security, auth |
### Legend (auto-generate from the palette)
When a diagram uses 3+ semantic colors, add a legend so the color coding is self-explanatory. Generate it mechanically from the roles actually present — never invent legend entries that aren't in the diagram:
```xml
<!-- Legend container: place in a corner clear of the diagram (e.g. below-left) -->
<mxCell id="legend" value="Legend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#666666;verticalAlign=top;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="40" y="720" width="180" height="110" as="geometry"/>
</mxCell>
<!-- One swatch + label pair per used role, 24px row pitch, children of the legend -->
<mxCell id="leg1" value="" style="rounded=0;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="legend">
<mxGeometry x="10" y="30" width="30" height="16" as="geometry"/>
</mxCell>
<mxCell id="leg1t" value="Service" style="text;html=1;align=left;verticalAlign=middle;" vertex="1" parent="legend">
<mxGeometry x="50" y="28" width="120" height="20" as="geometry"/>
</mxCell>
```
Rules: swatch colors come from the active palette (preset or the table above) with the **role name** as the label (Service, Database, Queue, …); height = `30 + 24 × rows`; the legend is a container (`parent="legend"`, relative coordinates); skip it entirely for single-color diagrams.
### Layout tips
**Spacing — scale with complexity:**
| Diagram complexity | Nodes | Horizontal gap | Vertical gap |
| ------------------- | ------- | ---------------- | -------------- |
| Simple | ≤5 | 200px | 150px |
| Medium | 6–10 | 280px | 200px |
| Complex | >10 | 350px | 250px |
**Routing corridors:** between shape rows/columns, leave an extra ~80px empty corridor where edges can route without crossing shapes. Never place a shape in a gap that edges need to traverse.
**Grid alignment:** snap all `x`, `y`, `width`, `height` values to **multiples of 10** — this ensures shapes align cleanly on draw.io's default grid and makes manual editing easier.
**General rules:**
- Plan a grid before assigning x/y coordinates — sketch node positions on paper/mentally first
- Group related nodes in the same horizontal or vertical band
- Use `swimlane` cells for logical grouping with visible borders
- Place heavily-connected "hub" nodes centrally so edges radiate outward instead of crossing
- To force straight vertical connections, pin entry/exit points explicitly on edges:
`exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0`
- Always center-align a child node under its parent (same center x) to avoid diagonal routing
- **Event bus pattern**: place Kafka/bus nodes in the **center of the service row**, not below — services on either side can reach it with short horizontal arrows (`exitX=1` left side, `exitX=0` right side), eliminating all line crossings
- Horizontal connections (`exitX=1` or `exitX=0`) never cross vertical nodes in the same row; use them for peer-to-peer and publish connections
**Avoiding edge-shape overlap:**
- Before finalizing coordinates, trace each edge path mentally — if it must cross an unrelated shape, either move the shape or add waypoints
- For tree/hierarchical layouts: assign nodes to layers (rows), connect only between adjacent layers to minimize crossings
- For star/hub layouts: place the hub center, satellites around it — edges stay short and radial
- When an edge must span multiple rows/columns, route it along the outer corridor, not through the middle of the diagram
scripts/aiicons.py
#!/usr/bin/env python3
"""Find AI / LLM brand logos (OpenAI, Claude, Gemini, ...) as draw.io styles.
draw.io's bundled shape libraries have no modern AI/LLM brand logos, so an
"LLM app architecture" renders as generic boxes. This resolves a brand name to a
draw.io `image` style that references the matching SVG from the lobe-icons set
(https://github.com/lobehub/lobe-icons, MIT) on the unpkg CDN.
python3 aiicons.py "openai"
python3 aiicons.py "claude" --json
python3 aiicons.py "langchain" --variant mono --size 48
The icon is referenced by URL (data/lobe-icons.json carries only the name list,
not the assets), so draw.io fetches it from the CDN when the diagram is rendered
or opened. That means **network is required at render time**; an offline export
draws a blank box. Use --embed to fetch the SVG once and inline it as a
self-contained data URI instead (portable, no network at render time).
The logos are trademarks of their respective owners and are referenced here for
identification only — the same basis on which draw.io ships AWS/Azure icons.
Usage: python3 aiicons.py <query> [--limit N] [--variant color|mono|text]
[--size PX] [--embed] [--json] [--list]
"""
import argparse
import base64
import json
import os
import re
import sys
import urllib.request
MANIFEST = os.path.join(os.path.dirname(__file__), "..", "data", "lobe-icons.json")
STYLE = ("shape=image;html=1;imageAspect=0;aspect=fixed;"
"verticalLabelPosition=bottom;verticalAlign=top;image=")
_VARIANT = re.compile(r"-(?:color|text(?:-[a-z]{2})?|brand(?:-color)?)$")
# Common RAG/LLM data stores that lobe-icons lacks, mapped to simple-icons
# slugs (https://simpleicons.org, CC0). Served from the simple-icons CDN. Each
# slug below is verified to return HTTP 200 at https://cdn.simpleicons.org/<slug>.
_SIMPLEICONS_CDN = "https://cdn.simpleicons.org/"
_SUPPLEMENT = {
"qdrant": "qdrant",
"milvus": "milvus",
"supabase": "supabase",
"redis": "redis",
"postgresql": "postgresql",
"mongodb": "mongodb",
"elasticsearch": "elasticsearch",
"neo4j": "neo4j",
"kafka": "apachekafka",
"clickhouse": "clickhouse",
"duckdb": "duckdb",
"mysql": "mysql",
"sqlite": "sqlite",
"cassandra": "apachecassandra",
"snowflake": "snowflake",
"databricks": "databricks",
"mariadb": "mariadb",
"couchbase": "couchbase",
}
def families(icons):
"""base brand name -> set of its variant filenames (without .svg)."""
fam = {}
for name in icons:
base = _VARIANT.sub("", name)
fam.setdefault(base, set()).add(name)
return fam
def squish(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
def search(fam, query, limit):
"""Rank brand bases against the query (squished + per-token matching)."""
q = squish(query)
tokens = [t for t in re.findall(r"[a-z0-9]+", query.lower()) if t]
scored = {}
for base in fam:
b = squish(base)
s = 0
if q and q == b:
s = 100
elif q and b.startswith(q):
s = 60
elif q and q in b:
s = 40
for t in tokens:
if t == b:
s = max(s, 90)
elif len(t) >= 3 and b.startswith(t):
s = max(s, 50)
elif len(t) >= 3 and t in b:
s = max(s, 30)
if s:
scored[base] = s
return sorted(scored, key=lambda base: (-scored[base], base))[:limit]
def search_supplement(query):
"""Fall back to the simple-icons supplement (exact or substring match)."""
q = squish(query)
if not q:
return None
if q in _SUPPLEMENT:
return q
for brand in _SUPPLEMENT:
if q in brand or brand in q:
return brand
return None
def pick_variant(base, variants, prefer):
order = {"color": ["-color", "-brand-color", "", "-brand", "-text", "-text-cn"],
"mono": ["", "-brand", "-color", "-brand-color", "-text", "-text-cn"],
"text": ["-text", "-text-cn", "-brand", "-brand-color", "-color", ""]}[prefer]
for suffix in order:
cand = base + suffix
if cand in variants:
return cand
return next(iter(sorted(variants)), None)
def main():
ap = argparse.ArgumentParser(description="Find AI/LLM brand logos as draw.io styles (lobe-icons via CDN).")
ap.add_argument("query", nargs="?", help='brand name, e.g. "openai" or "claude"')
ap.add_argument("--limit", type=int, default=8)
ap.add_argument("--variant", choices=["color", "mono", "text"], default="color")
ap.add_argument("--size", type=int, default=48, help="cell width/height in px (icons are square)")
ap.add_argument("--embed", action="store_true",
help="inline the SVG as a data URI (fetches it now; portable, no network at render time)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--list", action="store_true", help="list all brand names and exit")
args = ap.parse_args()
if not os.path.exists(MANIFEST):
sys.exit(f"error: manifest not found at {MANIFEST}")
manifest = json.load(open(MANIFEST, encoding="utf-8"))
fam = families(manifest["icons"])
cdn = manifest["cdn"]
if args.list:
for base in sorted(fam):
print(base)
return
if not args.query:
ap.error("a query is required (or use --list)")
matches = search(fam, args.query, args.limit)
results = []
if matches:
for base in matches:
file = pick_variant(base, fam[base], args.variant)
url = f"{cdn}{file}.svg"
if args.embed:
try:
svg = urllib.request.urlopen(url, timeout=15).read()
except Exception as exc: # noqa: BLE001 - report and skip
sys.stderr.write(f"warning: could not fetch {url} ({exc})\n")
continue
# Rewrite the 1em intrinsic size so draw.io scales the inlined SVG.
svg = svg.replace(b'width="1em"', b'width="24"').replace(b'height="1em"', b'height="24"')
# Marker-less base64: draw.io splits style values on ';', so a
# ';base64,' marker would truncate the image= value (issue #80).
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
else:
image = url
results.append({"brand": base, "file": file, "w": args.size, "h": args.size,
"style": STYLE + image})
else:
# lobe has no logo for this brand; fall back to the simple-icons supplement.
brand = search_supplement(args.query)
if brand:
slug = _SUPPLEMENT[brand]
url = _SIMPLEICONS_CDN + slug
image = url
if args.embed:
try:
svg = urllib.request.urlopen(url, timeout=15).read()
# Marker-less base64 (see issue #80 note above).
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
except Exception as exc: # noqa: BLE001 - keep the CDN URL
sys.stderr.write(f"warning: could not fetch {url} ({exc}); using CDN URL\n")
results.append({"brand": brand, "file": f"simpleicons:{slug}",
"w": args.size, "h": args.size, "style": STYLE + image})
if not results:
sys.exit(f"no logo for {args.query!r} — for a data store try a cylinder "
f"(shape=cylinder3) or shapesearch.py '{args.query} database'")
if args.json:
print(json.dumps(results, indent=2, ensure_ascii=False))
else:
for r in results:
shown = r["style"] if len(r["style"]) < 160 else r["style"][:157] + "..."
print(f"{r['brand']} ({r['file']}, {r['w']}x{r['h']})\n {shown}")
if __name__ == "__main__":
main()
scripts/autolayout.py
#!/usr/bin/env python3
"""Auto-layout a logical graph into draw.io XML using Graphviz.
Minimal layout pass for the drawio skill: takes a graph (nodes + edges as
JSON), runs `dot` to position the nodes, and emits a .drawio file with the
mxGeometry x/y filled in. draw.io routes the edges itself (orthogonal style).
This removes the manual-coordinate ceiling for medium/large diagrams.
Input JSON:
{
"direction": "TB", # TB (top-bottom, default) or LR (left-right)
"nodes": [
{"id": "a", "label": "Service A", "style": "rounded=1;...",
"width": 120, "height": 60}
],
"edges": [
{"source": "a", "target": "b", "label": "calls"}
]
}
Only "id" is required per node; label defaults to id and style/width/height
have defaults. Node ids must be unique and must not be "0" or "1" (reserved
for the draw.io root cells). Requires Graphviz `dot` on PATH.
Usage: python3 autolayout.py graph.json [-o diagram.drawio]
"""
import argparse
import json
import os
import shlex
import subprocess
import sys
from xml.sax.saxutils import escape
DEFAULT_W, DEFAULT_H = 120, 60
NODE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
EDGE_STYLE = "html=1;rounded=0;"
GROUP_STYLE = ("rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#999999;"
"verticalAlign=top;fontStyle=2;dashed=1;")
# Group colours come from the skill's own palette (styles/built-in/default.json)
# so there is a single source of truth, not a second list baked in here. When a
# grouped graph is laid out, each top-level group takes the next colour (cycled
# in a fixed, harmonious role order) so related modules read as a coloured
# cluster. Nodes that carry their own `style` keep it; only styleless grouped
# nodes are tinted. Disable with --mono.
_PALETTE_ORDER = ["primary", "success", "accent", "secondary", "warning", "danger", "neutral"]
_PALETTE_FILE = os.path.join(os.path.dirname(__file__), "..", "styles", "built-in", "default.json")
_FALLBACK_PALETTE = [("#dae8fc", "#6c8ebf"), ("#d5e8d4", "#82b366"), ("#ffe6cc", "#d79b00"),
("#e1d5e7", "#9673a6"), ("#fff2cc", "#d6b656"), ("#f8cecc", "#b85450")]
def load_palette():
"""Ordered (fill, stroke) list from the default preset's palette; fall back
to the same colours inline if the preset file can't be read."""
try:
with open(_PALETTE_FILE, encoding="utf-8") as fh:
pal = json.load(fh)["palette"]
colors = [(pal[r]["fillColor"], pal[r]["strokeColor"]) for r in _PALETTE_ORDER if r in pal]
if colors:
return colors
except (OSError, KeyError, ValueError):
pass
return _FALLBACK_PALETTE
PALETTE = load_palette()
# Uniform container padding; the title sits in the top pad (verticalAlign=top).
# dot's cluster margin is set to this same value so each container box equals
# dot's cluster box — which dot guarantees never overlaps, at any nesting depth.
GROUP_PAD = 24
def attr(value):
# Newlines in labels become 
 so draw.io renders a line break (a raw
# newline inside an XML attribute is normalized to a space by parsers).
return escape(str(value), {'"': """, "\n": "
"})
def dot_quote(value):
# Wrap as a DOT double-quoted string, escaping backslash and quote so ids
# with those characters can't corrupt the Graphviz input.
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def snap(value, grid=10):
# Align to the grid the skill uses everywhere (multiples of 10).
return int(round(value / grid) * grid)
def group_tree(nodes):
"""Parse hierarchical `group` paths ("a/b") into a container tree.
Returns (gpath, direct, children, ordered):
gpath[node_id] = tuple of path segments (the node's deepest container)
direct[path] = node ids whose group is exactly this path
children[path] = child container paths
ordered = all container paths, shallow-to-deep (stable)
"""
gpath, direct, paths = {}, {}, set()
for node in nodes:
g = node.get("group")
if g is None or str(g).strip("/") == "":
continue
t = tuple(str(g).strip("/").split("/"))
gpath[node["id"]] = t
direct.setdefault(t, []).append(node["id"])
for k in range(1, len(t) + 1):
paths.add(t[:k])
children = {}
for p in sorted(paths):
if len(p) > 1:
children.setdefault(p[:-1], []).append(p)
ordered = sorted(paths, key=lambda p: (len(p), p))
return gpath, direct, children, ordered
def build_dot(graph):
rankdir = "LR" if str(graph.get("direction", "TB")).upper() == "LR" else "TB"
# Optional graph-level spacing (inches). Icon nodes render their label below
# the shape, so importers emitting icons ask for extra rank/node separation.
sep = "".join(f" {k}={float(graph[k]):.2f};" for k in ("ranksep", "nodesep") if k in graph)
# splines=ortho makes dot route edges as orthogonal polylines; we replay
# those bends as draw.io waypoints so edges go around nodes, not through them.
lines = [f"digraph G {{ rankdir={rankdir};{sep} splines=ortho; node [shape=box fixedsize=true];"]
# Group nodes into (possibly nested) clusters so dot keeps each group
# together; a node's first appearance fixes its cluster, so list members
# before the size attributes. The cluster margin reserves room for the
# padded container boxes we draw below (extra on Y for the title strip) so
# neighbouring boxes do not overlap.
_, direct, children, ordered = group_tree(graph["nodes"])
cidx = {p: i for i, p in enumerate(ordered)}
def emit_cluster(p, pad):
lines.append(f'{pad}subgraph cluster_{cidx[p]} {{ margin={GROUP_PAD};')
for c in children.get(p, []):
emit_cluster(c, pad + " ")
lines.extend(f'{pad} {dot_quote(m)};' for m in direct.get(p, []))
lines.append(pad + "}")
for root in [p for p in ordered if len(p) == 1]:
emit_cluster(root, "")
for node in graph["nodes"]:
# Pass our pixel sizes to dot as inches so it lays out at the real size.
w = node.get("width", DEFAULT_W) / 72.0
h = node.get("height", DEFAULT_H) / 72.0
lines.append(f'{dot_quote(node["id"])} [width={w:.4f} height={h:.4f}];')
for edge in graph.get("edges", []):
lines.append(f'{dot_quote(edge["source"])} -> {dot_quote(edge["target"])};')
lines.append("}")
return "\n".join(lines)
def layout(dot_src):
"""Run `dot -Tplain`; return (height_in, {id: (xc, yc)}, {(src, dst): [(x, y), ...]}).
Node coords are inches (bottom-left origin); each edge's value is the list
of orthogonal control points dot computed for routing, endpoints included.
"""
try:
proc = subprocess.run(
["dot", "-Tplain"], input=dot_src,
capture_output=True, text=True, check=True,
)
except FileNotFoundError:
sys.exit("error: Graphviz `dot` not found on PATH (brew install graphviz)")
except subprocess.CalledProcessError as exc:
sys.exit(f"error: dot failed: {exc.stderr.strip()}")
height, pos, edges = 0.0, {}, {}
for line in proc.stdout.splitlines():
tok = shlex.split(line)
if not tok:
continue
if tok[0] == "graph":
height = float(tok[3]) # graph scale width height
elif tok[0] == "node":
pos[tok[1]] = (float(tok[2]), float(tok[3])) # node name x y ...
elif tok[0] == "edge": # edge tail head n x1 y1 ... xn yn
n = int(tok[3])
edges[(tok[1], tok[2])] = [
(float(tok[4 + 2 * i]), float(tok[5 + 2 * i])) for i in range(n)
]
return height, pos, edges
def group_style(stroke):
"""Container box styled with a group's colour (coloured border + title)."""
return (f"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor={stroke};"
f"fontColor={stroke};verticalAlign=top;fontStyle=2;dashed=1;")
def page_cells(graph, height, pos, edge_pts, color=True):
"""The <root> child cells (everything after the two reserved cells) for one
laid-out graph — reusable by multi-page generators (c4.py)."""
nodes = graph["nodes"]
# Absolute snapped rect for every placed node.
rects = {}
for node in nodes:
nid = node["id"]
if nid not in pos:
continue
w, h = node.get("width", DEFAULT_W), node.get("height", DEFAULT_H)
xc, yc = pos[nid]
x = snap(xc * 72 - w / 2)
y = snap((height - yc) * 72 - h / 2) # flip: dot origin is bottom-left
rects[nid] = (x, y, w, h)
# Parse the (possibly nested) group tree and assign each container a
# collision-free id and a title (the path's last segment, or a member's groupLabel).
gpath, direct, children, ordered = group_tree(nodes)
# Assign each top-level group a palette colour, in order of first appearance.
top_order = []
for node in nodes:
t = gpath.get(node["id"])
if t and t[0] not in top_order:
top_order.append(t[0])
def gcolor(seg):
return PALETTE[top_order.index(seg) % len(PALETTE)]
used = {n["id"] for n in nodes}
label_override = {}
for node in nodes:
if node["id"] in gpath and "groupLabel" in node:
label_override.setdefault(gpath[node["id"]], str(node["groupLabel"]))
gid, glabel = {}, {}
for i, p in enumerate(ordered):
cid = f"group_{i}"
while cid in used: # never collide with a node id
cid += "_"
used.add(cid)
gid[p] = cid
glabel[p] = label_override.get(p, p[-1])
# Container bounding box (members + nested children + uniform padding),
# computed deepest-first so a parent can wrap its already-sized children.
gbox = {}
for p in sorted(ordered, key=len, reverse=True):
xs = [(rects[m][0], rects[m][1], rects[m][0] + rects[m][2], rects[m][1] + rects[m][3])
for m in direct.get(p, []) if m in rects]
xs += [(gbox[c][0], gbox[c][1], gbox[c][0] + gbox[c][2], gbox[c][1] + gbox[c][3])
for c in children.get(p, []) if c in gbox]
if not xs:
continue
x0 = min(b[0] for b in xs) - GROUP_PAD
y0 = min(b[1] for b in xs) - GROUP_PAD
x1 = max(b[2] for b in xs) + GROUP_PAD
y1 = max(b[3] for b in xs) + GROUP_PAD
gbox[p] = (x0, y0, x1 - x0, y1 - y0)
# Shift everything positive: a container's top padding can push its top edge
# above the page origin. Only translates when something would be negative.
absx = [r[0] for r in rects.values()] + [b[0] for b in gbox.values()]
absy = [r[1] for r in rects.values()] + [b[1] for b in gbox.values()]
dx = GROUP_PAD - min(absx) if absx and min(absx) < 0 else 0
dy = GROUP_PAD - min(absy) if absy and min(absy) < 0 else 0
def rebase(x, y, parent_path):
"""Absolute -> coordinates relative to parent_path's box (or shifted if top-level)."""
if parent_path is None:
return x + dx, y + dy, "1"
px, py, _, _ = gbox[parent_path]
return x - px, y - py, gid[parent_path]
cells = []
# Containers shallow-first so each parent precedes its children.
for p in ordered:
if p not in gbox:
continue
gx, gy, gw, gh = gbox[p]
x, y, parent = rebase(gx, gy, p[:-1] if len(p) > 1 else None)
gstyle = group_style(gcolor(p[0])[1]) if color else GROUP_STYLE
cells.append(
f' <mxCell id="{attr(gid[p])}" value="{attr(glabel[p])}" '
f'style="{gstyle}" vertex="1" parent="{attr(parent)}">\n'
f' <mxGeometry x="{x}" y="{y}" width="{gw}" height="{gh}" as="geometry"/>\n'
f" </mxCell>"
)
for node in nodes:
nid = node["id"]
if nid not in rects:
continue
rx, ry, w, h = rects[nid]
x, y, parent = rebase(rx, ry, gpath.get(nid) if gpath.get(nid) in gbox else None)
if node.get("style"):
style = node["style"] # explicit style always wins
elif color and nid in gpath:
fill, stroke = gcolor(gpath[nid][0]) # tint styleless nodes by group
style = f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};strokeColor={stroke};"
else:
style = NODE_STYLE
body = (f'style="{attr(style)}" vertex="1" parent="{attr(parent)}">\n'
f' <mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" as="geometry"/>\n'
f" </mxCell>")
if node.get("link"):
# Links ride on a UserObject wrapper (id + label move to it).
cells.append(
f' <UserObject label="{attr(node.get("label", nid))}" '
f'link="{attr(node["link"])}" id="{attr(nid)}">\n'
f" <mxCell " + body + "\n </UserObject>")
else:
cells.append(
f' <mxCell id="{attr(nid)}" value="{attr(node.get("label", nid))}" '
+ body)
for i, edge in enumerate(graph.get("edges", [])):
# Drop the first/last points (they sit on the node borders, where
# draw.io attaches anyway) and replay the interior bends as waypoints.
interior = edge_pts.get((edge["source"], edge["target"]), [])[1:-1]
if interior:
points = "".join(
f'<mxPoint x="{snap(x * 72) + dx}" y="{snap((height - y) * 72) + dy}"/>'
for x, y in interior
)
geom = (f'<mxGeometry relative="1" as="geometry">'
f'<Array as="points">{points}</Array></mxGeometry>')
else:
geom = '<mxGeometry relative="1" as="geometry"/>'
cells.append(
f' <mxCell id="e{i}" value="{attr(edge.get("label", ""))}" '
f'style="{attr(edge.get("style", EDGE_STYLE))}" edge="1" parent="1" '
f'source="{attr(edge["source"])}" target="{attr(edge["target"])}">\n'
f" {geom}\n"
f" </mxCell>"
)
return "\n".join(cells)
def wrap_page(cells, page_id="autolayout", name="Page-1"):
"""One <diagram> page around pre-rendered root cells."""
return (
f' <diagram id="{attr(page_id)}" name="{attr(name)}">\n'
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
" <root>\n"
' <mxCell id="0"/>\n'
' <mxCell id="1" parent="0"/>\n'
+ cells
+ "\n </root>\n </mxGraphModel>\n </diagram>\n"
)
def to_drawio(graph, height, pos, edge_pts, color=True):
return ("<mxfile>\n"
+ wrap_page(page_cells(graph, height, pos, edge_pts, color=color))
+ "</mxfile>\n")
def route_score(graph, height, pos, edge_pts):
"""Readability score for one dot layout (lower is better): weighted count
of edge-through-vertex hits and edge-edge crossings, with total edge length
as a tiebreak. Uses the same geometry predicates as validate.py."""
import importlib.util
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "validate.py")
spec = importlib.util.spec_from_file_location("validate", path)
v = importlib.util.module_from_spec(spec)
spec.loader.exec_module(v)
rects = {}
for node in graph["nodes"]:
if node["id"] in pos:
w, h = node.get("width", DEFAULT_W), node.get("height", DEFAULT_H)
xc, yc = pos[node["id"]]
rects[node["id"]] = (xc * 72 - w / 2, (height - yc) * 72 - h / 2, w, h)
routes = []
for edge in graph.get("edges", []):
pts = edge_pts.get((edge["source"], edge["target"]))
if pts:
routes.append(([(x * 72, (height - y) * 72) for x, y in pts],
{edge["source"], edge["target"]}))
through = sum(1 for pts, ends in routes for nid, box in rects.items()
if nid not in ends and v.route_hits_rect(pts, box))
cross = sum(1 for i in range(len(routes)) for j in range(i + 1, len(routes))
if v.routes_cross(routes[i][0], routes[j][0]))
length = sum(abs(b[0] - a[0]) + abs(b[1] - a[1])
for pts, _ in routes for a, b in zip(pts, pts[1:]))
return 20 * through + 10 * cross + length / 100000
def main():
ap = argparse.ArgumentParser(description="Auto-layout a graph JSON into draw.io XML.")
ap.add_argument("input", help="graph JSON file")
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
ap.add_argument("--mono", action="store_true",
help="don't colour groups by palette (monochrome boxes)")
ap.add_argument("--tune", action="store_true",
help="lay out in both directions (TB and LR), keep the more "
"readable one (fewer crossings / through-vertex routes)")
args = ap.parse_args()
with open(args.input, encoding="utf-8") as f:
graph = json.load(f)
if args.tune:
best = None
for d in ("TB", "LR"):
cand = dict(graph, direction=d)
h, p, ep = layout(build_dot(cand))
s = route_score(cand, h, p, ep)
if best is None or s < best[0]:
best = (s, d, h, p, ep)
_, d, height, pos, edge_pts = best
print(f"tuned: direction={d} (score {best[0]:.2f})", file=sys.stderr)
else:
height, pos, edge_pts = layout(build_dot(graph))
xml = to_drawio(graph, height, pos, edge_pts, color=not args.mono)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
print(f"wrote {args.output} ({len(graph['nodes'])} nodes, "
f"{len(graph.get('edges', []))} edges)", file=sys.stderr)
else:
sys.stdout.write(xml)
if __name__ == "__main__":
main()
scripts/buildup.py
#!/usr/bin/env python3
"""Animate a static .drawio building itself, node by node -> HTML player.
Reveals a diagram's cells incrementally in dependency order — topological over
its edges, so a source always appears before the targets it points to, with
ties (and any leftover cycle members) falling back to document order — and
assembles a self-contained HTML player (base64-embedded PNG frames, play /
pause / step / scrub) of the diagram constructing itself, like a build
time-lapse.
python3 buildup.py architecture.drawio
# -> architecture.drawio's directory / buildup.html
python3 buildup.py architecture.drawio -o build.html --gif build.gif
Each frame is a temp copy of the diagram with not-yet-revealed cells removed
from <root> (not opacity — draw.io ignores that on headless export). An edge
is only shown once BOTH its endpoints are revealed. Container/group cells are
always shown (only leaf vertices and the edges between them build up one step
at a time). The page size is pinned to the FULL diagram's bounding box on
every frame so nothing jumps around as cells appear. Needs the draw.io CLI;
`--gif` additionally needs Pillow (skipped with a warning if absent — the
HTML is written regardless).
Usage: python3 buildup.py <file.drawio> [-o out.html] [--gif out.gif]
[--fps N] [--hold N] [--keep-frames]
"""
import argparse
import base64
import copy
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
def parse_page(path):
"""First page of a .drawio -> (tree, cells).
cells: list of dicts {id, el, vertex, edge, parent, source, target, style,
relative, x, y, w, h} in document order. `el` is the TOP-LEVEL <root>
child (mxCell / UserObject / object) so it can be removed directly;
vertex/edge/geometry attributes are read off the inner mxCell for wrapped
cells (UserObject/object), same unwrapping as drawiodiff.parse().
"""
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
pages = tree.getroot().findall("diagram")
if not pages:
sys.exit(f"error: no <diagram> pages in {path}")
if len(pages) > 1:
sys.stderr.write(f"warning: {path} has {len(pages)} pages, animating the first only\n")
model = pages[0].find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
sys.exit(f"error: {path}: page is compressed, cannot buildup")
cells = []
for el in root:
inner = el if el.tag == "mxCell" else el.find("mxCell")
if inner is None:
continue
g = inner.find("mxGeometry")
relative = g is not None and g.get("relative") == "1"
if g is not None and not relative and g.get("x") is not None and g.get("width") is not None:
x, y = float(g.get("x")), float(g.get("y", 0))
w, h = float(g.get("width")), float(g.get("height", 0))
else:
x = y = w = h = None
cells.append({
"id": el.get("id"), "el": el,
"vertex": inner.get("vertex") == "1", "edge": inner.get("edge") == "1",
"parent": inner.get("parent"), "source": inner.get("source"),
"target": inner.get("target"), "style": inner.get("style") or "",
"relative": relative, "x": x, "y": y, "w": w, "h": h,
})
return tree, cells
def classify(cells):
"""cells -> (leaf_vertex_ids in doc order, container_ids, edges[(id,source,target)]).
Mirrors drawiodiff.parse(): a vertex that is some other cell's `parent` is
a container/group (always shown, never an individual reveal step); an
edge-label sub-cell (relative geometry or an `edgeLabel` style) is neither
a node nor revealed on its own — it rides along once its parent edge is.
"""
parents = {c["parent"] for c in cells if c["parent"]}
leaves, containers, edges = [], set(), []
for c in cells:
if c["edge"]:
if c["source"] and c["target"]:
edges.append((c["id"], c["source"], c["target"]))
elif c["vertex"]:
if c["relative"] or "edgeLabel" in c["style"]:
continue
if c["id"] in parents:
containers.add(c["id"])
else:
leaves.append(c["id"])
return leaves, containers, edges
def bounding_box(cells, margin=40):
"""(width, height) of the full diagram from every absolute cell geometry,
with a margin — used to pin pageWidth/pageHeight so frames don't jump."""
xs = [c["x"] + c["w"] for c in cells if c["x"] is not None]
ys = [c["y"] + c["h"] for c in cells if c["y"] is not None]
if not xs or not ys:
return 850, 1100
return int(max(xs)) + margin, int(max(ys)) + margin
def reveal_order(node_ids, edges):
"""Kahn topological order over node_ids given directed (source, target)
edges. Ties among ready nodes, and any nodes left over from a cycle, fall
back to document order (node_ids' input order)."""
doc = list(dict.fromkeys(node_ids)) # de-dup, keep doc order
idx = {nid: i for i, nid in enumerate(doc)}
adj = {nid: [] for nid in doc}
indeg = {nid: 0 for nid in doc}
for s, t in edges:
if s in idx and t in idx and s != t:
adj[s].append(t)
indeg[t] += 1
import heapq
ready = list({idx[n] for n in doc if indeg[n] == 0})
heapq.heapify(ready)
order, seen = [], set()
while ready:
nid = doc[heapq.heappop(ready)]
seen.add(nid)
order.append(nid)
for nxt in adj[nid]:
indeg[nxt] -= 1
if indeg[nxt] == 0:
heapq.heappush(ready, idx[nxt])
for nid in doc: # cycle remnants, document order
if nid not in seen:
order.append(nid)
return order
def reveal_steps(node_order, edges):
"""-> (node_step {id: int}, edge_step {edge_id: int}). An edge's step is
the LATER of its two endpoints' steps, so it only appears once both are
revealed (endpoints outside node_order, e.g. a container, count as step 0
— already shown)."""
node_step = {nid: i for i, nid in enumerate(node_order)}
edge_step = {eid: max(node_step.get(s, 0), node_step.get(t, 0)) for eid, s, t in edges}
return node_step, edge_step
def label_of(el):
"""Visible text of a root child (mxCell or UserObject/object wrapper)."""
if el.tag == "mxCell":
return el.get("value") or el.get("id") or ""
return el.get("label") or el.get("value") or el.get("id") or ""
def build_html(frames, title):
"""Self-contained HTML player. frames: [(png_bytes, label, step, total)]."""
data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(),
"label": label, "step": step, "total": total}
for png, label, step, total in frames]
payload = json.dumps(data).replace("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
padding:12px 4px 6px;color:#556;font-size:13px}}
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
.cap .lbl{{color:#1a1a1a;font-weight:600}}
@media(prefers-color-scheme:dark){{.cap .lbl{{color:#e8e8e8}}}}
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
button:hover{{border-color:#0d99ff}}
input[type=range]{{flex:1;accent-color:#0d99ff}}
</style></head><body>
<header><h1>{title}</h1></header>
<main>
<div id="stage"><img id="img" alt="build-up frame"></div>
<div class="cap">
<span><b id="idx"></b></span>
<span>+ <span class="lbl" id="label"></span></span>
</div>
<div class="barwrap"><div class="bar" id="bar"></div></div>
<div class="ctl">
<button id="prev">‹ Prev</button>
<button id="play">▶ Play</button>
<button id="next">Next ›</button>
<input type="range" id="scrub" min="0" value="0">
</div>
</main>
<script>
const F={payload};
let i=0,timer=null;
const $=id=>document.getElementById(id);
$("scrub").max=F.length-1;
function show(k){{
i=(k+F.length)%F.length;const f=F[i];
$("img").src=f.img;$("idx").textContent=`Step ${{f.step}} / ${{f.total}}`;
$("label").textContent=f.label;
$("bar").style.width=(6+94*f.step/f.total)+"%";$("scrub").value=i;
}}
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
$("prev").onclick=()=>{{stop();show(i-1);}};
$("next").onclick=()=>{{stop();show(i+1);}};
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
$("play").onclick=()=>{{
if(timer){{stop();return;}}
$("play").textContent="⏸ Pause";
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},700);
}};
show(0);
</script></body></html>"""
def make_gif(pngs, out_path, fps, hold):
"""Assemble PNG frame bytes into an animated GIF via Pillow. Skips with a
stderr warning (not fatal) if Pillow isn't installed."""
try:
from PIL import Image
except ImportError:
sys.stderr.write("warning: Pillow not installed, skipping --gif (pip install Pillow)\n")
return
frames = [Image.open(io.BytesIO(p)).convert("RGB") for p in pngs]
duration = [int(1000 / fps)] * (len(frames) - 1) + [int(hold * 1000)]
frames[0].save(out_path, save_all=True, append_images=frames[1:],
duration=duration, loop=0)
sys.stderr.write(f"wrote {out_path} ({len(frames)} frames)\n")
def main():
ap = argparse.ArgumentParser(description="Animate a .drawio building itself -> self-contained HTML player.")
ap.add_argument("file", help="input .drawio (uncompressed)")
ap.add_argument("-o", "--output", help="output .html (default: buildup.html alongside input)")
ap.add_argument("--gif", help="also assemble frames into an animated GIF (needs Pillow)")
ap.add_argument("--fps", type=float, default=2.0, help="GIF frames per second (default 2)")
ap.add_argument("--hold", type=float, default=1.5, help="seconds to hold the final GIF frame")
ap.add_argument("--keep-frames", action="store_true", help="also write the PNG frames next to the output")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
if not shutil.which("drawio"):
sys.exit("error: draw.io CLI not found on PATH (is the draw.io CLI installed?)")
tree, cells = parse_page(args.file)
leaves, containers, edge_list = classify(cells)
if not leaves:
sys.exit(f"error: no revealable vertices found in {args.file}")
order = reveal_order(leaves, [(s, t) for _, s, t in edge_list])
node_step, edge_step = reveal_steps(order, edge_list)
width, height = bounding_box(cells)
labels = {c["id"]: label_of(c["el"]) for c in cells}
n_total = len(order)
out = args.output or os.path.join(
os.path.dirname(os.path.abspath(args.file)) or ".", "buildup.html")
frames = []
with tempfile.TemporaryDirectory() as tmp:
for k in range(n_total):
revealed_nodes = set(order[:k + 1])
revealed_edges = {eid for eid, _, _ in edge_list if edge_step[eid] <= k}
keep = {"0", "1"} | containers | revealed_nodes | revealed_edges
keep |= {c["id"] for c in cells if c["id"] not in keep and c["parent"] in keep}
frame_tree = copy.deepcopy(tree)
model = frame_tree.getroot().find("diagram").find("mxGraphModel")
model.set("pageWidth", str(width))
model.set("pageHeight", str(height))
froot = model.find("root")
for child in list(froot):
if child.get("id") not in keep:
froot.remove(child)
src = os.path.join(tmp, f"step{k:03d}.drawio")
frame_tree.write(src, encoding="utf-8", xml_declaration=False)
png_path = os.path.join(tmp, f"step{k:03d}.png")
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", "1",
"--width", "2000", "-o", png_path, src], capture_output=True)
if r.returncode != 0 or not os.path.exists(png_path):
sys.stderr.write(f"warning: step {k + 1}/{n_total} export failed — skipped\n")
continue
with open(png_path, "rb") as f:
png = f.read()
label = labels.get(order[k], order[k])
frames.append((png, label, k + 1, n_total))
if args.keep_frames:
with open(f"{os.path.splitext(out)[0]}-frame{k + 1:03d}.png", "wb") as f:
f.write(png)
sys.stderr.write(f"[{k + 1}/{n_total}] revealed {label!r}\n")
if not frames:
sys.exit("error: no frames exported (is the draw.io CLI installed?)")
title = os.path.splitext(os.path.basename(args.file))[0] + " — build-up"
with open(out, "w", encoding="utf-8") as f:
f.write(build_html(frames, title))
sys.stderr.write(f"wrote {out} ({len(frames)} frames)\n")
if args.gif:
make_gif([f[0] for f in frames], args.gif, args.fps, args.hold)
if __name__ == "__main__":
main()
scripts/c4.py
#!/usr/bin/env python3
"""C4 model diagrams: levels JSON -> one multi-page .drawio with drill-down.
Generates a C4 architecture diagram set (System Context -> Containers ->
Components, as many levels as you define) in a single `.drawio` file: one
page per level, official draw.io C4 shapes and colors, Graphviz placement
per page (via autolayout), and **drill-down links** — an element with a
`"children"` key becomes clickable and jumps to that level's page in
draw.io / the diagrams.net viewer.
python3 c4.py c4.json -o architecture.drawio
Input JSON:
{
"title": "Internet Banking",
"levels": [
{
"name": "System Context",
"elements": [
{"id": "customer", "type": "person", "label": "Personal Customer",
"desc": "A customer of the bank"},
{"id": "ibs", "type": "system", "label": "Internet Banking System",
"desc": "Lets customers manage accounts", "children": "Containers"},
{"id": "email", "type": "external", "label": "E-mail System",
"desc": "Microsoft Exchange"}
],
"relations": [
{"from": "customer", "to": "ibs", "label": "Uses"},
{"from": "ibs", "to": "email", "label": "Sends e-mail via"}
]
},
{
"name": "Containers",
"elements": [
{"id": "spa", "type": "container", "label": "Single-Page App",
"tech": "React", "desc": "Banking UI in the browser"},
{"id": "api", "type": "container", "label": "API Application",
"tech": "Java/Spring", "children": "Components"},
{"id": "db", "type": "database", "label": "Database",
"tech": "PostgreSQL"}
],
"relations": [
{"from": "spa", "to": "api", "label": "JSON/HTTPS"},
{"from": "api", "to": "db", "label": "JDBC"}
]
}
]
}
Element types: person, system, external (greyed external system), container,
component, database. `tech` renders as the [Type: Tech] line, `desc` as the
description line — the standard C4 label. Element ids must be unique across
ALL levels (pages share one link namespace). Requires Graphviz `dot`.
Usage: python3 c4.py <c4.json> [-o out.drawio] [--direction TB|LR]
"""
import argparse
import importlib.util
import json
import os
import re
import sys
# Official draw.io C4 template styles (colors from c4model.com).
_BASE = "html=1;whiteSpace=wrap;fontSize=12;fontColor=#ffffff;align=center;"
STYLES = {
"person": ("shape=mxgraph.c4.person2;" + _BASE +
"fillColor=#083F75;strokeColor=#06315C;", 200, 180),
"system": ("rounded=1;arcSize=10;" + _BASE +
"fillColor=#1061B0;strokeColor=#0D5091;", 240, 120),
"external": ("rounded=1;arcSize=10;" + _BASE +
"fillColor=#8C8496;strokeColor=#736782;", 240, 120),
"container": ("rounded=1;arcSize=10;" + _BASE +
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
"component": ("rounded=1;arcSize=10;" + _BASE +
"fillColor=#63BEF2;strokeColor=#2086C9;", 240, 120),
"database": ("shape=cylinder3;size=15;boundedLbl=1;" + _BASE +
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
}
TYPE_WORD = {"person": "Person", "system": "Software System",
"external": "Software System", "container": "Container",
"component": "Component", "database": "Container"}
EDGE = ("endArrow=blockThin;endFill=1;endSize=10;html=1;fontSize=11;"
"fontColor=#404040;strokeColor=#828282;labelBackgroundColor=#ffffff;"
"rounded=0;")
def load_autolayout():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autolayout.py")
spec = importlib.util.spec_from_file_location("autolayout", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def slug(name):
return re.sub(r"[^a-z0-9]+", "-", str(name).lower()).strip("-") or "page"
def c4_label(el):
"""Standard C4 element label: Name / [Type: Tech] / description."""
kind = TYPE_WORD.get(el.get("type", "system"), "Software System")
bracket = f"[{kind}: {el['tech']}]" if el.get("tech") else f"[{kind}]"
lines = [el.get("label", el["id"]), bracket]
if el.get("desc"):
lines.append(el["desc"])
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="C4 levels JSON -> multi-page draw.io.")
ap.add_argument("input", help="C4 JSON file")
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
args = ap.parse_args()
with open(args.input, encoding="utf-8") as f:
spec = json.load(f)
levels = spec.get("levels") or []
if not levels:
sys.exit("error: no levels in input")
al = load_autolayout()
page_ids = {lv["name"]: slug(lv["name"]) for lv in levels}
seen = set()
pages = []
for lv in levels:
nodes = []
for el in lv.get("elements", []):
if el["id"] in seen:
sys.exit(f"error: duplicate element id {el['id']!r} "
"(ids must be unique across all levels)")
seen.add(el["id"])
style, w, h = STYLES.get(el.get("type", "system"), STYLES["system"])
node = {"id": el["id"], "label": c4_label(el), "style": style,
"width": w, "height": h}
child = el.get("children")
if child:
if child not in page_ids:
sys.exit(f"error: element {el['id']!r} drills down to "
f"unknown level {child!r}")
node["link"] = f"data:page/id,{page_ids[child]}"
nodes.append(node)
edges = [{"source": r["from"], "target": r["to"],
"label": r.get("label", ""), "style": EDGE}
for r in lv.get("relations", [])]
graph = {"direction": args.direction, "nodes": nodes, "edges": edges,
"ranksep": 0.9, "nodesep": 0.5}
height, pos, edge_pts = al.layout(al.build_dot(graph))
pages.append(al.wrap_page(al.page_cells(graph, height, pos, edge_pts, color=False),
page_id=page_ids[lv["name"]], name=lv["name"]))
xml = "<mxfile>\n" + "".join(pages) + "</mxfile>\n"
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
print(f"wrote {args.output} ({len(pages)} pages, {len(seen)} elements)",
file=sys.stderr)
else:
sys.stdout.write(xml)
if __name__ == "__main__":
main()
scripts/ciimports.py
#!/usr/bin/env python3
"""Extract a CI pipeline (GitHub Actions / GitLab CI) as autolayout graph JSON.
GitHub Actions: every job becomes a node (label: name, runner, matrix size,
reusable-workflow target), `needs:` become edges, and each workflow gets a
trigger node (its `on:` events) feeding the jobs that have no `needs`. Given a
repo root, all of `.github/workflows/*.yml|yaml` are read and each workflow is
boxed in its own container.
GitLab CI (`.gitlab-ci.yml`, auto-detected): jobs become nodes grouped by
stage; edges come from `needs:`, and jobs without `needs` inherit the stage
DAG (every job of the previous stage), matching GitLab's execution order.
python3 ciimports.py . # repo root -> all workflows
python3 ciimports.py .github/workflows/ci.yml -o graph.json
python3 autolayout.py graph.json -o pipeline.drawio
Requires PyYAML (pip install pyyaml).
Usage: python3 ciimports.py <repo-root | workflow.yml ...> [-o graph.json]
[--direction TB|LR]
"""
import argparse
import json
import os
import sys
JOB_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
REUSE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;"
TRIGGER_STYLE = "ellipse;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;"
GITLAB_RESERVED = {"stages", "variables", "workflow", "default", "include", "image",
"services", "before_script", "after_script", "cache", "pages"}
def find_workflows(path):
"""Workflow files for a path: file(s) as-is, a repo root via .github/workflows."""
if os.path.isfile(path):
return [path]
wfdir = os.path.join(path, ".github", "workflows")
files = sorted(os.path.join(wfdir, f) for f in os.listdir(wfdir)
if f.endswith((".yml", ".yaml"))) if os.path.isdir(wfdir) else []
gitlab = os.path.join(path, ".gitlab-ci.yml")
if os.path.isfile(gitlab):
files.append(gitlab)
if not files:
sys.exit(f"error: no workflow files under {path}")
return files
def matrix_size(strategy):
n = 1
matrix = (strategy or {}).get("matrix") or {}
if not isinstance(matrix, dict):
return 0 # dynamic (fromJSON) — unknown
for key, vals in matrix.items():
if key not in ("include", "exclude") and isinstance(vals, list):
n *= len(vals)
n += len(matrix.get("include") or []) - len(matrix.get("exclude") or [])
return max(n, 1)
def parse_actions(spec, wf_id, wf_name, group):
"""One GitHub Actions workflow -> (nodes, edges)."""
nodes, edges = [], []
# YAML 1.1 quirk: bare `on:` parses as boolean True
on = spec.get("on", spec.get(True, {}))
events = sorted(on) if isinstance(on, dict) else \
([on] if isinstance(on, str) else sorted(on or []))
trig_id = f"{wf_id}//trigger"
nodes.append({"id": trig_id, "label": "on: " + (", ".join(events) or "?"),
"style": TRIGGER_STYLE, "width": 160, "height": 50, "group": group})
jobs = spec.get("jobs") or {}
for jid, job in jobs.items():
job = job or {}
lines = [job.get("name") or jid]
if job.get("uses"):
lines.append("uses: " + os.path.basename(str(job["uses"])))
style = REUSE_STYLE
else:
style = JOB_STYLE
runner = job.get("runs-on")
if runner:
lines.append(str(runner if isinstance(runner, str) else ", ".join(runner)))
n = matrix_size(job.get("strategy"))
if n > 1:
lines.append(f"matrix ×{n}")
elif n == 0:
lines.append("matrix (dynamic)")
nodes.append({"id": f"{wf_id}//{jid}", "label": "\n".join(lines),
"style": style, "width": 180, "height": 60, "group": group})
needs = job.get("needs") or []
needs = [needs] if isinstance(needs, str) else needs
for dep in needs:
if dep in jobs:
edges.append({"source": f"{wf_id}//{dep}", "target": f"{wf_id}//{jid}"})
if not needs:
edges.append({"source": trig_id, "target": f"{wf_id}//{jid}"})
return nodes, edges
def parse_gitlab(spec, wf_id, group_prefix):
"""A .gitlab-ci.yml -> (nodes, edges); jobs grouped by stage."""
stages = spec.get("stages") or ["build", "test", "deploy"]
jobs = {k: v for k, v in spec.items()
if isinstance(v, dict) and k not in GITLAB_RESERVED and not k.startswith(".")
and ("script" in v or "trigger" in v or "extends" in v or "stage" in v)}
nodes, edges = [], []
by_stage = {}
for jid, job in jobs.items():
stage = job.get("stage") or "test"
by_stage.setdefault(stage, []).append(jid)
nodes.append({"id": f"{wf_id}//{jid}", "label": jid, "style": JOB_STYLE,
"width": 160, "height": 50,
"group": f"{group_prefix}{stage}"})
order = [s for s in stages if s in by_stage]
for jid, job in jobs.items():
needs = [(n.get("job") if isinstance(n, dict) else n) for n in job.get("needs") or []]
needs = [n for n in needs if n in jobs]
if needs:
edges.extend({"source": f"{wf_id}//{n}", "target": f"{wf_id}//{jid}"} for n in needs)
else: # stage DAG: all jobs of the previous stage
stage = job.get("stage") or "test"
i = order.index(stage) if stage in order else 0
if i > 0:
edges.extend({"source": f"{wf_id}//{p}", "target": f"{wf_id}//{jid}"}
for p in by_stage[order[i - 1]])
return nodes, edges
def main():
ap = argparse.ArgumentParser(description="CI pipeline -> autolayout graph JSON.")
ap.add_argument("paths", nargs="+",
help="repo root, or workflow file(s) (.github/workflows/*.yml, .gitlab-ci.yml)")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="LR", choices=["TB", "LR"])
args = ap.parse_args()
try:
import yaml
except ImportError:
sys.exit("error: PyYAML is required (pip install pyyaml)")
files = [f for p in args.paths for f in find_workflows(p)]
nodes, edges = [], []
for path in files:
with open(path, encoding="utf-8") as f:
try:
spec = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
sys.stderr.write(f"warning: skipping {path}: {e}\n")
continue
wf_id = os.path.splitext(os.path.basename(path))[0]
if os.path.basename(path) == ".gitlab-ci.yml" or (
"jobs" not in spec and "stages" in spec):
n, e = parse_gitlab(spec, wf_id, "stage: " if len(files) == 1
else f"{wf_id} / stage: ")
elif spec.get("jobs"):
wf_name = spec.get("name") or wf_id
group = wf_name if len(files) > 1 else None
n, e = parse_actions(spec, wf_id, wf_name, group)
else:
sys.stderr.write(f"warning: {path} has no jobs — skipped\n")
continue
nodes.extend(n)
edges.extend(e)
if not nodes:
sys.exit("error: no CI jobs found")
graph = {"direction": args.direction, "nodes": nodes, "edges": edges}
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"{len(nodes)} nodes, {len(edges)} edges from {len(files)} file(s)\n")
if __name__ == "__main__":
main()
scripts/composeimports.py
#!/usr/bin/env python3
"""Extract a docker-compose file's service graph as autolayout graph JSON.
Services become rounded boxes (labeled name + image), named volumes become
cylinders, and edges come from real wiring: `depends_on` (list or mapping
form), `links`, `volumes_from`, and named-volume mounts (short "vol:/path"
and long {type: volume, source: ...} syntax). The output feeds autolayout.py:
python3 composeimports.py docker-compose.yml -o graph.json
python3 autolayout.py graph.json -o stack.drawio
Given a directory, the usual compose file names are tried
(compose.yaml/compose.yml/docker-compose.yml/docker-compose.yaml).
Requires PyYAML (pip install pyyaml). `--group` boxes services by their
first network.
Usage: python3 composeimports.py <compose-file-or-dir> [-o graph.json]
[--direction TB|LR] [--group]
"""
import argparse
import json
import os
import sys
SERVICE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
VOLUME_STYLE = ("shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;"
"fillColor=#f5f5f5;strokeColor=#666666;")
def find_compose(path):
if os.path.isfile(path):
return path
for name in ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"):
cand = os.path.join(path, name)
if os.path.isfile(cand):
return cand
sys.exit(f"error: no compose file found under {path}")
def volume_mounts(svc):
"""Named volumes a service mounts (short and long syntax)."""
for v in svc.get("volumes") or []:
if isinstance(v, str):
src = v.split(":", 1)[0]
if src and not src.startswith((".", "/", "~", "$")):
yield src
elif isinstance(v, dict) and v.get("type", "volume") == "volume" and v.get("source"):
yield v["source"]
def dependencies(svc):
dep = svc.get("depends_on") or []
deps = list(dep) if isinstance(dep, (list, dict)) else []
for link in svc.get("links") or []:
deps.append(str(link).split(":", 1)[0])
for vf in svc.get("volumes_from") or []:
deps.append(str(vf).split(":", 1)[0])
return deps
def main():
ap = argparse.ArgumentParser(description="docker-compose -> autolayout graph JSON.")
ap.add_argument("path", help="compose file, or directory containing one")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group services into containers by their first network")
args = ap.parse_args()
try:
import yaml
except ImportError:
sys.exit("error: PyYAML is required (pip install pyyaml)")
path = find_compose(args.path)
with open(path, encoding="utf-8") as f:
spec = yaml.safe_load(f) or {}
services = spec.get("services") or {}
if not services:
sys.exit(f"error: no services in {path}")
declared_volumes = set(spec.get("volumes") or {})
nodes, edges = [], set()
for name, svc in services.items():
svc = svc or {}
image = svc.get("image") or ("build: " + str((svc.get("build") or {}).get("context", ".")
if isinstance(svc.get("build"), dict)
else svc.get("build", ".")))
node = {"id": name, "label": f"{name}\n{image}", "style": SERVICE_STYLE,
"width": 160, "height": 60}
nets = svc.get("networks")
first_net = (sorted(nets)[0] if isinstance(nets, dict) else nets[0]) if nets else None
if args.group and first_net:
node["group"] = str(first_net)
nodes.append(node)
for dep in dependencies(svc):
if dep in services and dep != name:
edges.add((name, dep))
for vol in volume_mounts(svc):
if vol in declared_volumes:
edges.add((name, f"vol:{vol}"))
for vol in sorted({t[4:] for _, t in edges if t.startswith("vol:")}):
nodes.append({"id": f"vol:{vol}", "label": vol, "style": VOLUME_STYLE,
"width": 120, "height": 70})
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": s, "target": t} for s, t in sorted(edges)]}
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"{len(nodes)} nodes, {len(edges)} edges\n")
if __name__ == "__main__":
main()
scripts/compress.py
#!/usr/bin/env python3
"""Collapse a big .drawio into a boardroom-friendly executive summary.
Detects clusters in a large diagram with a deterministic pure-Python label
propagation pass (no networkx), replaces each cluster with ONE labeled group
node, keeps aggregated inter-cluster edges, and emits a 2-page .drawio: page 1
is the executive view (auto-laid-out via autolayout.py), page 2 is the
original full diagram, copied verbatim. Each executive node is wrapped in a
draw.io UserObject `data:page/id,...` drill-down link to page 2, so clicking
"Auth (5)" jumps straight into the full detail.
Community detection is unsupervised — it finds however many clusters the
graph naturally has; `--clusters` is only a soft hint and may be ignored.
Clusters are named after the longest common leading token shared by their
members' labels (falling back to the highest-degree member's label), with the
member count appended, e.g. "Auth (5)". Rename them by hand afterward for a
more semantic label — label propagation does not know what your system does.
Requires Graphviz `dot` on PATH (shells out to autolayout.py to place the
executive nodes).
python3 compress.py big-system.drawio -o exec-view.drawio
Usage: python3 compress.py <diagram.drawio> [-o out.drawio] [--clusters N]
"""
import argparse
import copy
import json
import os
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
def parse(path):
"""Return (nodes, edges) for a .drawio: nodes {id: (label, style)} for leaf
vertices, edges {(source_id, target_id)}. Cells are flattened across pages;
UserObject/object wrappers are unwrapped (id on the wrapper, cell inside).
Copied from drawiodiff.parse() — see SHARED CONVENTIONS."""
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
cells, labels = [], {}
for page in pages:
model = page.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
if (page.text or "").strip():
sys.stderr.write(f"warning: {path}: a page is compressed, skipped\n")
continue
for child in root:
if child.tag == "mxCell":
cells.append(child)
labels[child.get("id")] = child.get("value") or ""
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
cells.append(inner)
labels[child.get("id")] = child.get("label") or child.get("value") or ""
parents = {c.get("parent") for c in cells} # ids that have children
nodes, edges = {}, set()
for c in cells:
cid = c.get("id")
if c.get("edge") == "1":
s, t = c.get("source"), c.get("target")
if s and t:
edges.add((s, t))
elif c.get("vertex") == "1" and cid not in parents: # leaf vertices only
if "edgeLabel" in (c.get("style") or ""):
continue
g = c.find("mxGeometry")
if g is not None and g.get("relative") == "1": # edge-label child
continue
nodes[cid] = (labels.get(cid, ""), c.get("style") or "")
return nodes, edges
def label_propagation(node_ids, edges, max_passes=20):
"""Deterministic pure-Python label propagation for community detection.
Edges are treated as undirected for clustering. Each pass computes every
node's new label synchronously from the PREVIOUS pass's labels (most
frequent label among neighbours, ties -> smallest label), then applies
them all at once — this keeps a thin bridge between two dense clusters
from cascading a merge within a single pass. Stops early once no label
changes, else after `max_passes`. Returns {node_id: community_label}.
"""
nodes = sorted(set(node_ids))
neighbours = {n: set() for n in nodes}
for s, t in edges:
if s in neighbours and t in neighbours and s != t:
neighbours[s].add(t)
neighbours[t].add(s)
labels = {n: n for n in nodes}
for _ in range(max_passes):
new_labels = {}
for n in nodes:
if not neighbours[n]:
new_labels[n] = labels[n]
continue
counts = {}
for nb in neighbours[n]:
lbl = labels[nb]
counts[lbl] = counts.get(lbl, 0) + 1
best = max(counts.values())
new_labels[n] = min(lbl for lbl, c in counts.items() if c == best)
if new_labels == labels:
break
labels = new_labels
return labels
def compute_degree(node_ids, edges):
"""Undirected degree per node (used as the naming tiebreak)."""
degree = {n: 0 for n in node_ids}
for s, t in edges:
if s in degree:
degree[s] += 1
if t in degree:
degree[t] += 1
return degree
def aggregate_edges(edges, community_of):
"""Roll original edges up to inter-community edges: for every edge whose
endpoints fall in two different communities, count crossings by
(source_community, target_community) and dedupe into one entry per pair.
Same-community (internal) edges are dropped. Returns
{(src_community, tgt_community): crossing_count}."""
counts = {}
for s, t in edges:
cs, ct = community_of.get(s), community_of.get(t)
if cs is None or ct is None or cs == ct:
continue
counts[(cs, ct)] = counts.get((cs, ct), 0) + 1
return counts
def cluster_name(member_ids, node_labels, degree):
"""Heuristic community name: the longest common leading token shared by
every member's label (split on whitespace), else the highest-degree
member's label. The member count is appended, e.g. "Auth (5)"."""
token_lists = [str(node_labels.get(m, m)).split() for m in member_ids]
common = []
if token_lists and all(token_lists):
for tokens in zip(*token_lists):
if len(set(tokens)) == 1:
common.append(tokens[0])
else:
break
if common:
base = " ".join(common)
else:
top = max(member_ids, key=lambda m: (degree.get(m, 0), m))
base = node_labels.get(top) or top
return f"{base} ({len(member_ids)})"
def layout_exec_page(graph):
"""Shell out to autolayout.py to place the executive nodes; return the
rendered <diagram>...</diagram> page, renamed to a friendlier id/title."""
with tempfile.TemporaryDirectory() as d:
gpath = os.path.join(d, "exec.json")
with open(gpath, "w", encoding="utf-8") as f:
json.dump(graph, f)
opath = os.path.join(d, "exec.drawio")
r = subprocess.run(
[sys.executable, os.path.join(HERE, "autolayout.py"), gpath, "-o", opath],
capture_output=True, text=True,
)
if r.returncode != 0 or not os.path.exists(opath):
sys.exit(f"error: autolayout failed: {r.stderr.strip()}")
with open(opath, encoding="utf-8") as f:
xml = f.read()
m = re.search(r"(<diagram\b.*?</diagram>)", xml, re.S)
if not m:
sys.exit("error: autolayout produced no page")
page = m.group(1).replace('id="autolayout"', 'id="exec-view"', 1)
page = page.replace('name="Page-1"', 'name="Executive View"', 1)
return page + "\n"
def copy_original_page(path, page2_id):
"""Copy the source's first page verbatim (cells untouched) into a new
<diagram> with id=page2_id, so exec-node drill-down links resolve to it."""
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
page = copy.deepcopy(pages[0])
if page.find("mxGraphModel/root") is None:
sys.exit(f"error: {path}: page is compressed (no <root>), cannot copy verbatim")
page.set("id", page2_id)
page.set("name", "Full Diagram")
return ET.tostring(page, encoding="unicode") + "\n"
def main():
ap = argparse.ArgumentParser(
description="Collapse a big .drawio into an executive-summary view with drill-down.")
ap.add_argument("input", help="source .drawio")
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
ap.add_argument("--clusters", type=int,
help="soft hint for cluster count; label propagation picks the "
"count automatically and may ignore this")
args = ap.parse_args()
if args.clusters:
sys.stderr.write("note: --clusters is a soft hint; label propagation "
"determines the actual cluster count automatically\n")
nodes, edges = parse(args.input)
if not nodes:
sys.exit(f"error: no leaf vertices found in {args.input}")
community_of = label_propagation(nodes.keys(), edges)
communities = {}
for nid in sorted(nodes):
communities.setdefault(community_of[nid], []).append(nid)
degree = compute_degree(nodes.keys(), edges)
node_labels = {nid: label for nid, (label, _style) in nodes.items()}
names = {c: cluster_name(members, node_labels, degree) for c, members in communities.items()}
crossings = aggregate_edges(edges, community_of)
page2_id = "full-diagram"
exec_nodes = [{"id": f"c_{c}", "label": names[c], "link": f"data:page/id,{page2_id}"}
for c in communities]
exec_edges = [{"source": f"c_{s}", "target": f"c_{t}", "label": str(n) if n > 1 else ""}
for (s, t), n in sorted(crossings.items())]
exec_graph = {"direction": "TB", "nodes": exec_nodes, "edges": exec_edges}
page1 = layout_exec_page(exec_graph)
page2 = copy_original_page(args.input, page2_id)
xml = "<mxfile>\n" + page1 + page2 + "</mxfile>\n"
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
sys.stderr.write(f"wrote {args.output} ({len(nodes)} nodes -> "
f"{len(communities)} clusters)\n")
else:
sys.stdout.write(xml)
sys.stderr.write(f"{len(nodes)} nodes -> {len(communities)} clusters\n")
if __name__ == "__main__":
main()
scripts/dockerimports.py
#!/usr/bin/env python3
"""Draw the containers that are ACTUALLY running from `docker inspect` output.
Where composeimports.py reads the *declared* stack (compose file), this reads
the *live* one: pipe `docker inspect` of the running containers and it maps the
real topology — every container, the user networks they are attached to, the
named volumes they mount, and the container->container edges recorded in
`links` / compose `depends_on` labels. The output feeds autolayout.py:
docker inspect $(docker ps -q) | python3 dockerimports.py - -o graph.json
python3 autolayout.py graph.json -o running.drawio
Input is the JSON array `docker inspect` prints (a file path, or `-` for
stdin). Containers become rounded boxes (name + image), user networks become
green ellipses, named volumes become cylinders — visually matching the compose
importer so declared and live diagrams read alike. `--group` boxes containers
by their compose project (falling back to their first user network).
Usage: docker inspect $(docker ps -q) | python3 dockerimports.py - [-o graph.json]
[--direction TB|LR] [--group]
"""
import argparse
import json
import sys
CONTAINER_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
NETWORK_STYLE = "ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;"
VOLUME_STYLE = ("shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;"
"fillColor=#f5f5f5;strokeColor=#666666;")
# Docker's built-in networks are topology noise — a compose stack's own
# networks are what tell the architecture story.
BUILTIN_NETS = {"bridge", "host", "none", "ingress"}
def cname(obj):
"""A container's short name (strip docker's leading slash)."""
return (obj.get("Name") or obj.get("Id", "")[:12]).lstrip("/")
def links_of(obj):
"""Container names this one links to (HostConfig.Links + per-network Links)."""
out = set()
raw = list((obj.get("HostConfig") or {}).get("Links") or [])
for net in ((obj.get("NetworkSettings") or {}).get("Networks") or {}).values():
raw.extend((net or {}).get("Links") or [])
for link in raw:
# "/db:/web/db" -> target container is the part before the first colon.
target = str(link).lstrip("/").split(":", 1)[0]
if target:
out.add(target)
return out
def depends_on(obj):
"""Compose service names this container depends on (label form)."""
label = (obj.get("Config") or {}).get("Labels", {}).get("com.docker.compose.depends_on")
if not label:
return set()
# "db:service_healthy:false,cache:service_started:false" -> {db, cache}
return {part.split(":", 1)[0] for part in label.split(",") if part.strip()}
def main():
ap = argparse.ArgumentParser(description="`docker inspect` output -> autolayout graph JSON.")
ap.add_argument("input", help="`docker inspect` JSON file, or - for stdin")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group containers by compose project (else first network)")
args = ap.parse_args()
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
sys.exit(f"error: input is not valid JSON ({exc}) — feed `docker inspect ...`")
containers = data if isinstance(data, list) else [data]
containers = [c for c in containers if isinstance(c, dict) and c.get("Id")]
if not containers:
sys.exit("error: no containers found (feed `docker inspect $(docker ps -q)`)")
names = {cname(c) for c in containers}
# compose service label -> container name, so depends_on (which names
# services) can resolve to the real container node.
svc_to_name = {}
for c in containers:
svc = (c.get("Config") or {}).get("Labels", {}).get("com.docker.compose.service")
if svc:
svc_to_name[svc] = cname(c)
nodes, edges, nets, vols = [], set(), set(), set()
for c in containers:
name = cname(c)
image = (c.get("Config") or {}).get("Image") or "?"
labels = (c.get("Config") or {}).get("Labels", {}) or {}
node = {"id": name, "label": f"{name}\n{image}", "style": CONTAINER_STYLE,
"width": 160, "height": 60}
attached = [n for n in ((c.get("NetworkSettings") or {}).get("Networks") or {})
if n not in BUILTIN_NETS]
if args.group:
project = labels.get("com.docker.compose.project")
grp = project or (attached[0] if attached else None)
if grp:
node["group"] = str(grp)
nodes.append(node)
for net in attached:
nets.add(net)
edges.add((name, f"net:{net}"))
for m in c.get("Mounts") or []:
if m.get("Type") == "volume" and m.get("Name"):
vols.add(m["Name"])
edges.add((name, f"vol:{m['Name']}"))
for target in links_of(c):
if target in names and target != name:
edges.add((name, target))
for dep in depends_on(c):
target = svc_to_name.get(dep, dep)
if target in names and target != name:
edges.add((name, target))
for net in sorted(nets):
nodes.append({"id": f"net:{net}", "label": net, "style": NETWORK_STYLE,
"width": 120, "height": 70})
for vol in sorted(vols):
nodes.append({"id": f"vol:{vol}", "label": vol, "style": VOLUME_STYLE,
"width": 120, "height": 70})
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": s, "target": t} for s, t in sorted(edges)]}
out = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(out)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(out)
sys.stderr.write(f"{len(nodes)} nodes ({len(containers)} containers, "
f"{len(nets)} networks, {len(vols)} volumes), {len(edges)} edges\n")
if __name__ == "__main__":
main()
scripts/drawio2mermaid.py
#!/usr/bin/env python3
"""Convert a .drawio into Mermaid flowchart text (diagrams-as-code).
The other reverse tool, `explain.py`, turns a diagram into prose; this turns it
into a **Mermaid `flowchart`** you can paste into a Markdown file and have
GitHub / GitLab / docs render natively — handy when you want the diagram to live
as maintainable text next to the code. Containers become `subgraph`s, edge
labels are kept, and a few shapes map to Mermaid node forms (cylinder → database
`[( )]`, rhombus → decision `{ }`, else `[ ]`).
python3 drawio2mermaid.py architecture.drawio # Mermaid to stdout
python3 drawio2mermaid.py c4.drawio --fenced -o out.md # ```mermaid fenced
Multi-page files emit one flowchart per page. This is a structural conversion —
styling, colours and vendor icons do not survive (Mermaid has no equivalent);
for a faithful, richly-styled diagram keep the `.drawio`.
Usage: python3 drawio2mermaid.py <file.drawio> [-o out] [--direction TD|LR] [--fenced]
"""
import argparse
import html
import re
import sys
import xml.etree.ElementTree as ET
def clean(text):
"""Strip the HTML draw.io stores in labels; keep line breaks as <br/>."""
if not text:
return ""
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
return re.sub(r"[ \t]+", " ", text).strip()
def esc(label):
"""Mermaid-safe quoted label: escape quotes, newlines -> <br/>."""
label = label.replace('"', """).replace("\n", "<br/>")
return label or " "
def node_form(safe_id, label, style):
"""Mermaid node declaration, shape chosen from the draw.io style."""
lbl = f'"{esc(label)}"'
if "shape=cylinder" in style or "shape=datastore" in style:
return f"{safe_id}[({lbl})]" # database
if "rhombus" in style:
return f"{safe_id}{{{lbl}}}" # decision
if "ellipse" in style or "shape=cloud" in style:
return f"{safe_id}(({lbl}))" # circle-ish
return f"{safe_id}[{lbl}]" # default box
def cells_of(page):
"""(cell, id, label) for a page, unwrapping UserObject/object wrappers."""
model = page.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
return None
out = []
for child in root:
if child.tag == "mxCell":
out.append((child, child.get("id"), clean(child.get("value"))))
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
out.append((inner, child.get("id"),
clean(child.get("label") or child.get("value"))))
return out
def page_to_mermaid(page, direction):
cells = cells_of(page)
if cells is None:
return "%% (compressed page — skipped)"
label = {cid: lbl for _, cid, lbl in cells}
style = {cid: (c.get("style") or "") for c, cid, _ in cells}
parents = {c.get("parent") for c, _, _ in cells if c.get("parent")}
verts = [(c, cid) for c, cid, _ in cells if c.get("vertex") == "1"]
containers = {cid for c, cid in verts if cid in parents}
leaves = [(c, cid) for c, cid in verts
if cid not in containers and "edgeLabel" not in style.get(cid, "")]
sid = {cid: f"n{i}" for i, (_, cid) in enumerate(leaves)} # mermaid-safe ids
lines = [f"flowchart {direction}"]
# Nodes, grouped into subgraphs by their container.
by_container = {}
for c, cid in leaves:
parent = c.get("parent")
key = parent if parent in containers else None
by_container.setdefault(key, []).append(cid)
def emit_node(cid, indent):
lines.append(indent + node_form(sid[cid], label.get(cid) or cid, style.get(cid, "")))
for cid in by_container.get(None, []):
emit_node(cid, " ")
for cont, members in by_container.items():
if cont is None:
continue
lines.append(f' subgraph {sid.get(cont, "g_" + cont)}["{esc(label.get(cont) or "")}"]')
for cid in members:
emit_node(cid, " ")
lines.append(" end")
# Edges (only between leaves we emitted).
for c, _, _ in cells:
if c.get("edge") != "1":
continue
s, t = c.get("source"), c.get("target")
if s in sid and t in sid:
lbl = clean(c.get("value"))
arrow = f'-->|"{esc(lbl)}"|' if lbl else "-->"
lines.append(f" {sid[s]} {arrow} {sid[t]}")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="Convert a .drawio to Mermaid flowchart text.")
ap.add_argument("file")
ap.add_argument("-o", "--output", help="output path (default: stdout)")
ap.add_argument("--direction", default="TD", choices=["TD", "LR", "TB", "RL", "BT"])
ap.add_argument("--fenced", action="store_true", help="wrap each graph in a ```mermaid fence")
args = ap.parse_args()
try:
root = ET.parse(args.file).getroot()
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {args.file}: {exc}")
pages = root.findall("diagram") or [root]
blocks = []
for i, page in enumerate(pages, 1):
graph = page_to_mermaid(page, args.direction)
name = page.get("name")
if len(pages) > 1 and name:
graph = f"%% Page {i}: {name}\n{graph}"
blocks.append(f"```mermaid\n{graph}\n```" if args.fenced else graph)
text = ("\n\n".join(blocks)).rstrip() + "\n"
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output} ({len(pages)} page{'s' if len(pages) != 1 else ''})\n")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
scripts/drawio2pptx.py
#!/usr/bin/env python3
"""Turn a .drawio into a PowerPoint deck — one slide per page.
Exports each page of a (multi-page) .drawio to a PNG via the draw.io CLI and
lays them into a 16:9 .pptx, one page per slide, scaled to fit with the page
name as the slide title. A C4 model (Context / Container / Component) becomes a
ready-to-present deck; any multi-page diagram becomes a slide sequence.
python3 drawio2pptx.py c4.drawio -o c4.pptx
python3 drawio2pptx.py architecture.drawio # -> architecture.pptx
Needs the draw.io CLI (for the PNG export) and the `python-pptx` package
(`pip install python-pptx`) for writing the deck. Slides are 13.333in × 7.5in
(16:9); each image is centred and scaled to fit inside a small margin.
Usage: python3 drawio2pptx.py <file.drawio> [-o out.pptx] [--scale N]
"""
import argparse
import os
import struct
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
def page_names(path):
"""Names of the <diagram> pages, in order (None where unnamed)."""
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
diagrams = root.findall("diagram")
return [d.get("name") for d in diagrams] if diagrams else [None]
def png_size(path):
"""(width, height) in pixels from a PNG's IHDR header."""
with open(path, "rb") as f:
head = f.read(24)
return struct.unpack(">II", head[16:24])
def export_page(drawio_file, index, out_png, scale):
"""Export one page (1-based index) to PNG via the draw.io CLI."""
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", str(index),
"-s", str(scale), "-o", out_png, drawio_file],
capture_output=True)
return r.returncode == 0 and os.path.exists(out_png)
def main():
ap = argparse.ArgumentParser(description="Export a .drawio to a PowerPoint deck (one slide per page).")
ap.add_argument("file")
ap.add_argument("-o", "--output", help="output .pptx (default: alongside input)")
ap.add_argument("--scale", type=float, default=2.0, help="PNG export scale (default 2)")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
try:
from pptx import Presentation
from pptx.util import Emu, Pt
except ImportError:
sys.exit("error: python-pptx is required (pip install python-pptx)")
names = page_names(args.file)
out = args.output or os.path.splitext(args.file)[0] + ".pptx"
prs = Presentation()
prs.slide_width = Emu(12192000) # 13.333in — 16:9
prs.slide_height = Emu(6858000) # 7.5in
blank = prs.slide_layouts[6] # the built-in "Blank" layout
sw, sh = int(prs.slide_width), int(prs.slide_height)
margin = Emu(457200) # 0.5in
title_h = Emu(500000)
made = 0
with tempfile.TemporaryDirectory() as tmp:
for i, name in enumerate(names, 1): # draw.io --page-index is 1-based
png = os.path.join(tmp, f"page{i}.png")
if not export_page(args.file, i, png, args.scale):
sys.stderr.write(f"warning: page {i} export failed — skipped\n")
continue
slide = prs.slides.add_slide(blank)
top_pad = margin
if name:
box = slide.shapes.add_textbox(margin, Emu(180000),
Emu(sw - 2 * int(margin)), title_h)
tf = box.text_frame
tf.text = name
tf.paragraphs[0].runs[0].font.size = Pt(20)
tf.paragraphs[0].runs[0].font.bold = True
top_pad = Emu(180000) + title_h
cw, ch = sw - 2 * int(margin), sh - int(top_pad) - int(margin)
pw, ph = png_size(png)
scale = min(cw / pw, ch / ph) # fit, preserve aspect
iw, ih = int(pw * scale), int(ph * scale)
left = Emu(int((sw - iw) / 2))
top = Emu(int(top_pad) + int((ch - ih) / 2))
slide.shapes.add_picture(png, left, top, width=Emu(iw), height=Emu(ih))
made += 1
if not made:
sys.exit("error: no pages exported (is the draw.io CLI installed?)")
prs.save(out)
sys.stderr.write(f"wrote {out} ({made} slide{'s' if made != 1 else ''})\n")
if __name__ == "__main__":
main()
scripts/drawiodiff.py
#!/usr/bin/env python3
"""Diff two .drawio diagrams into a colour-coded autolayout graph JSON.
Compares an OLD and a NEW .drawio and emits a single graph where every node and
edge is tinted by what happened to it:
added (in new only) -> green
removed (in old only) -> red, dashed
changed (matched, label moved) -> orange
same (matched, unchanged) -> grey
The output is a normal graph JSON — feed it to autolayout.py for one clean,
freshly laid-out "what changed" diagram:
python3 drawiodiff.py old.drawio new.drawio -o diff.json
python3 autolayout.py diff.json -o diff.drawio
Nodes are matched by cell **id** (the default) — perfect for diagrams the
bundled importers generate, whose ids are stable semantic keys
(`aws_instance.web`, `shop-db-1`), so two snapshots line up exactly. This makes
it the natural companion to the live-infra importers: snapshot `terraform show
-json` / `docker inspect` / `kubectl get -o json` twice and diff the two to see
drift. For hand-drawn diagrams whose ids are random, pass `--by-label` to match
on the visible label text instead.
Only leaf vertices and the edges between them are compared; container/group
cells and edge labels are skipped. The diff is a flat colour-coded view, so the
original icons/shapes are replaced by status colours (the label is kept).
Usage: python3 drawiodiff.py <old.drawio> <new.drawio> [-o diff.json]
[--direction TB|LR] [--by-label]
"""
import argparse
import json
import sys
import xml.etree.ElementTree as ET
STYLE = {
"added": "rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;",
"removed": "rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;dashed=1;",
"changed": "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;",
"same": "rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;",
}
EDGE_STYLE = {
"added": "endArrow=classic;html=1;strokeColor=#82b366;strokeWidth=2;",
"removed": "endArrow=classic;html=1;strokeColor=#b85450;strokeWidth=2;dashed=1;",
"same": "endArrow=classic;html=1;strokeColor=#999999;",
}
def parse(path):
"""Return (nodes, edges) for a .drawio: nodes {id: (label, style)} for leaf
vertices, edges {(source_id, target_id)}. Cells are flattened across pages;
UserObject/object wrappers are unwrapped (id on the wrapper, cell inside)."""
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
cells, labels = [], {}
for page in pages:
model = page.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
if (page.text or "").strip():
sys.stderr.write(f"warning: {path}: a page is compressed, skipped\n")
continue
for child in root:
if child.tag == "mxCell":
cells.append(child)
labels[child.get("id")] = child.get("value") or ""
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
cells.append(inner)
labels[child.get("id")] = child.get("label") or child.get("value") or ""
parents = {c.get("parent") for c in cells} # ids that have children
nodes, edges = {}, set()
for c in cells:
cid = c.get("id")
if c.get("edge") == "1":
s, t = c.get("source"), c.get("target")
if s and t:
edges.add((s, t))
elif c.get("vertex") == "1" and cid not in parents: # leaf vertices only
if "edgeLabel" in (c.get("style") or ""):
continue
g = c.find("mxGeometry")
if g is not None and g.get("relative") == "1": # edge-label child
continue
nodes[cid] = (labels.get(cid, ""), c.get("style") or "")
return nodes, edges
def main():
ap = argparse.ArgumentParser(description="Diff two .drawio files -> autolayout graph JSON.")
ap.add_argument("old", help="baseline .drawio")
ap.add_argument("new", help="updated .drawio")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--by-label", action="store_true",
help="match nodes by visible label instead of cell id "
"(for hand-drawn diagrams with non-stable ids)")
args = ap.parse_args()
old_n, old_e = parse(args.old)
new_n, new_e = parse(args.new)
def keyed(nodes):
"""Map match-key -> label. By id (default) the key is the cell id and the
value is its label; by label the key *is* the label."""
if args.by_label:
return {lbl: lbl for lbl, _ in nodes.values()}, {i: lbl for i, (lbl, _) in nodes.items()}
return {i: lbl for i, (lbl, _) in nodes.items()}, {i: i for i in nodes}
old_keys, old_id2key = keyed(old_n)
new_keys, new_id2key = keyed(new_n)
nodes, counts = [], {"added": 0, "removed": 0, "changed": 0, "same": 0}
for key in sorted(set(old_keys) | set(new_keys)):
if key in old_keys and key not in new_keys:
status, label = "removed", old_keys[key]
elif key in new_keys and key not in old_keys:
status, label = "added", new_keys[key]
elif old_keys[key] != new_keys[key]: # matched, label moved
status, label = "changed", new_keys[key]
else:
status, label = "same", new_keys[key]
counts[status] += 1
nodes.append({"id": key, "label": label or key, "style": STYLE[status],
"width": 160, "height": 60})
def edge_keys(edges, id2key):
out = set()
for s, t in edges:
if s in id2key and t in id2key:
out.add((id2key[s], id2key[t]))
return out
old_ek, new_ek = edge_keys(old_e, old_id2key), edge_keys(new_e, new_id2key)
node_keys = {n["id"] for n in nodes}
edges = []
for s, t in sorted(old_ek | new_ek):
if s not in node_keys or t not in node_keys:
continue
status = "same" if (s, t) in old_ek and (s, t) in new_ek else \
("added" if (s, t) in new_ek else "removed")
edges.append({"source": s, "target": t, "style": EDGE_STYLE[status]})
graph = {"direction": args.direction, "nodes": nodes, "edges": edges}
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"+{counts['added']} added, -{counts['removed']} removed, "
f"~{counts['changed']} changed, ={counts['same']} unchanged\n")
if __name__ == "__main__":
main()
scripts/drawiohtml.py
#!/usr/bin/env python3
"""Publish a .drawio as a single interactive HTML viewer.
Exports every page to SVG via the draw.io CLI and inlines them into ONE
self-contained .html with pan (drag), zoom (wheel / buttons), page tabs,
node search, and working links — external links open normally and internal
page links ("data:page/id,…", e.g. a C4 model's drill-down) switch tabs
inside the viewer. Share the file with anyone: no draw.io, no server,
no external requests.
python3 drawiohtml.py architecture.drawio -o architecture.html
python3 drawiohtml.py c4.drawio # -> c4.html, drill-down works
Search matches node text (draw.io wraps every cell in <g data-cell-id>);
matches glow, Enter cycles through them and centres each. Internal page
links survive export by being rewritten to "#page-<id>" fragments first
(draw.io drops raw data:page/id links from SVG).
Usage: python3 drawiohtml.py <file.drawio> [-o out.html]
"""
import argparse
import html
import json
import os
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
PAGE_LINK = "data:page/id,"
def pages_of(path):
"""[(id, name)] of the <diagram> pages, in order."""
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
return [(d.get("id") or f"p{i}", d.get("name") or f"Page {i + 1}")
for i, d in enumerate(root.findall("diagram"))]
def rewrite_page_links(tree):
"""data:page/id,X links -> #page-X (fragments survive SVG export). Returns count."""
n = 0
for el in tree.getroot().iter():
link = el.get("link")
if link and link.startswith(PAGE_LINK):
el.set("link", "#page-" + link[len(PAGE_LINK):])
n += 1
return n
def export_svg(drawio_file, index, out_svg):
"""Export one page (1-based index) to SVG via the draw.io CLI."""
r = subprocess.run(["drawio", "-x", "-f", "svg", "--page-index", str(index),
"-o", out_svg, drawio_file], capture_output=True)
return r.returncode == 0 and os.path.exists(out_svg)
def strip_prolog(svg):
"""Drop any XML declaration / doctype so the SVG can be inlined in HTML."""
return re.sub(r"^\s*(<\?xml[^>]*\?>\s*|<!DOCTYPE[^>]*>\s*)*", "", svg)
def build_html(title, page_meta, svgs):
"""One self-contained viewer page. page_meta = [(id, name)] aligned with svgs."""
sections = "\n".join(
f'<div class="page" data-pgid="{html.escape(pid, quote=True)}">{svg}</div>'
for (pid, _), svg in zip(page_meta, svgs))
tabs = json.dumps([{"id": pid, "name": name} for pid, name in page_meta]) \
.replace("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a;height:100vh;display:flex;flex-direction:column}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{padding:10px 16px 8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}}
h1{{margin:0;font-size:15px;font-weight:600}}
nav{{display:flex;gap:6px;flex-wrap:wrap}}
button,input{{font:inherit;color:inherit}}
nav button,.ctl button{{padding:4px 10px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer}}
@media(prefers-color-scheme:dark){{nav button,.ctl button{{background:#262b31;border-color:#fff2}}}}
nav button.on{{border-color:#0d99ff;color:#0d99ff;font-weight:600}}
.ctl{{display:flex;gap:8px;align-items:center;margin-left:auto}}
.ctl input{{padding:4px 10px;border:1px solid #0002;border-radius:8px;background:#fff;width:180px}}
@media(prefers-color-scheme:dark){{.ctl input{{background:#262b31;border-color:#fff2}}}}
#hits{{font-size:12px;color:#889;min-width:56px}}
#stage{{flex:1;overflow:hidden;position:relative;background:#fff;
border-top:1px solid #0001;cursor:grab;touch-action:none}}
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
#stage.drag{{cursor:grabbing}}
.page{{position:absolute;transform-origin:0 0;display:none}}
.page.on{{display:block}}
.page svg{{display:block}}
.hit{{filter:drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 6px #ff980088)}}
.hit.cursel{{filter:drop-shadow(0 0 4px #f44336) drop-shadow(0 0 9px #f44336aa)}}
</style></head><body>
<header><h1>{html.escape(title)}</h1><nav id="tabs"></nav>
<div class="ctl">
<input id="q" type="search" placeholder="Search nodes… Enter = next">
<span id="hits"></span>
<button id="zout" title="zoom out">−</button><button id="zin" title="zoom in">+</button>
<button id="fit">Fit</button>
</div></header>
<main id="stage">
{sections}
</main>
<script>
const META={tabs};
const stage=document.getElementById('stage');
const pages=[...document.querySelectorAll('.page')];
const view=pages.map(()=>({{x:0,y:0,s:1}}));
let cur=0,hits=[],hi=-1;
const tabs=document.getElementById('tabs');
if(META.length>1)META.forEach((m,i)=>{{
const b=document.createElement('button');b.textContent=m.name;
b.onclick=()=>show(i);tabs.appendChild(b);}});
function apply(){{const v=view[cur];
pages[cur].style.transform=`translate(${{v.x}}px,${{v.y}}px) scale(${{v.s}})`;}}
function svgSize(i){{const s=pages[i].querySelector('svg');
return[parseFloat(s.getAttribute('width'))||800,
parseFloat(s.getAttribute('height'))||600];}}
function fit(){{const[w,h]=svgSize(cur),r=stage.getBoundingClientRect(),
s=Math.min((r.width-40)/w,(r.height-40)/h,4);
view[cur]={{s,x:(r.width-w*s)/2,y:(r.height-h*s)/2}};apply();}}
function show(i){{cur=i;
pages.forEach((p,j)=>p.classList.toggle('on',j===i));
[...tabs.children].forEach((b,j)=>b.classList.toggle('on',j===i));
if(!pages[i].dataset.seen){{pages[i].dataset.seen=1;fit();}}else apply();
search();}}
stage.addEventListener('wheel',e=>{{e.preventDefault();const v=view[cur],
r=stage.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top,
k=Math.exp(-e.deltaY*0.0015),s=Math.min(Math.max(v.s*k,0.05),8);
v.x=mx-(mx-v.x)*s/v.s;v.y=my-(my-v.y)*s/v.s;v.s=s;apply();}},{{passive:false}});
let drag=null;
stage.addEventListener('pointerdown',e=>{{if(e.target.closest('a'))return;
drag={{x:e.clientX,y:e.clientY}};stage.classList.add('drag');
stage.setPointerCapture(e.pointerId);}});
stage.addEventListener('pointermove',e=>{{if(!drag)return;const v=view[cur];
v.x+=e.clientX-drag.x;v.y+=e.clientY-drag.y;
drag={{x:e.clientX,y:e.clientY}};apply();}});
stage.addEventListener('pointerup',()=>{{drag=null;stage.classList.remove('drag');}});
function zoom(k){{const v=view[cur],r=stage.getBoundingClientRect(),
mx=r.width/2,my=r.height/2,s=Math.min(Math.max(v.s*k,0.05),8);
v.x=mx-(mx-v.x)*s/v.s;v.y=my-(my-v.y)*s/v.s;v.s=s;apply();}}
document.getElementById('zin').onclick=()=>zoom(1.25);
document.getElementById('zout').onclick=()=>zoom(0.8);
document.getElementById('fit').onclick=fit;
// Internal page links: any anchor whose target ends in #page-<id> switches tabs.
document.addEventListener('click',e=>{{const a=e.target.closest('a');
if(!a)return;const href=a.getAttribute('xlink:href')||a.getAttribute('href')||'';
const m=href.match(/#page-(.+)$/);if(!m)return;e.preventDefault();
const i=META.findIndex(p=>p.id===decodeURIComponent(m[1]));if(i>=0)show(i);}},true);
const q=document.getElementById('q'),hitEl=document.getElementById('hits');
function search(){{
hits.forEach(g=>g.classList.remove('hit','cursel'));hits=[];hi=-1;
const t=q.value.trim().toLowerCase();
if(t){{
const all=[...pages[cur].querySelectorAll('g[data-cell-id]')]
.filter(g=>!['0','1'].includes(g.dataset.cellId))
.filter(g=>g.textContent.toLowerCase().includes(t));
hits=all.filter(g=>!all.some(o=>o!==g&&g.contains(o))); // innermost only
hits.forEach(g=>g.classList.add('hit'));
}}
hitEl.textContent=t?hits.length+' hit'+(hits.length===1?'':'s'):'';}}
function centre(g){{const v=view[cur],r=stage.getBoundingClientRect(),
b=g.getBoundingClientRect();
v.x+=r.left+r.width/2-(b.left+b.width/2);
v.y+=r.top+r.height/2-(b.top+b.height/2);apply();}}
q.addEventListener('input',search);
q.addEventListener('keydown',e=>{{
if(e.key==='Escape'){{q.value='';search();q.blur();}}
if(e.key!=='Enter'||!hits.length)return;
if(hi>=0)hits[hi].classList.remove('cursel');
hi=(hi+1)%hits.length;hits[hi].classList.add('cursel');centre(hits[hi]);}});
show(0);
</script></body></html>
"""
def main():
ap = argparse.ArgumentParser(description="Export a .drawio to a self-contained interactive HTML viewer.")
ap.add_argument("file")
ap.add_argument("-o", "--output", help="output .html (default: alongside input)")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
meta = pages_of(args.file)
if not meta:
sys.exit(f"error: no <diagram> pages in {args.file}")
tree = ET.parse(args.file)
relinked = rewrite_page_links(tree)
svgs, kept = [], []
with tempfile.TemporaryDirectory() as tmp:
src = args.file
if relinked: # export the rewritten copy instead
src = os.path.join(tmp, "relinked.drawio")
tree.write(src, encoding="utf-8", xml_declaration=False)
for i, (pid, name) in enumerate(meta, 1): # draw.io --page-index is 1-based
out = os.path.join(tmp, f"p{i}.svg")
if not export_svg(src, i, out):
sys.stderr.write(f"warning: page {i} ({name}) export failed — skipped\n")
continue
with open(out, encoding="utf-8") as f:
svgs.append(strip_prolog(f.read()))
kept.append((pid, name))
if not svgs:
sys.exit("error: no pages exported (is the draw.io CLI installed?)")
title = os.path.splitext(os.path.basename(args.file))[0]
out = args.output or os.path.splitext(args.file)[0] + ".html"
with open(out, "w", encoding="utf-8") as f:
f.write(build_html(title, kept, svgs))
sys.stderr.write(f"wrote {out} ({len(svgs)} page{'s' if len(svgs) != 1 else ''}"
+ (f", {relinked} drill-down link{'s' if relinked != 1 else ''}" if relinked else "")
+ ")\n")
if __name__ == "__main__":
main()
scripts/edgeports.py
#!/usr/bin/env python3
"""Assign edge connection points (ports) on an existing .drawio file.
draw.io's floating connections attach every edge of a node to the middle of
whichever side faces the other endpoint. When a node has several connections
leaving the same side they all land on the same point, so the lines stack and
overlap — the usual complaint on swimlane / cross-functional flowcharts, where
handoff edges between lanes share long orthogonal corridors.
This pass pins ``exitX/exitY`` and ``entryX/entryY`` instead:
1. Resolve every vertex to absolute coordinates (through swimlane/container
parents, via ``validate.abs_rect``).
2. For each edge end, pick the side of the node that faces the other endpoint
(whichever of dx/dy dominates, measured centre-to-centre).
3. Group the ends by (node, side) and sort each group along the side by the far
endpoint's position across that axis. Sorting by the far endpoint is what
removes crossings: two edges leaving the same side keep their relative order
instead of swapping over each other.
4. Spread the group evenly over the side — k ends get slots 1/(k+1) .. k/(k+1).
Only sides with 2+ ends are touched. Edges that already pin a port are left
alone, so hand-tuned geometry survives a re-run. Idempotent: running it twice
produces the same file.
This is a *port* assignment, not a router — it fixes lines stacking at the
shape boundary, not an edge crossing an unrelated shape in the middle of its
run. For that, add waypoints (see references/xml-authoring.md).
Usage:
python3 edgeports.py diagram.drawio # in place
python3 edgeports.py diagram.drawio -o routed.drawio
python3 edgeports.py diagram.drawio --dry-run # report only
"""
import argparse
import importlib.util
import os
import sys
import xml.etree.ElementTree as ET
_spec = importlib.util.spec_from_file_location(
"validate", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validate.py"))
validate = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(validate)
# Port coordinates per side. Each entry is (fixed_axis_value, varies_along_x).
# 'varies_along_x' says which coordinate the evenly-spaced slot fills in.
SIDES = {
"N": (0.0, True), # top edge: y=0, x varies
"S": (1.0, True), # bottom edge: y=1, x varies
"W": (0.0, False), # left edge: x=0, y varies
"E": (1.0, False), # right edge: x=1, y varies
}
def centre(r):
x, y, w, h = r
return (x + w / 2.0, y + h / 2.0)
def side_facing(src_rect, dst_rect):
"""Which side of src faces dst: whichever of dx/dy dominates."""
sx, sy = centre(src_rect)
dx_, dy_ = centre(dst_rect)
dx, dy = dx_ - sx, dy_ - sy
if abs(dx) >= abs(dy):
return "E" if dx >= 0 else "W"
return "S" if dy >= 0 else "N"
def has_port(style, end):
"""True if the edge already pins this end's port (hand-tuned — leave it)."""
prefix = "exit" if end == "source" else "entry"
return (validate.style_num(style, prefix + "X") is not None
and validate.style_num(style, prefix + "Y") is not None)
def set_style(style, end, px, py):
"""Return style with this end's port keys set, other keys order-preserved."""
prefix = "exit" if end == "source" else "entry"
drop = {prefix + "X", prefix + "Y", prefix + "Dx", prefix + "Dy"}
parts = [p for p in (style or "").split(";")
if p and p.split("=", 1)[0] not in drop]
# Dx/Dy are perpendicular offsets in px; reset them so a re-run is stable.
parts += [f"{prefix}X={px:g}", f"{prefix}Y={py:g}",
f"{prefix}Dx=0", f"{prefix}Dy=0"]
return ";".join(parts) + ";"
def assign(cells, by_id):
"""Compute {(edge_elem, end): (px, py)} for every end worth pinning."""
rects = {}
for c in cells:
if c.get("vertex") == "1" and not validate.is_edge_label(c):
r = validate.abs_rect(c, by_id)
if r and not any(v != v for v in r): # NaN width/height guard
rects[c.get("id")] = r
# Collect ends: one entry per (edge, end) whose node and peer are known.
groups = {}
for e in cells:
if e.get("edge") != "1":
continue
style = e.get("style") or ""
src, dst = e.get("source"), e.get("target")
if src not in rects or dst not in rects:
continue # dangling — validate.py's job
for end, me, peer in (("source", src, dst), ("target", dst, src)):
if has_port(style, end):
continue
side = side_facing(rects[me], rects[peer])
groups.setdefault((me, side), []).append((e, end, rects[peer]))
ports = {}
for (node_id, side), ends in groups.items():
if len(ends) < 2:
continue # single edge: centre is fine
fixed, along_x = SIDES[side]
# Sort by the far endpoint's position across the axis we spread along.
# Tie-break on the other axis, then edge id, so the order is total and
# the output is deterministic.
ends.sort(key=lambda t: (centre(t[2])[0] if along_x else centre(t[2])[1],
centre(t[2])[1] if along_x else centre(t[2])[0],
t[0].get("id") or ""))
for i, (edge, end, _) in enumerate(ends):
slot = (i + 1) / float(len(ends) + 1)
ports[(edge, end)] = (slot, fixed) if along_x else (fixed, slot)
return ports
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("file", help="input .drawio")
ap.add_argument("-o", "--output", help="output path (default: edit in place)")
ap.add_argument("--dry-run", action="store_true",
help="report what would change, write nothing")
args = ap.parse_args()
try:
tree = ET.parse(args.file)
except ET.ParseError as exc:
sys.exit(f"error: {args.file} is not parseable XML ({exc}). "
"Compressed .drawio files must be saved uncompressed first.")
total = 0
for model in tree.getroot().iter("mxGraphModel"):
cells = list(model.iter("mxCell"))
by_id = {c.get("id"): c for c in cells if c.get("id")}
ports = assign(cells, by_id)
for (edge, end), (px, py) in ports.items():
edge.set("style", set_style(edge.get("style") or "", end, px, py))
total += len(ports)
if args.dry_run:
print(f"{total} edge end(s) would be pinned in {args.file}")
return
out = args.output or args.file
tree.write(out, encoding="utf-8", xml_declaration=False)
print(f"{total} edge end(s) pinned -> {out}")
def demo():
"""Self-check: three edges leaving one node's east side get distinct,
non-crossing ports, and a re-run is a no-op."""
xml = """<mxfile><diagram><mxGraphModel><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="lane" vertex="1" parent="1">
<mxGeometry x="100" y="0" width="400" height="400" as="geometry"/></mxCell>
<mxCell id="hub" vertex="1" parent="lane">
<mxGeometry x="0" y="150" width="80" height="40" as="geometry"/></mxCell>
<mxCell id="a" vertex="1" parent="1">
<mxGeometry x="600" y="300" width="80" height="40" as="geometry"/></mxCell>
<mxCell id="b" vertex="1" parent="1">
<mxGeometry x="600" y="100" width="80" height="40" as="geometry"/></mxCell>
<mxCell id="c" vertex="1" parent="1">
<mxGeometry x="600" y="200" width="80" height="40" as="geometry"/></mxCell>
<mxCell id="e1" edge="1" parent="1" source="hub" target="a" style="rounded=1;"/>
<mxCell id="e2" edge="1" parent="1" source="hub" target="b" style="rounded=1;"/>
<mxCell id="e3" edge="1" parent="1" source="hub" target="c" style="rounded=1;"/>
<mxCell id="e4" edge="1" parent="1" source="hub" target="a"
style="rounded=1;exitX=1;exitY=0.9;"/>
</root></mxGraphModel></diagram></mxfile>"""
root = ET.fromstring(xml)
cells = list(root.iter("mxCell"))
by_id = {c.get("id"): c for c in cells if c.get("id")}
# hub sits inside 'lane' (x=100), so its absolute x is 100, not 0. Without
# parent resolution every target would look like it was to the west.
assert validate.abs_rect(by_id["hub"], by_id)[0] == 100.0
ports = assign(cells, by_id)
exits = {e.get("id"): p for (e, end), p in ports.items() if end == "source"}
assert set(exits) == {"e1", "e2", "e3"}, exits # e4 pre-pinned, untouched
assert all(x == 1.0 for x, _ in exits.values()) # all leave the east side
ys = [exits[i][1] for i in ("e2", "e3", "e1")] # targets ordered top->bottom
assert ys == sorted(ys), ys # ports follow => no crossing
assert len(set(ys)) == 3, ys # and no two stack
for (edge, end), (px, py) in ports.items():
edge.set("style", set_style(edge.get("style"), end, px, py))
assert not assign(cells, by_id), "second run must be a no-op"
print("ok")
if __name__ == "__main__":
if "--demo" in sys.argv:
demo()
else:
main()
scripts/encode_drawio_url.py
#!/usr/bin/env python3
"""Encode a .drawio XML file into a diagrams.net browser URL.
Used as the browser fallback when the draw.io desktop CLI is unavailable.
The diagram XML is carried in the URL fragment (after `#`), so nothing is
uploaded to any server.
Two modes:
(default) read-only viewer -> https://viewer.diagrams.net/...#R<payload>
--edit editable editor -> https://app.diagrams.net/...#create=<payload>
Usage: python3 encode_drawio_url.py [--edit] <path/to/input.drawio>
"""
import base64
import json
import sys
import urllib.parse
import zlib
def _deflate_b64(xml: str) -> str:
# draw.io's loader runs JS decodeURIComponent on the inflated string, so the
# XML MUST be percent-encoded (encodeURIComponent) BEFORE deflate — otherwise
# a literal `%` or any non-ASCII (e.g. CJK) label makes the browser throw
# "URI malformed" and the diagram never opens. encodeURIComponent leaves
# only A-Za-z0-9 and -_.!~*'() unescaped, which `quote` reproduces here.
pre = urllib.parse.quote(xml, safe="!~*'()")
c = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
compressed = c.compress(pre.encode("utf-8")) + c.flush()
# Standard base64 (atob rejects url-safe -/_); strip newlines.
return base64.b64encode(compressed).decode("utf-8").replace("\n", "")
def encode(xml: str) -> str:
"""Read-only viewer URL (mxGraph `#R` raw-inflate format)."""
return (
"https://viewer.diagrams.net/?tags=%7B%7D&lightbox=1&edit=_blank#R"
+ urllib.parse.quote(_deflate_b64(xml), safe="")
)
def edit_url(xml: str) -> str:
"""Editable editor URL — opens directly in the draw.io editor."""
payload = json.dumps({"type": "xml", "compressed": True, "data": _deflate_b64(xml)})
return (
"https://app.diagrams.net/?grid=0&pv=0&border=10&edit=_blank#create="
+ urllib.parse.quote(payload, safe="")
)
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if a != "--edit"]
if len(args) != 1:
print("usage: encode_drawio_url.py [--edit] <path>", file=sys.stderr)
sys.exit(2)
with open(args[0], "r", encoding="utf-8") as f:
xml = f.read()
print(edit_url(xml) if "--edit" in sys.argv[1:] else encode(xml))
scripts/explain.py
#!/usr/bin/env python3
"""Read a .drawio and describe it as structured Markdown.
The inverse of the skill's generators: instead of data -> diagram, this turns a
diagram -> documentation. It lists the components (grouped by their container /
swimlane / tier), the relations between them (edge labels become the relation
verb), and a per-page breakdown for multi-page files (e.g. a C4 model). Handy
for dropping an architecture summary into a README or PR, or for a text-only
description of a diagram someone handed you.
python3 explain.py architecture.drawio # Markdown to stdout
python3 explain.py c4.drawio -o architecture.md
Components are the leaf vertices; a vertex that contains others is treated as a
container and becomes a grouping heading. Relations read `source -> target`,
annotated with the edge label when present. A handful of common shapes are
named (data store, actor, decision, queue, cloud, and AWS/Azure/GCP/Kubernetes
vendor icons). UserObject/object wrappers are unwrapped; compressed pages are
reported but cannot be described (this skill always writes uncompressed XML).
Usage: python3 explain.py <file.drawio> [-o out.md]
"""
import argparse
import html
import re
import sys
import xml.etree.ElementTree as ET
# style fragment -> human noun. First match wins; order matters (specific first).
SHAPE_TYPES = [
("mxgraph.aws", "AWS"), ("img/lib/azure", "Azure"), ("mxgraph.gcp", "GCP"),
("mxgraph.kubernetes", "Kubernetes"), ("umlActor", "actor"), ("shape=actor", "actor"),
("shape=cylinder", "data store"), ("shape=datastore", "data store"),
("shape=cloud", "cloud"), ("rhombus", "decision"), ("mscae", "Azure"),
("shape=process", "process"), ("shape=hexagon", "queue"),
]
def clean(text):
"""Strip HTML tags/entities draw.io stores in labels; collapse whitespace."""
if not text:
return ""
text = re.sub(r"<br\s*/?>", " ", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
return re.sub(r"\s+", " ", html.unescape(text)).strip()
def shape_of(style):
for frag, noun in SHAPE_TYPES:
if frag in (style or ""):
return noun
return None
def cells_of(page):
"""[(cell, id, label)] for a page, unwrapping UserObject/object wrappers."""
model = page.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
return None # compressed / empty page
out = []
for child in root:
if child.tag == "mxCell":
out.append((child, child.get("id"), clean(child.get("value"))))
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
out.append((inner, child.get("id"),
clean(child.get("label") or child.get("value"))))
return out
def describe_page(page):
"""Markdown body lines for one <diagram> page (no page heading)."""
cells = cells_of(page)
if cells is None:
return ["_(compressed page — cannot describe)_"]
label = {cid: lbl for _, cid, lbl in cells}
style = {cid: (c.get("style") or "") for c, cid, _ in cells}
parents = {c.get("parent") for c, _, _ in cells if c.get("parent")}
vertices = [(c, cid) for c, cid, _ in cells if c.get("vertex") == "1"]
containers = {cid for c, cid in vertices if cid in parents} # holds other cells
leaves = [(c, cid) for c, cid in vertices
if cid not in containers and "edgeLabel" not in style.get(cid, "")]
# Group leaves by their container's label (else "Ungrouped").
groups, order = {}, []
for c, cid in leaves:
parent = c.get("parent")
gname = label.get(parent) or "" if parent in containers else ""
gname = gname or "Ungrouped"
if gname not in groups:
groups[gname] = []
order.append(gname)
typ = shape_of(style.get(cid, ""))
name = label.get(cid) or f"(unlabeled {cid})"
groups[gname].append(f"{name}" + (f" _{typ}_" if typ else ""))
lines = [f"### Components ({len(leaves)})", ""]
single = len(order) == 1 and order[0] == "Ungrouped"
for gname in order:
if not single:
lines.append(f"- **{gname}**")
lines += [f" - {item}" for item in groups[gname]]
else:
lines += [f"- {item}" for item in groups[gname]]
lines.append("")
edges = [c for c, _, _ in cells if c.get("edge") == "1"]
rels = []
for e in edges:
s, t = label.get(e.get("source")), label.get(e.get("target"))
if not s or not t: # dangling endpoint — skip
continue
verb = clean(e.get("value"))
rels.append(f"- {s} —{verb}→ {t}" if verb else f"- {s} → {t}")
lines.append(f"### Relations ({len(rels)})")
lines.append("")
lines += rels or ["_(none)_"]
lines.append("")
return lines
def main():
ap = argparse.ArgumentParser(description="Describe a .drawio diagram as Markdown.")
ap.add_argument("file")
ap.add_argument("-o", "--output", help="output Markdown path (default: stdout)")
args = ap.parse_args()
try:
tree = ET.parse(args.file)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {args.file}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
title = args.file.rsplit("/", 1)[-1].rsplit(".", 1)[0]
lines = [f"# {title}", ""]
for i, page in enumerate(pages, 1):
name = page.get("name")
if len(pages) > 1:
lines.append(f"## Page {i}: {name}" if name else f"## Page {i}")
lines.append("")
lines += describe_page(page)
text = "\n".join(lines).rstrip() + "\n"
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
scripts/goimports.py
#!/usr/bin/env python3
"""Extract a Go module's package-import graph as autolayout graph JSON.
The Go counterpart to pyimports.py / jsimports.py. Reads the module path from
go.mod, walks the module, treats each directory of .go files as one package
(node = its import path), and records the intra-module package imports. Stdlib
and third-party imports are ignored. Transitive reduction is on by default so
the diagram stays readable.
python3 goimports.py ./mymodule -o graph.json
python3 autolayout.py graph.json -o diagram.drawio
Parsing is regex-based over `import` statements (single and block form),
which is enough for a structural package graph. *_test.go files and vendor/
are skipped.
Usage: python3 goimports.py <module_dir> [-o graph.json] [--direction TB|LR]
[--group] [--no-reduce]
"""
import argparse
import json
import os
import re
import subprocess
import sys
MODULE = re.compile(r"^module\s+(\S+)", re.MULTILINE)
BLOCK = re.compile(r"import\s*\((.*?)\)", re.DOTALL)
SINGLE = re.compile(r'import\s+(?:[\w.]+\s+|_\s+)?"([^"]+)"')
QUOTED = re.compile(r'"([^"]+)"')
def module_path(root):
"""Read the `module` path from go.mod at the module root, or None."""
gomod = os.path.join(root, "go.mod")
if not os.path.exists(gomod):
return None
m = MODULE.search(open(gomod, encoding="utf-8", errors="ignore").read())
return m.group(1) if m else None
def discover(root, modpath):
"""Map package import path -> list of .go files (one entry per directory)."""
root = os.path.abspath(root)
pkgs = {}
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs
if d not in ("vendor", "testdata") and not d.startswith(".")]
gofiles = [os.path.join(dirpath, f) for f in files
if f.endswith(".go") and not f.endswith("_test.go")]
if not gofiles:
continue
rel = os.path.relpath(dirpath, root).replace(os.sep, "/")
ip = modpath if rel == "." else f"{modpath}/{rel}"
pkgs[ip] = gofiles
return pkgs
def imports_of(files, modpath, pkgs):
"""Intra-module package import paths referenced by a package's files."""
found = set()
for path in files:
try:
src = open(path, encoding="utf-8", errors="ignore").read()
except OSError:
continue
specs = []
for block in BLOCK.findall(src):
specs += QUOTED.findall(block)
specs += SINGLE.findall(src)
for spec in specs:
if (spec == modpath or spec.startswith(modpath + "/")) and spec in pkgs:
found.add(spec)
return found
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def main():
ap = argparse.ArgumentParser(description="Go import graph -> autolayout graph JSON.")
ap.add_argument("module", help="module directory (contains go.mod)")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group nodes into containers by top-level package dir")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
args = ap.parse_args()
modpath = module_path(args.module)
if not modpath:
sys.exit(f"error: no go.mod with a module path found in {args.module}")
pkgs = discover(args.module, modpath)
if not pkgs:
sys.exit(f"error: no Go packages found under {args.module}")
edges = sorted({(ip, t) for ip, files in pkgs.items()
for t in imports_of(files, modpath, pkgs) if t != ip})
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce(list(pkgs), edges)
# Drop the module prefix from labels for readability; ids stay full.
strip = modpath + "/"
label = lambda ip: ip[len(strip):] if ip.startswith(strip) else os.path.basename(ip)
def node(ip):
d = {"id": ip, "label": label(ip)}
if args.group:
rest = label(ip).split("/")
if len(rest) > 1: # nested under a sub-package
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
return d
graph = {
"direction": args.direction,
"nodes": [node(ip) for ip in pkgs],
"edges": [{"source": s, "target": t} for s, t in edges],
}
text = json.dumps(graph, indent=2)
if args.output:
open(args.output, "w", encoding="utf-8").write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(pkgs)} packages, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()
scripts/heatmap.py
#!/usr/bin/env python3
"""Colour a diagram by data — turn a .drawio into a metric heatmap.
Reads a .drawio and a metrics file (CSV `key,value` or JSON `{key: value}`),
matches each metric key to a node by cell id or by its label text, and recolours
that node along a gradient from the lowest value to the highest. Optionally
scales node size by value (`--size`) and drops in a legend. The result is a new
.drawio you can export like any other — an architecture diagram that now shows
cost / latency / traffic / error-rate as a heat map.
python3 heatmap.py architecture.drawio --metrics latency.csv -o hot.drawio
python3 heatmap.py architecture.drawio -m cost.json --palette cool --size
Metrics match on cell id first, then on the (HTML-stripped) label, case-
insensitively. Unmatched nodes keep their original style. `--palette` picks the
colour ramp (heat|cool|warm), `--reverse` flips it (so low = hot).
Usage: python3 heatmap.py <file.drawio> --metrics <file.csv|json> [-o out.drawio]
[--palette heat|cool|warm] [--reverse] [--size] [--no-legend]
"""
import argparse
import csv
import html
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
# Sequential ramps as (low, mid, high) anchor colours; value is lerped across them.
PALETTES = {
"heat": ("#57bb8a", "#ffd666", "#e67c73"), # green -> yellow -> red
"cool": ("#deebf7", "#6baed6", "#08519c"), # light -> deep blue
"warm": ("#fff7bc", "#fec44f", "#d95f0e"), # pale -> deep amber
}
def clean(text):
"""Strip the HTML tags/entities draw.io stores in labels; collapse whitespace."""
if not text:
return ""
text = re.sub(r"<br\s*/?>", " ", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
return re.sub(r"\s+", " ", html.unescape(text)).strip()
def _num(x):
try:
return float(x)
except (TypeError, ValueError):
return None
def load_metrics(path):
"""{key: float} from a JSON object/list or a CSV/TSV (first col key, last numeric col value)."""
with open(path, encoding="utf-8") as f:
text = f.read()
if path.lower().endswith(".json"):
data = json.loads(text)
if isinstance(data, dict):
return {str(k): _num(v) for k, v in data.items() if _num(v) is not None}
out = {}
for row in data:
key = row.get("id") or row.get("key") or row.get("name") or row.get("label")
val = _num(row.get("value", row.get("val", row.get("metric"))))
if key is not None and val is not None:
out[str(key)] = val
return out
delim = "\t" if text.splitlines() and "\t" in text.splitlines()[0] else ","
out = {}
for row in csv.reader(text.splitlines(), delimiter=delim):
if len(row) < 2:
continue
v = _num(row[-1])
if v is not None: # skip header / non-numeric rows
out[row[0].strip()] = v
return out
def _rgb(c):
c = c.lstrip("#")
return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
def ramp(anchors, t):
"""t in [0,1] -> #rrggbb across a 3-stop (low, mid, high) ramp."""
lo, mid, hi = (_rgb(c) for c in anchors)
a, b, u = (lo, mid, t * 2) if t < 0.5 else (mid, hi, (t - 0.5) * 2)
return "#%02x%02x%02x" % tuple(round(a[i] + (b[i] - a[i]) * u) for i in range(3))
def darker(hexcolor, f=0.6):
return "#%02x%02x%02x" % tuple(round(c * f) for c in _rgb(hexcolor))
def set_style(style, fill, stroke):
"""Replace/insert fillColor & strokeColor in a draw.io style string."""
s = re.sub(r"(fillColor|strokeColor)=[^;]*;?", "", style or "").strip("; ")
return (s + ";" if s else "") + f"fillColor={fill};strokeColor={stroke};"
def vertices(root):
"""Yield (cell, id, label) for every vertex, unwrapping UserObject/object wrappers."""
for child in root:
if child.tag == "mxCell" and child.get("vertex") == "1":
yield child, child.get("id"), clean(child.get("value"))
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None and inner.get("vertex") == "1":
yield inner, child.get("id"), clean(child.get("label") or child.get("value"))
def scale_geom(cell, factor):
"""Grow/shrink a cell about its centre by `factor`."""
g = cell.find("mxGeometry")
if g is None:
return
w, h = _num(g.get("width")), _num(g.get("height"))
if w is None or h is None:
return
nw, nh = w * factor, h * factor
g.set("width", "%g" % nw)
g.set("height", "%g" % nh)
for attr, delta in (("x", (nw - w) / 2), ("y", (nh - h) / 2)):
v = _num(g.get(attr))
if v is not None:
g.set(attr, "%g" % (v - delta))
def color_for(val, lo, hi, anchors, reverse):
t = (val - lo) / (hi - lo) if hi > lo else 0.5
fill = ramp(anchors, 1 - t if reverse else t)
return fill, darker(fill), t
def content_bounds(root):
"""(min_x, min_y) of the page's top-level cells, so the legend sits clear of them."""
xs, ys = [], []
for child in root:
cell = child if child.tag == "mxCell" else child.find("mxCell")
if cell is None or cell.get("parent") != "1": # skip nested (relative-coord) cells
continue
g = cell.find("mxGeometry")
x, y = (_num(g.get("x")), _num(g.get("y"))) if g is not None else (None, None)
if x is not None and y is not None:
xs.append(x)
ys.append(y)
return (min(xs) if xs else 20, min(ys) if ys else 20)
def add_legend(root, anchors, lo, hi, reverse):
"""Drop a small min/mid/max swatch legend just left of the page's content."""
w, h = 90, 26
cx, cy = content_bounds(root)
x0, y0 = cx - w - 40, cy # to the left of the diagram
def cell(cid, value, style, gy, gh):
c = ET.SubElement(root, "mxCell", {"id": cid, "value": value, "style": style,
"vertex": "1", "parent": "1"})
ET.SubElement(c, "mxGeometry", {"x": "%g" % x0, "y": "%g" % gy,
"width": "%g" % w, "height": "%g" % gh, "as": "geometry"})
cell("hm-title", "Heatmap",
"text;html=1;fontStyle=1;align=left;verticalAlign=middle;", y0, 20)
for i, val in enumerate((hi, (lo + hi) / 2, lo)):
fill, stroke, _ = color_for(val, lo, hi, anchors, reverse)
cell("hm-%d" % i, "%g" % val,
set_style("rounded=0;whiteSpace=wrap;html=1;", fill, stroke),
y0 + 24 + i * (h + 4), h)
def main():
ap = argparse.ArgumentParser(description="Recolour a .drawio into a metric heatmap.")
ap.add_argument("file", help="input .drawio (uncompressed)")
ap.add_argument("-m", "--metrics", required=True, help="metrics .csv or .json")
ap.add_argument("-o", "--output", help="output .drawio (default: <name>-heat.drawio)")
ap.add_argument("--palette", default="heat", choices=list(PALETTES))
ap.add_argument("--reverse", action="store_true", help="flip the ramp (low = hot)")
ap.add_argument("--size", action="store_true", help="also scale node size by value")
ap.add_argument("--no-legend", dest="legend", action="store_false", help="skip the legend")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
if not os.path.isfile(args.metrics):
sys.exit(f"error: {args.metrics} not found")
metrics = load_metrics(args.metrics)
if not metrics:
sys.exit(f"error: no numeric metrics parsed from {args.metrics}")
low_map = {k.lower(): v for k, v in metrics.items()}
anchors = PALETTES[args.palette]
lo, hi = min(metrics.values()), max(metrics.values())
tree = ET.parse(args.file)
matched, first_root = 0, None
for diagram in tree.getroot().iter("diagram"):
model = diagram.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None: # compressed / empty page
continue
if first_root is None:
first_root = root
for cell, cid, label in list(vertices(root)):
val = metrics.get(cid)
if val is None and label:
val = metrics.get(label, low_map.get(label.lower()))
if val is None:
continue
fill, stroke, t = color_for(val, lo, hi, anchors, args.reverse)
cell.set("style", set_style(cell.get("style"), fill, stroke))
if args.size and hi > lo:
scale_geom(cell, 0.7 + 0.8 * t)
matched += 1
if args.legend and first_root is not None and matched:
add_legend(first_root, anchors, lo, hi, args.reverse)
out = args.output or os.path.splitext(args.file)[0] + "-heat.drawio"
tree.write(out, encoding="utf-8", xml_declaration=False)
if matched == 0:
sys.stderr.write("warning: no nodes matched any metric key (check ids/labels)\n")
sys.stderr.write(f"wrote {out} ({matched}/{len(metrics)} metrics matched)\n")
if __name__ == "__main__":
main()
scripts/jsimports.py
#!/usr/bin/env python3
"""Extract a JS/TS project's module-import graph as autolayout graph JSON.
The JavaScript/TypeScript counterpart to pyimports.py: walks a source
directory, scans each module for static and dynamic import specifiers
(`import`/`export ... from`, `require()`, `import()`), keeps only the
intra-project edges (relative specifiers that resolve to a file under the
root), and applies transitive reduction so the diagram stays readable.
python3 jsimports.py src -o graph.json
python3 autolayout.py graph.json -o diagram.drawio
Resolution is path-based: relative specifiers are resolved against the
importing file's directory, trying the .ts/.tsx/.js/.jsx/.mjs/.cjs extensions
and directory index files. Bare specifiers (node_modules packages such as
"react") are ignored. Scanning is regex-based rather than a full parser, so a
specifier inside a comment or string literal is counted in rare cases.
Usage: python3 jsimports.py <src_dir> [-o graph.json] [--direction TB|LR]
[--group] [--no-reduce]
"""
import argparse
import json
import os
import re
import subprocess
import sys
EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
SPEC = re.compile(
r"(?:import|export)\b[^'\";]*?\bfrom\s*['\"]([^'\"]+)['\"]" # import/export ... from "x"
r"|import\s*['\"]([^'\"]+)['\"]" # import "x" (side effect)
r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # require("x")
r"|import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # import("x") dynamic
)
def modid(path, root):
"""Module id = path relative to root, extension stripped, posix separators."""
rel = os.path.relpath(path, root)
for ext in EXTS:
if rel.endswith(ext):
rel = rel[: -len(ext)]
break
return rel.replace(os.sep, "/")
def discover(root):
"""Map module id -> absolute file path for every source file under root."""
root = os.path.abspath(root)
modules = {}
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d != "node_modules" and not d.startswith(".")]
for fn in files:
if fn.endswith(EXTS) and not fn.endswith(".d.ts"):
full = os.path.join(dirpath, fn)
modules[modid(full, root)] = full
return modules, root
def resolve(spec, importer, root, modules):
"""Resolve a relative specifier to a known module id, or None (external)."""
if not spec.startswith("."):
return None
base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
candidates = ([base + e for e in EXTS]
+ [os.path.join(base, "index" + e) for e in EXTS]
+ [base])
for cand in candidates:
mid = modid(cand, root)
if mid in modules and modules[mid] != importer:
return mid
return None
def edges_of(mid, path, root, modules):
"""Intra-project modules imported by module `mid`."""
found = set()
try:
src = open(path, encoding="utf-8", errors="ignore").read()
except OSError:
return found
for m in SPEC.finditer(src):
spec = m.group(1) or m.group(2) or m.group(3) or m.group(4)
target = resolve(spec, path, root, modules)
if target and target != mid:
found.add(target)
return found
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def common_dir(ids):
"""Longest shared leading path segment across module ids (e.g. 'src/')."""
common = []
for parts in zip(*[m.split("/") for m in ids]):
if len(set(parts)) == 1:
common.append(parts[0])
else:
break
return "/".join(common) + "/" if common else ""
def main():
ap = argparse.ArgumentParser(description="JS/TS import graph -> autolayout graph JSON.")
ap.add_argument("src", help="source directory")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group nodes into containers by top-level directory")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
args = ap.parse_args()
modules, root = discover(args.src)
if not modules:
sys.exit(f"error: no JS/TS modules found under {args.src}")
edges = sorted({(m, t) for m, p in modules.items()
for t in edges_of(m, p, root, modules)})
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce(list(modules), edges)
strip = common_dir(list(modules))
label = lambda m: (m[len(strip):] if strip and m.startswith(strip) else m) or m
def node(m):
d = {"id": m, "label": label(m)}
if args.group:
rest = label(m).split("/")
if len(rest) > 1: # has a sub-directory
d["group"] = "/".join(rest[:-1]) # full directory path -> nested boxes
return d
graph = {
"direction": args.direction,
"nodes": [node(m) for m in modules],
"edges": [{"source": s, "target": t} for s, t in edges],
}
text = json.dumps(graph, indent=2)
if args.output:
open(args.output, "w", encoding="utf-8").write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()
scripts/k8simports.py
#!/usr/bin/env python3
"""Extract a Kubernetes manifest set's object graph as autolayout graph JSON.
Reads manifest files (a directory, or explicit .yaml/.yml/.json paths), maps
each object kind to its official draw.io Kubernetes icon (mxgraph.kubernetes
set, resolved from the bundled shape index), and derives the reference edges
that make an architecture readable:
Ingress -> Service (spec backend service names)
Service -> workload (selector labels match the pod template)
workload -> ConfigMap/Secret (env / envFrom / volumes)
workload -> PVC (persistentVolumeClaim volumes)
HPA -> target workload (scaleTargetRef)
Workloads: Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, Pod.
Edges only land on objects that are themselves in the manifest set. The output
feeds autolayout.py:
python3 k8simports.py ./manifests -o graph.json
python3 autolayout.py graph.json -o cluster.drawio
JSON input (single object, or a `kind: List` as produced by
`kubectl get ... -o json`) parses with the stdlib alone; .yaml/.yml files
need PyYAML (`pip install pyyaml`). Pass `-` to read a live cluster snapshot
from stdin: `kubectl get all,ing,cm,secret,pvc -o json | k8simports.py -`.
Usage: python3 k8simports.py <dir-or-manifest...|-> [-o graph.json]
[--direction TB|LR] [--group] [--no-icons]
"""
import argparse
import glob
import gzip
import json
import os
import re
import sys
# Object kind -> prIcon name inside the mxgraph.kubernetes.icon2 shape set.
KIND_ICON = {
"ClusterRole": "c-role", "ClusterRoleBinding": "crb", "ConfigMap": "cm",
"CronJob": "cronjob", "CustomResourceDefinition": "crd", "DaemonSet": "ds",
"Deployment": "deploy", "Endpoints": "ep", "HorizontalPodAutoscaler": "hpa",
"Ingress": "ing", "Job": "job", "Namespace": "ns", "NetworkPolicy": "netpol",
"Node": "node", "PersistentVolume": "pv", "PersistentVolumeClaim": "pvc",
"Pod": "pod", "ReplicaSet": "rs", "Role": "role", "RoleBinding": "rb",
"Secret": "secret", "Service": "svc", "ServiceAccount": "sa",
"StatefulSet": "sts", "StorageClass": "sc",
}
WORKLOADS = {"Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job", "CronJob", "Pod"}
_INDEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data",
"shape-index.json.gz")
def icon_styles():
"""prIcon name -> (style, w, h) from the bundled official shape index."""
with gzip.open(_INDEX, "rt", encoding="utf-8") as f:
shapes = json.load(f)
out = {}
for s in shapes:
st = s["style"]
m = re.search(r"prIcon=([\w-]+)", st)
# Skip the kubernetesLabel=1 variants (they paint the kind name into
# the icon; our node label already names the object below it).
if "mxgraph.kubernetes.icon2" in st and m and "kubernetesLabel" not in st:
out.setdefault(m.group(1), (st, s["w"], s["h"]))
return out
def load_manifests(paths):
"""Parse all given files into a list of k8s objects."""
files = []
for p in paths:
if os.path.isdir(p):
for ext in ("yaml", "yml", "json"):
files.extend(glob.glob(os.path.join(p, "**", f"*.{ext}"), recursive=True))
else:
files.append(p)
objs = []
for path in sorted(set(files)):
if path == "-": # `kubectl get ... -o json | k8simports.py -`
docs = [json.loads(sys.stdin.read())]
elif path.endswith(".json"):
with open(path, encoding="utf-8") as f:
docs = [json.loads(f.read())]
else:
with open(path, encoding="utf-8") as f:
text = f.read()
try:
import yaml
except ImportError:
sys.exit(f"error: {path} is YAML but PyYAML is not installed "
"(pip install pyyaml) — or feed JSON from `kubectl get ... -o json`")
docs = list(yaml.safe_load_all(text))
for doc in docs:
if not isinstance(doc, dict):
continue
if doc.get("kind") == "List":
objs.extend(i for i in doc.get("items", []) if isinstance(i, dict))
else:
objs.append(doc)
return [o for o in objs if o.get("kind") and o.get("metadata", {}).get("name")]
def pod_spec(obj):
spec = obj.get("spec") or {}
if obj["kind"] == "Pod":
return spec
if obj["kind"] == "CronJob":
spec = (spec.get("jobTemplate") or {}).get("spec") or {}
return (spec.get("template") or {}).get("spec") or {}
def pod_labels(obj):
if obj["kind"] == "Pod":
return (obj.get("metadata") or {}).get("labels") or {}
spec = obj.get("spec") or {}
if obj["kind"] == "CronJob":
spec = (spec.get("jobTemplate") or {}).get("spec") or {}
return ((spec.get("template") or {}).get("metadata") or {}).get("labels") or {}
def mounted_refs(pspec):
"""(kind, name) pairs a pod spec references via env/envFrom/volumes."""
refs = set()
for c in (pspec.get("containers") or []) + (pspec.get("initContainers") or []):
for e in c.get("env") or []:
vf = e.get("valueFrom") or {}
for key, kind in (("configMapKeyRef", "ConfigMap"), ("secretKeyRef", "Secret")):
if vf.get(key, {}).get("name"):
refs.add((kind, vf[key]["name"]))
for e in c.get("envFrom") or []:
for key, kind in (("configMapRef", "ConfigMap"), ("secretRef", "Secret")):
if e.get(key, {}).get("name"):
refs.add((kind, e[key]["name"]))
for v in pspec.get("volumes") or []:
if v.get("configMap", {}).get("name"):
refs.add(("ConfigMap", v["configMap"]["name"]))
if v.get("secret", {}).get("secretName"):
refs.add(("Secret", v["secret"]["secretName"]))
if v.get("persistentVolumeClaim", {}).get("claimName"):
refs.add(("PersistentVolumeClaim", v["persistentVolumeClaim"]["claimName"]))
return refs
def ingress_backends(obj):
"""Service names referenced by an Ingress (networking.k8s.io/v1 + legacy)."""
names, stack = set(), [obj.get("spec") or {}]
while stack:
cur = stack.pop()
if isinstance(cur, dict):
svc = cur.get("service")
if isinstance(svc, dict) and svc.get("name"):
names.add(svc["name"])
if isinstance(cur.get("serviceName"), str):
names.add(cur["serviceName"])
stack.extend(cur.values())
elif isinstance(cur, list):
stack.extend(cur)
return names
def main():
ap = argparse.ArgumentParser(description="Kubernetes manifests -> autolayout graph JSON.")
ap.add_argument("paths", nargs="+", help="manifest files and/or directories")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true", help="group objects by namespace")
ap.add_argument("--no-icons", action="store_true",
help="plain boxes instead of official Kubernetes icons")
args = ap.parse_args()
objs = load_manifests(args.paths)
if not objs:
sys.exit("error: no Kubernetes objects found (need kind + metadata.name)")
def key(obj):
meta = obj.get("metadata") or {}
return (meta.get("namespace") or "", obj["kind"], meta["name"])
by_key = {key(o): o for o in objs}
icons = {} if args.no_icons else icon_styles()
edges = set()
for k, obj in by_key.items():
ns, kind, _ = k
def link(tkind, tname):
if (ns, tkind, tname) in by_key and (ns, tkind, tname) != k:
edges.add((k, (ns, tkind, tname)))
if kind == "Ingress":
for svc in ingress_backends(obj):
link("Service", svc)
elif kind == "Service":
sel = (obj.get("spec") or {}).get("selector") or {}
if sel:
for tk, target in by_key.items():
if (tk[0] == ns and tk[1] in WORKLOADS
and sel.items() <= pod_labels(target).items()):
edges.add((k, tk))
elif kind in WORKLOADS:
for tkind, tname in mounted_refs(pod_spec(obj)):
link(tkind, tname)
elif kind == "HorizontalPodAutoscaler":
ref = (obj.get("spec") or {}).get("scaleTargetRef") or {}
if ref.get("kind") and ref.get("name"):
link(ref["kind"], ref["name"])
def nid(k):
ns, kind, name = k
return f"{ns + '/' if ns else ''}{kind}/{name}"
nodes = []
for k in by_key:
ns, kind, name = k
node = {"id": nid(k), "label": name}
icon = icons.get(KIND_ICON.get(kind, ""))
if icon:
node.update(style=icon[0], width=icon[1], height=icon[2])
else:
node["label"] = f"{name}\n{kind}"
if args.group and ns:
node["group"] = ns
nodes.append(node)
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": nid(s), "target": nid(t)} for s, t in sorted(edges)]}
if icons:
# Icon labels render below the shape — reserve extra layout spacing.
graph.update(ranksep=0.7, nodesep=0.6)
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"{len(nodes)} objects, {len(edges)} edges\n")
if __name__ == "__main__":
main()
scripts/openapiimports.py
#!/usr/bin/env python3
"""Turn an OpenAPI / Swagger spec into an API diagram as autolayout graph JSON.
Reads an OpenAPI 3 or Swagger 2 spec (JSON, or YAML with PyYAML) and emits one
node per operation — coloured by HTTP method — plus one node per component
schema, with edges from each operation to the schemas it references (request /
response bodies) and between schemas that nest one another. Feeds autolayout.py:
python3 openapiimports.py openapi.yaml -o graph.json
python3 autolayout.py graph.json -o api.drawio
Operations are grouped by their first `tag` (falling back to the first path
segment) with `--group`; `--no-schemas` drops the data-model nodes to show just
the endpoint surface. `$ref`s are resolved to their final name; only schemas
defined under components/definitions become nodes, so external refs are ignored.
Usage: python3 openapiimports.py <spec.json|spec.yaml> [-o graph.json]
[--direction TB|LR] [--group] [--no-schemas]
"""
import argparse
import json
import os
import sys
METHODS = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
# HTTP method -> (fill, stroke). GET reads, POST creates, PUT/PATCH update, DELETE removes.
METHOD_STYLE = {
"get": ("#dae8fc", "#6c8ebf"), "post": ("#d5e8d4", "#82b366"),
"put": ("#ffe6cc", "#d79b00"), "patch": ("#ffe6cc", "#d79b00"),
"delete": ("#f8cecc", "#b85450"),
}
OTHER_STYLE = ("#f5f5f5", "#666666")
SCHEMA_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;"
OP_EDGE = "edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=10;endArrow=open;"
REF_EDGE = ("edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=10;"
"dashed=1;endArrow=open;strokeColor=#9673a6;")
def load_spec(path):
"""Parse the spec: JSON directly, YAML (or ambiguous) via PyYAML if present."""
with open(path, encoding="utf-8") as f:
text = f.read()
if path.lower().endswith((".yaml", ".yml")):
try:
import yaml
except ImportError:
sys.exit("error: spec is YAML but PyYAML is not installed "
"(pip install pyyaml) — or convert the spec to JSON")
return yaml.safe_load(text)
try:
return json.loads(text)
except json.JSONDecodeError:
try:
import yaml
except ImportError:
sys.exit("error: could not parse spec as JSON (install PyYAML to read YAML)")
return yaml.safe_load(text)
def find_refs(obj):
"""Yield the final name of every $ref anywhere inside a spec fragment."""
if isinstance(obj, dict):
for k, v in obj.items():
if k == "$ref" and isinstance(v, str):
yield v.split("/")[-1]
else:
yield from find_refs(v)
elif isinstance(obj, list):
for item in obj:
yield from find_refs(item)
def method_style(method):
fill, stroke = METHOD_STYLE.get(method, OTHER_STYLE)
return ("rounded=1;whiteSpace=wrap;html=1;align=left;spacingLeft=6;"
f"fillColor={fill};strokeColor={stroke};")
def build(spec, group, no_schemas, direction):
"""Spec dict -> autolayout graph JSON dict."""
paths = spec.get("paths") or {}
# components.schemas (OpenAPI 3) or definitions (Swagger 2)
schemas = (spec.get("components") or {}).get("schemas") or spec.get("definitions") or {}
want_schemas = bool(schemas) and not no_schemas
sid = {name: f"S:{name}" for name in schemas}
nodes, edges, seen = [], [], set()
def add_edge(src, dst, style):
if src != dst and (src, dst) not in seen:
seen.add((src, dst))
edges.append({"source": src, "target": dst, "style": style, "label": ""})
i = 0
for path, item in paths.items():
if not isinstance(item, dict):
continue
for method in METHODS:
op = item.get(method)
if not isinstance(op, dict):
continue
oid = f"op{i}"
i += 1
summary = (op.get("summary") or op.get("operationId") or "").strip()
head = f"{method.upper()} {path}"
nodes.append({
"id": oid, "label": head + (f"\n{summary}" if summary else ""),
"style": method_style(method),
"width": max(160, 8 * len(head) + 20), "height": 40,
**({"group": (op.get("tags") or [path.strip('/').split('/')[0] or "root"])[0]}
if group else {}),
})
if want_schemas:
for ref in set(find_refs(op)):
if ref in sid:
add_edge(oid, sid[ref], OP_EDGE)
if want_schemas:
for name, schema in schemas.items():
fields = schema.get("properties") if isinstance(schema, dict) else None
count = len(fields) if fields else 0
nodes.append({
"id": sid[name],
"label": name + (f"\n({count} field{'s' if count != 1 else ''})" if count else ""),
"style": SCHEMA_STYLE, "width": max(140, 9 * len(name) + 20), "height": 40,
**({"group": "schemas"} if group else {}),
})
for ref in set(find_refs(schema)):
if ref in sid:
add_edge(sid[name], sid[ref], REF_EDGE)
return {"direction": direction, "nodes": nodes, "edges": edges}
def main():
ap = argparse.ArgumentParser(description="OpenAPI/Swagger spec -> API diagram graph JSON.")
ap.add_argument("spec", help="OpenAPI 3 / Swagger 2 spec (.json or .yaml)")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="LR", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true", help="group operations by tag")
ap.add_argument("--no-schemas", action="store_true",
help="show only endpoints, omit schema nodes and edges")
args = ap.parse_args()
if not os.path.isfile(args.spec):
sys.exit(f"error: {args.spec} not found")
spec = load_spec(args.spec) or {}
if not (spec.get("paths")):
sys.exit("error: no paths found (is this an OpenAPI/Swagger spec?)")
graph = build(spec, args.group, args.no_schemas, args.direction)
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
ops = sum(1 for n in graph["nodes"] if n["id"].startswith("op"))
sys.stderr.write(f"{ops} operations, {len(graph['nodes']) - ops} schemas, "
f"{len(graph['edges'])} edges\n")
if __name__ == "__main__":
main()
scripts/prdiff.py
#!/usr/bin/env python3
"""Render base/head/diff PNGs for every .drawio changed between two git refs.
For each `.drawio` that differs between `--base` and `--head`, exports the
base and head pages as PNGs via the draw.io CLI, and — for files present on
both sides — chains `drawiodiff.py` -> `autolayout.py` -> CLI export into a
third colour-coded diff PNG. Added/removed files just get the one side that
exists. Emits a Markdown report with one section per changed file (status +
image links) and a summary count, suitable for a PR comment or CI job
summary; pair with `.github/actions/drawio-diff/`.
python3 prdiff.py --base origin/main --head HEAD -o drawio-pr/report.md
Missing draw.io CLI degrades gracefully: the Markdown still lists every
changed file, just without images (a review comment listing the files is
still useful). Missing git, or `--repo` not a git repository, is fatal.
Usage: python3 prdiff.py --base <ref> [--head <ref>] [--repo <dir>] [--out-dir <dir>] [-o report.md]
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
def changed_drawios(base, head, repo):
"""List of (path, status) for .drawio files that differ between base and head.
status is "added", "removed", or "modified" (renames/copies count as
modified, keyed on the new path). Shells to `git diff --name-status`.
"""
try:
r = subprocess.run(
["git", "-C", repo, "diff", "--name-status", f"{base}..{head}", "--", "*.drawio"],
capture_output=True, text=True)
except FileNotFoundError:
sys.exit("error: not a git repo / git not found (git not on PATH)")
if r.returncode != 0:
sys.exit(f"error: not a git repo / git not found: {r.stderr.strip()}")
entries = []
for line in r.stdout.splitlines():
if not line.strip():
continue
parts = line.split("\t")
code, path = parts[0], parts[-1]
status = "added" if code.startswith("A") else "removed" if code.startswith("D") else "modified"
entries.append((path, status))
return entries
def git_show_file(repo, ref, path, dest):
"""Write the blob at ref:path (in repo) to dest. False if it doesn't exist there."""
r = subprocess.run(["git", "-C", repo, "show", f"{ref}:{path}"], capture_output=True)
if r.returncode != 0:
return False
with open(dest, "wb") as f:
f.write(r.stdout)
return True
def export_png(src_drawio, out_png):
"""CLI-export page 1 of src_drawio to out_png. True on success."""
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", "1", "-o", out_png, src_drawio],
capture_output=True)
return r.returncode == 0 and os.path.exists(out_png)
def export_diff_png(base_drawio, head_drawio, out_png, tmp):
"""drawiodiff.py -> autolayout.py -> CLI export a coloured diff PNG. True on success."""
diff_json = os.path.join(tmp, "diff.json")
diff_drawio = os.path.join(tmp, "diff.drawio")
r1 = subprocess.run([sys.executable, os.path.join(HERE, "drawiodiff.py"),
base_drawio, head_drawio, "-o", diff_json], capture_output=True)
if r1.returncode != 0 or not os.path.exists(diff_json):
return False
r2 = subprocess.run([sys.executable, os.path.join(HERE, "autolayout.py"),
diff_json, "-o", diff_drawio], capture_output=True)
if r2.returncode != 0 or not os.path.exists(diff_drawio):
return False
return export_png(diff_drawio, out_png)
def build_entry(repo, base, head, path, status, out_dir, drawio_available):
"""One render_markdown entry: fetch both sides, export whatever PNGs it can."""
entry = {"path": path, "status": status}
if not drawio_available:
entry["skipped"] = True
return entry
slug = path.replace("/", "__")
with tempfile.TemporaryDirectory() as tmp:
base_drawio = os.path.join(tmp, "base.drawio")
head_drawio = os.path.join(tmp, "head.drawio")
have_base = git_show_file(repo, base, path, base_drawio)
have_head = git_show_file(repo, head, path, head_drawio)
if have_base:
p = os.path.join(out_dir, f"{slug}.base.png")
if export_png(base_drawio, p):
entry["base_png"] = p
if have_head:
p = os.path.join(out_dir, f"{slug}.head.png")
if export_png(head_drawio, p):
entry["head_png"] = p
if have_base and have_head:
p = os.path.join(out_dir, f"{slug}.diff.png")
if export_diff_png(base_drawio, head_drawio, p, tmp):
entry["diff_png"] = p
return entry
def render_markdown(entries, out_dir):
"""Pure: Markdown PR report from prdiff entries. No I/O, no CLI.
entries: list of {"path", "status", "base_png"?, "head_png"?, "diff_png"?,
"skipped"?} — image paths (if any) are made relative to out_dir for the
Markdown links. "skipped" means the draw.io CLI was unavailable.
"""
counts = {"added": 0, "removed": 0, "modified": 0}
for e in entries:
counts[e["status"]] = counts.get(e["status"], 0) + 1
lines = [
"# draw.io diagram changes",
"",
f"{len(entries)} file(s) changed: +{counts.get('added', 0)} added, "
f"-{counts.get('removed', 0)} removed, ~{counts.get('modified', 0)} modified",
]
if not entries:
lines.append("")
lines.append("No `.drawio` files changed.")
return "\n".join(lines) + "\n"
def rel(png):
return os.path.relpath(png, out_dir).replace(os.sep, "/") if png else None
for e in entries:
lines.append("")
lines.append(f"## {e['path']} ({e['status']})")
if e.get("skipped"):
lines.append("")
lines.append("_draw.io CLI not available — images skipped._")
continue
base_r, head_r, diff_r = rel(e.get("base_png")), rel(e.get("head_png")), rel(e.get("diff_png"))
lines.append("")
if base_r:
lines.append(f"")
if head_r:
lines.append(f"")
if diff_r:
lines.append(f"")
if not (base_r or head_r or diff_r):
lines.append("_no image produced._")
return "\n".join(lines) + "\n"
def main():
ap = argparse.ArgumentParser(description="Render PNGs + a Markdown report for .drawio files "
"changed between two git refs.")
ap.add_argument("--base", required=True, help="base git ref/sha")
ap.add_argument("--head", default="HEAD", help="head git ref/sha (default HEAD)")
ap.add_argument("--repo", default=".", help="path to the git repo (default: current directory)")
ap.add_argument("--out-dir", default="drawio-pr", help="directory for exported PNGs (default: ./drawio-pr)")
ap.add_argument("-o", "--output", help="write the Markdown report here (default: stdout)")
args = ap.parse_args()
changed = changed_drawios(args.base, args.head, args.repo)
if not changed:
sys.stderr.write("no .drawio files changed\n")
drawio_available = shutil.which("drawio") is not None
if not drawio_available and changed:
sys.stderr.write("warning: draw.io CLI not found - image export skipped, "
"Markdown will list files only (is the draw.io CLI installed?)\n")
os.makedirs(args.out_dir, exist_ok=True)
entries = [build_entry(args.repo, args.base, args.head, path, status, args.out_dir, drawio_available)
for path, status in changed]
report = render_markdown(entries, args.out_dir)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(report)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(report)
if __name__ == "__main__":
main()
scripts/pyclasses.py
#!/usr/bin/env python3
"""Extract a Python project's class-inheritance graph as autolayout graph JSON.
A finer-grained companion to pyimports.py: instead of module->module imports,
it emits one node per class and an edge from each subclass to the project base
classes it extends. With --group, classes are boxed by their module (nested by
sub-package), so the result reads as an auto-generated class hierarchy.
python3 pyclasses.py myproject --group -o graph.json
python3 autolayout.py graph.json -o diagram.drawio
Only inheritance is resolved (statically reliable); base classes are matched
by name, preferring a class in the same module. External bases (object,
Exception, third-party) are ignored. This is a *class structure* view, not a
function-level call graph — static call resolution in Python is unreliable, so
that is deliberately out of scope.
Usage: python3 pyclasses.py <project_dir> [-o graph.json] [--direction TB|LR]
[--group] [--no-reduce]
"""
import argparse
import ast
import json
import os
import re
import subprocess
import sys
def discover(root):
"""Map dotted module name -> file path; qualify with the package name when
root is itself a package (mirrors pyimports.py)."""
root = os.path.abspath(root)
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
modules = {}
for dirpath, _, files in os.walk(root):
for fn in files:
if not fn.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3]
parts = rel.split(os.sep)
if parts[-1] == "__init__":
parts = parts[:-1]
parts = ([base] + parts) if base else parts
if parts:
modules[".".join(parts)] = os.path.join(dirpath, fn)
return modules, base
def base_name(node):
"""Simple name of a base-class expression (`Foo` or `pkg.Foo` -> 'Foo')."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return None
def classes_in(module, path):
"""Top-level classes of a module: list of (qualified_id, simple_name, [base names])."""
try:
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
except SyntaxError:
return []
out = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
bases = [b for b in (base_name(b) for b in node.bases) if b]
out.append((f"{module}.{node.name}", node.name, bases))
return out
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def main():
ap = argparse.ArgumentParser(description="Python class-inheritance graph -> autolayout graph JSON.")
ap.add_argument("project", help="package or project directory")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="box classes by their module (nested by sub-package)")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
args = ap.parse_args()
modules, base = discover(args.project)
classes = {} # qualified id -> (module, bases)
by_name = {} # simple name -> [qualified ids]
for mod, path in modules.items():
for cid, name, bases in classes_in(mod, path):
classes[cid] = (mod, bases)
by_name.setdefault(name, []).append(cid)
if not classes:
sys.exit(f"error: no classes found under {args.project}")
def resolve(name, module):
cands = by_name.get(name, [])
same = [c for c in cands if classes[c][0] == module]
if same:
return same[0] # prefer a class in the same module
return cands[0] if len(cands) == 1 else None # else only if unambiguous
edges = set()
for cid, (mod, bases) in classes.items():
for b in bases:
target = resolve(b, mod)
if target and target != cid:
edges.add((cid, target))
edges = sorted(edges)
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce(list(classes), edges)
strip = base + "." if base else ""
short = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
def node(cid):
# No hard-coded colour: autolayout tints nodes by their group (module),
# so a grouped class hierarchy reads as coloured-by-module.
d = {"id": cid, "label": cid.rsplit(".", 1)[1]}
if args.group:
mod = classes[cid][0]
path = short(mod).replace(".", "/") # module path -> nested boxes
if path:
d["group"] = path
return d
graph = {
"direction": args.direction,
"nodes": [node(cid) for cid in classes],
"edges": [{"source": s, "target": t} for s, t in edges],
}
text = json.dumps(graph, indent=2)
if args.output:
open(args.output, "w", encoding="utf-8").write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(classes)} classes, {len(edges)} inheritance edges{note}\n")
if __name__ == "__main__":
main()
scripts/pyimports.py
#!/usr/bin/env python3
"""Extract a Python project's module-import graph as autolayout graph JSON.
Walks a package/project directory, parses each module with `ast`, builds the
intra-project import edges, and (by default) applies transitive reduction so
the diagram stays readable instead of becoming a hairball. The output feeds
autolayout.py:
python3 pyimports.py myproject -o graph.json
python3 autolayout.py graph.json -o diagram.drawio
Transitive reduction uses Graphviz `tred` (drops edges implied by a longer
path); pass --no-reduce to keep every import edge. Only intra-project imports
are kept — third-party and stdlib imports are ignored.
Usage: python3 pyimports.py <project_dir> [-o graph.json] [--direction TB|LR] [--no-reduce]
"""
import argparse
import ast
import json
import os
import re
import subprocess
import sys
def discover(root):
"""Map dotted module name -> file path for every .py under root, plus the
package prefix. If root is itself a package (has __init__.py), module names
are qualified with its name so the project's own absolute imports resolve."""
root = os.path.abspath(root)
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
modules = {}
for dirpath, _, files in os.walk(root):
for fn in files:
if not fn.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3] # strip .py
parts = rel.split(os.sep)
if parts[-1] == "__init__":
parts = parts[:-1] # package = its dir
parts = ([base] + parts) if base else parts
if parts:
modules[".".join(parts)] = os.path.join(dirpath, fn)
return modules, base
def resolve(name, current, modules):
"""Resolve a dotted name to the longest known module prefix (or None)."""
parts = name.split(".") if name else []
while parts:
cand = ".".join(parts)
if cand in modules and cand != current:
return cand
parts = parts[:-1]
return None
def edges_of(name, path, modules):
"""Intra-project modules imported by `name`."""
pkg = name if path.endswith("__init__.py") else name.rsplit(".", 1)[0] if "." in name else ""
found = set()
try:
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
except SyntaxError:
return found
for node in ast.walk(tree):
if isinstance(node, ast.Import): # import a.b.c
for alias in node.names:
target = resolve(alias.name, name, modules)
if target:
found.add(target)
elif isinstance(node, ast.ImportFrom): # from a.b import c
if node.level: # relative: climb level-1 packages
base = pkg.split(".") if pkg else []
base = base[: len(base) - (node.level - 1)]
prefix = ".".join(base)
mod = f"{prefix}.{node.module}" if prefix and node.module else (node.module or prefix)
else:
mod = node.module or ""
target = resolve(mod, name, modules)
if target:
found.add(target)
for alias in node.names: # `from pkg import submodule`
sub = f"{mod}.{alias.name}" if mod else alias.name
target = resolve(sub, name, modules)
if target:
found.add(target)
return found
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def main():
ap = argparse.ArgumentParser(description="Python import graph -> autolayout graph JSON.")
ap.add_argument("project", help="package or project directory")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group nodes into containers by sub-package")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
args = ap.parse_args()
modules, base = discover(args.project)
if not modules:
sys.exit(f"error: no .py modules found under {args.project}")
edges = sorted({(name, t) for name, path in modules.items()
for t in edges_of(name, path, modules)})
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce(list(modules), edges)
# Drop the shared package prefix from labels for readability; ids stay full.
strip = base + "." if base else ""
label = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
def node(m):
d = {"id": m, "label": label(m)}
if args.group:
rest = label(m).split(".")
if len(rest) > 1: # nested under a sub-package
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
return d
graph = {
"direction": args.direction,
"nodes": [node(m) for m in modules],
"edges": [{"source": s, "target": t} for s, t in edges],
}
text = json.dumps(graph, indent=2)
if args.output:
open(args.output, "w", encoding="utf-8").write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()
scripts/raster2drawio.py
#!/usr/bin/env python3
"""De-rasterize an image-extracted graph (JSON) into an editable .drawio.
Turns a whiteboard photo, legacy PNG, or Visio screenshot into an editable
diagram: Claude's own vision reads the image and extracts a JSON description
of the nodes/edges (the workflow is documented in
references/derasterize.md); this script turns that JSON into `.drawio` XML,
honoring the coordinates, labels, shapes, and colors Claude read off the
image.
python3 raster2drawio.py graph.json -o out.drawio
Input JSON:
{"nodes": [{"id": "n1", "label": "API Gateway", "x": 120, "y": 60,
"w": 160, "h": 60, "shape": "rect",
"fill": "#dae8fc", "stroke": "#6c8ebf"},
{"id": "n2", "label": "Auth DB", "x": 360, "y": 60,
"shape": "cylinder"}],
"edges": [{"source": "n1", "target": "n2", "label": "HTTPS",
"dashed": false, "arrow": true}]}
Only "id" is required per node; label defaults to id, w/h default to
120/60, shape defaults to "rect" (choices: rect, rounded, ellipse,
rhombus/diamond, cylinder, parallelogram, cloud, hexagon), fill/stroke
default to the skill's palette blue. Edge "arrow" defaults to true
(endArrow=none when false); "dashed" defaults to false.
If ANY node is missing x or y, positions are not guessed: the graph is
handed to autolayout.py (shelled out, requires Graphviz `dot`) to place it,
and a note is written to stderr.
Usage: python3 raster2drawio.py <graph.json|-> [-o out.drawio]
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from xml.sax.saxutils import escape
DEFAULT_W, DEFAULT_H = 120, 60
DEFAULT_FILL, DEFAULT_STROKE = "#dae8fc", "#6c8ebf"
SHAPES = {
"rect": "whiteSpace=wrap;html=1;",
"rounded": "rounded=1;whiteSpace=wrap;html=1;",
"ellipse": "ellipse;whiteSpace=wrap;html=1;",
"rhombus": "rhombus;whiteSpace=wrap;html=1;",
"diamond": "rhombus;whiteSpace=wrap;html=1;",
"cylinder": "shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;",
"parallelogram": "shape=parallelogram;whiteSpace=wrap;html=1;",
"cloud": "ellipse;shape=cloud;whiteSpace=wrap;html=1;",
"hexagon": "shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;",
}
EDGE_BASE = "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;"
def attr(value):
# Newlines in labels become 
 so draw.io renders a line break (a raw
# newline inside an XML attribute is normalized to a space by parsers).
return escape(str(value), {'"': """, "\n": "
"})
def node_style(node):
base = SHAPES.get(node.get("shape", "rect"), SHAPES["rect"])
fill = node.get("fill", DEFAULT_FILL)
stroke = node.get("stroke", DEFAULT_STROKE)
return f"{base}fillColor={fill};strokeColor={stroke};"
def edge_style(edge):
style = EDGE_BASE
if edge.get("dashed"):
style += "dashed=1;"
if edge.get("arrow") is False:
style += "endArrow=none;"
return style
def to_drawio(nodes, edges):
"""Direct build: every node already has x/y. Mirrors autolayout.py's
to_drawio() string-building, without the dot layout pass."""
cells = []
for node in nodes:
w, h = node.get("w", DEFAULT_W), node.get("h", DEFAULT_H)
cells.append(
f' <mxCell id="{attr(node["id"])}" value="{attr(node.get("label", node["id"]))}" '
f'style="{attr(node_style(node))}" vertex="1" parent="1">\n'
f' <mxGeometry x="{node["x"]}" y="{node["y"]}" width="{w}" height="{h}" as="geometry"/>\n'
f" </mxCell>"
)
for i, edge in enumerate(edges):
cells.append(
f' <mxCell id="e{i}" value="{attr(edge.get("label", ""))}" '
f'style="{attr(edge_style(edge))}" edge="1" parent="1" '
f'source="{attr(edge["source"])}" target="{attr(edge["target"])}">\n'
f' <mxGeometry relative="1" as="geometry"/>\n'
f" </mxCell>"
)
return (
"<mxfile>\n"
' <diagram id="raster2drawio" name="Page-1">\n'
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
" <root>\n"
' <mxCell id="0"/>\n'
' <mxCell id="1" parent="0"/>\n'
+ "\n".join(cells)
+ "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
)
def build_autolayout_graph(nodes, edges):
"""Same graph, in autolayout.py's input shape (positions dropped —
dot will compute fresh ones for every node)."""
return {
"direction": "TB",
"nodes": [
{"id": n["id"], "label": n.get("label", n["id"]), "style": node_style(n),
"width": n.get("w", DEFAULT_W), "height": n.get("h", DEFAULT_H)}
for n in nodes
],
"edges": [
{"source": e["source"], "target": e["target"], "label": e.get("label", ""),
"style": edge_style(e)}
for e in edges
],
}
def run_autolayout(graph):
"""Shell out to the sibling autolayout.py; return the .drawio XML text."""
here = os.path.dirname(os.path.abspath(__file__))
autolayout = os.path.join(here, "autolayout.py")
fd, graph_path = tempfile.mkstemp(suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(graph, f)
r = subprocess.run([sys.executable, autolayout, graph_path],
capture_output=True, text=True)
finally:
os.unlink(graph_path)
if r.returncode != 0:
sys.exit(f"error: autolayout.py failed: {r.stderr.strip()}")
return r.stdout
def main():
ap = argparse.ArgumentParser(
description="Convert an image-extracted graph JSON into an editable .drawio.")
ap.add_argument("input", help="graph JSON file, or - for stdin")
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
args = ap.parse_args()
if args.input == "-":
raw = sys.stdin.read()
else:
try:
with open(args.input, encoding="utf-8") as f:
raw = f.read()
except OSError as exc:
sys.exit(f"error: cannot read {args.input}: {exc}")
try:
graph = json.loads(raw)
except json.JSONDecodeError as exc:
sys.exit(f"error: invalid JSON: {exc}")
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
if not nodes:
sys.exit("error: no nodes in input")
for n in nodes:
if "id" not in n:
sys.exit("error: every node needs an 'id'")
for e in edges:
if "source" not in e or "target" not in e:
sys.exit("error: every edge needs 'source' and 'target'")
if any(n.get("x") is None or n.get("y") is None for n in nodes):
xml = run_autolayout(build_autolayout_graph(nodes, edges))
sys.stderr.write(
"note: some nodes had no x/y — positions were auto-placed via autolayout.py\n")
else:
xml = to_drawio(nodes, edges)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
sys.stderr.write(f"wrote {args.output} ({len(nodes)} nodes, {len(edges)} edges)\n")
else:
sys.stdout.write(xml)
if __name__ == "__main__":
main()
scripts/relabel.py
#!/usr/bin/env python3
"""Swap every label in a .drawio via a mapping — layout, styles, ids untouched.
The main use-case is language variants of one diagram (EN <-> CN docs figures):
extract the labels, translate the values, apply the map — the geometry never
moves, so both variants stay pixel-identical except for the text.
python3 relabel.py diagram.drawio --extract -o labels.json # step 1
# edit labels.json: keep keys, replace each value with the new text
python3 relabel.py diagram.drawio --map labels.json -o diagram_cn.drawio
Extract emits an identity JSON map {"label": "label", ...} of every non-empty
vertex/edge label, UserObject label, and page name, in document order. Apply
replaces each label that exactly matches a map key (raw string, HTML markup
included) and reports what matched. Unmapped labels stay unchanged; map keys
that matched nothing are listed on stderr so translations don't silently miss.
Usage: relabel.py <file.drawio> (--extract | --map <labels.json>) [-o <out>]
"""
import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET
def label_slots(tree):
"""Yield (element, attribute) for every label-bearing slot in the file."""
for diagram in tree.getroot().iter("diagram"):
if diagram.get("name"):
yield diagram, "name"
model = diagram.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None: # compressed page — can't edit
sys.stderr.write("warning: skipping compressed page "
f"'{diagram.get('name', '?')}' (open+save in draw.io to decompress)\n")
continue
for child in root:
if child.tag == "mxCell":
if child.get("value"):
yield child, "value"
elif child.tag in ("UserObject", "object"):
if child.get("label"):
yield child, "label"
inner = child.find("mxCell")
if inner is not None and inner.get("value"):
yield inner, "value"
def main():
p = argparse.ArgumentParser(description="Extract or swap .drawio labels via a JSON map.")
p.add_argument("file", help="input .drawio")
mode = p.add_mutually_exclusive_group(required=True)
mode.add_argument("--extract", action="store_true",
help="dump an identity label map as JSON")
mode.add_argument("--map", metavar="JSON", dest="mapfile",
help="JSON map {old label: new label} to apply")
p.add_argument("-o", "--output", help="output path (default: stdout for --extract, "
"<file>-relabel.drawio for --map)")
args = p.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
tree = ET.parse(args.file)
if args.extract:
seen = {}
for el, attr in label_slots(tree):
seen.setdefault(el.get(attr), el.get(attr))
out = json.dumps(seen, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(out + "\n")
sys.stderr.write(f"wrote {args.output} ({len(seen)} labels)\n")
else:
print(out)
return
with open(args.mapfile, encoding="utf-8") as f:
mapping = json.load(f)
if not isinstance(mapping, dict):
sys.exit("error: map file must be a JSON object {old: new}")
matched, used = 0, set()
for el, attr in label_slots(tree):
old = el.get(attr)
if old in mapping:
el.set(attr, str(mapping[old]))
matched += 1
used.add(old)
out = args.output or os.path.splitext(args.file)[0] + "-relabel.drawio"
tree.write(out, encoding="utf-8", xml_declaration=False)
unused = [k for k in mapping if k not in used]
if unused:
sys.stderr.write("warning: %d map key(s) matched no label: %s\n"
% (len(unused), ", ".join(repr(k)[:60] for k in unused[:10])))
sys.stderr.write(f"wrote {out} ({matched} labels replaced)\n")
if __name__ == "__main__":
main()
scripts/repair_png.py
#!/usr/bin/env python3
"""Repair truncated IEND chunk in draw.io -e PNG exports (issue #8).
draw.io's CLI emits -e PNGs with the 4-byte IEND length field but missing
the 8 bytes of "IEND" type + CRC. Strict PNG decoders and vision APIs
(Anthropic included) reject the file with 400 "Could not process image".
SVG/PDF are unaffected.
Usage: python3 repair_png.py <path/to/diagram.drawio.png>
Idempotent: the endswith(IEND) guard makes this a no-op once draw.io
fixes the bug upstream, so it's safe to run unconditionally after every
-e PNG export.
"""
import sys
IEND = b"\x00\x00\x00\x00IEND\xaeB`\x82"
def repair(path: str) -> bool:
with open(path, "rb") as f:
data = f.read()
if data.endswith(IEND):
return False
if data.endswith(b"\x00\x00\x00\x00"):
data = data[:-4]
with open(path, "wb") as f:
f.write(data + IEND)
return True
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: repair_png.py <path>", file=sys.stderr)
sys.exit(2)
if repair(sys.argv[1]):
print(f"repaired {sys.argv[1]}")
scripts/restyle.py
#!/usr/bin/env python3
"""Re-theme an EXISTING .drawio with a style preset — layout and shapes untouched.
Style presets (styles/schema.json) normally apply at generation time; this is
the post-processor for diagrams that already exist: "make this dark", "apply my
corporate style to last week's diagram".
python3 restyle.py diagram.drawio --preset dark
python3 restyle.py diagram.drawio --preset ~/.drawio-skill/styles/corp.json -o out.drawio
What it changes, per the preset application rules in references/style-presets.md:
- Every vertex fill/stroke is remapped to the preset palette. Each existing
fillColor is matched to its nearest palette slot by hue (grey/low-saturation
-> neutral), so same-colored nodes stay same-colored in the new theme.
- font.fontFamily on every vertex; existing fontSize values are kept (they
encode hierarchy).
- extras: fontColor (vertices + text cells), edgeColor (edge stroke + label),
sketch=1, globalStrokeWidth, page background on <mxGraphModel>.
Edge ROUTING styles and shape keywords are left alone — rewriting them would
break existing waypoints and geometry. fillColor=none is structural (lanes,
transparent containers) and is never replaced.
Usage: restyle.py <file.drawio> --preset <name|path.json> [-o <out.drawio>]
"""
import argparse
import colorsys
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Canonical hue (degrees) of each palette slot in the built-in conventions.
SLOT_HUES = {"primary": 210, "success": 120, "warning": 50, "accent": 30,
"danger": 0, "secondary": 280}
SLOT_ORDER = ["primary", "success", "warning", "accent", "danger", "neutral", "secondary"]
def find_preset(name):
"""Resolve a preset name/path to its JSON dict (user dir, then built-ins)."""
candidates = [name] if name.endswith(".json") else [
os.path.expanduser(f"~/.drawio-skill/styles/{name.lower()}.json"),
os.path.join(SKILL_DIR, "styles", "built-in", f"{name.lower()}.json"),
]
for path in candidates:
if os.path.isfile(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
builtin = os.path.join(SKILL_DIR, "styles", "built-in")
known = sorted(f[:-5] for f in os.listdir(builtin) if f.endswith(".json"))
sys.exit(f"error: preset '{name}' not found (built-ins: {', '.join(known)})")
def hue_slot(hexcolor, palette):
"""Nearest non-null palette slot for an existing fill color, by hue."""
r, g, b = (int(hexcolor[i:i + 2], 16) / 255 for i in (1, 3, 5))
h, l, s = colorsys.rgb_to_hls(r, g, b)
if s < 0.15 or l > 0.97 or l < 0.03: # grey / near-white / near-black
slot = "neutral"
else:
deg = h * 360
slot = min(SLOT_HUES, key=lambda k: min(abs(deg - SLOT_HUES[k]),
360 - abs(deg - SLOT_HUES[k])))
if palette.get(slot):
return slot
for k in SLOT_ORDER: # fallback ladder
if palette.get(k):
return k
sys.exit("error: preset palette has no non-null slots")
def get_key(style, key):
m = re.search(rf"(?:^|;){key}=([^;]*)", style)
return m.group(1) if m else None
def set_keys(style, **kv):
"""Replace/insert style keys, dropping existing occurrences first."""
for key in kv:
style = re.sub(rf"(?:^|;){key}=[^;]*", "", style)
style = style.strip("; ")
tail = ";".join(f"{k}={v}" for k, v in kv.items() if v is not None)
return (style + ";" if style else "") + tail + ";"
def main():
p = argparse.ArgumentParser(description="Apply a style preset to an existing .drawio.")
p.add_argument("file", help="input .drawio")
p.add_argument("--preset", required=True, help="preset name (user or built-in) or JSON path")
p.add_argument("-o", "--output", help="output path (default: <file>-<preset>.drawio)")
args = p.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
preset = find_preset(args.preset)
palette, extras, font = preset["palette"], preset.get("extras", {}), preset["font"]
vertex_extra = {"fontFamily": font["fontFamily"]}
if extras.get("fontColor"):
vertex_extra["fontColor"] = extras["fontColor"]
if extras.get("sketch"):
vertex_extra["sketch"] = "1"
if extras.get("globalStrokeWidth") not in (None, 1):
vertex_extra["strokeWidth"] = "%g" % extras["globalStrokeWidth"]
tree = ET.parse(args.file)
slot_map, n_vert, n_edge = {}, 0, 0
for diagram in tree.getroot().iter("diagram"):
model = diagram.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
sys.stderr.write(f"warning: skipping compressed page '{diagram.get('name', '?')}'\n")
continue
if extras.get("background"):
model.set("background", extras["background"])
for child in root:
cell = child if child.tag == "mxCell" else child.find("mxCell")
if cell is None:
continue
style = cell.get("style") or ""
if cell.get("edge") == "1":
kv = {}
if extras.get("edgeColor"):
# labelBackgroundColor=none: the default white label box is
# unreadable under a light edgeColor on dark backgrounds
kv.update(strokeColor=extras["edgeColor"], fontColor=extras["edgeColor"],
labelBackgroundColor="none")
if extras.get("sketch"):
kv["sketch"] = "1"
if extras.get("globalStrokeWidth") not in (None, 1):
kv["strokeWidth"] = "%g" % extras["globalStrokeWidth"]
if kv:
cell.set("style", set_keys(style, **kv))
n_edge += 1
continue
if cell.get("vertex") != "1":
continue
kv = dict(vertex_extra)
fill = get_key(style, "fillColor")
if fill and re.fullmatch(r"#[0-9A-Fa-f]{6}", fill):
slot = slot_map.setdefault(fill.lower(), hue_slot(fill.lower(), palette))
pair = palette[slot]
kv.update(fillColor=pair["fillColor"], strokeColor=pair["strokeColor"])
elif fill is None:
# No fillColor -> draw.io default white fill. Keep its default
# dark text: extras.fontColor would be unreadable on white.
kv.pop("fontColor", None)
cell.set("style", set_keys(style, **kv))
n_vert += 1
out = args.output or "%s-%s.drawio" % (os.path.splitext(args.file)[0],
preset.get("name", "restyled"))
tree.write(out, encoding="utf-8", xml_declaration=False)
remap = ", ".join(f"{c}->{s}" for c, s in sorted(slot_map.items()))
sys.stderr.write(f"wrote {out} ({n_vert} vertices, {n_edge} edges restyled"
+ (f"; colors: {remap}" if remap else "") + ")\n")
if __name__ == "__main__":
main()
scripts/runbook.py
#!/usr/bin/env python3
"""Turn a flowchart / decision-tree .drawio into a click-through HTML runbook.
Parses the nodes and edges out of a .drawio and infers a node "type" from its
shape style (ellipse -> start/end, rhombus -> decision, parallelogram -> io,
else process). The ellipse with no incoming edges is taken as the start node.
The output is a single self-contained HTML page: the current node's text
front and center, one button per outgoing edge (labeled with the edge's
choice text, or "Continue" when a node has a single unlabeled successor), a
breadcrumb trail of visited nodes, Back/Restart controls, and an "end" state
on terminal nodes (no outgoing edges). No draw.io CLI is needed -- the XML is
read and the HTML is built directly, so the whole script is testable without
any external tool.
python3 runbook.py triage.drawio -o triage.html
Usage: python3 runbook.py <file.drawio> [-o out.html]
"""
import argparse
import html
import json
import os
import sys
import xml.etree.ElementTree as ET
def parse(path):
"""Return (nodes, edges, start_id).
nodes: {id: {"label": str, "type": "start"|"end"|"decision"|"io"|"process"}}
edges: [{"source": id, "target": id, "label": str}, ...] in document order.
Cells are flattened across pages; UserObject/object wrappers are unwrapped
(id on the wrapper, cell inside) -- mirrors drawiodiff.py parse().
"""
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {path}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
cells, labels = [], {}
for page in pages:
model = page.find("mxGraphModel")
root = model.find("root") if model is not None else None
if root is None:
if (page.text or "").strip():
sys.stderr.write(f"warning: {path}: a page is compressed, skipped\n")
continue
for child in root:
if child.tag == "mxCell":
cells.append(child)
labels[child.get("id")] = child.get("value") or ""
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
cells.append(inner)
labels[child.get("id")] = child.get("label") or child.get("value") or ""
parents = {c.get("parent") for c in cells} # ids that have children
order, styles, edges = [], {}, []
for c in cells:
cid = c.get("id")
if c.get("edge") == "1":
s, t = c.get("source"), c.get("target")
if s and t:
edges.append({"source": s, "target": t, "label": labels.get(cid, "")})
elif c.get("vertex") == "1" and cid not in parents: # leaf vertices only
style = c.get("style") or ""
if "edgeLabel" in style:
continue
g = c.find("mxGeometry")
if g is not None and g.get("relative") == "1": # edge-label child
continue
order.append(cid)
styles[cid] = style
indeg = {i: 0 for i in order}
outdeg = {i: 0 for i in order}
for e in edges:
if e["source"] in outdeg:
outdeg[e["source"]] += 1
if e["target"] in indeg:
indeg[e["target"]] += 1
nodes = {}
for nid in order:
style = styles[nid]
if "ellipse" in style:
ntype = "end" if outdeg[nid] == 0 and indeg[nid] > 0 else "start"
elif "rhombus" in style:
ntype = "decision"
elif "parallelogram" in style:
ntype = "io"
else:
ntype = "process"
nodes[nid] = {"label": labels.get(nid, ""), "type": ntype}
edges = [e for e in edges if e["source"] in nodes and e["target"] in nodes]
# Start node: the ellipse with no incoming edges; else the unique in-degree-0
# node; else the first node in document order. Warn to stderr if ambiguous.
ellipse_zero_in = [nid for nid in order if "ellipse" in styles[nid] and indeg[nid] == 0]
if len(ellipse_zero_in) == 1:
start_id = ellipse_zero_in[0]
elif len(ellipse_zero_in) > 1:
sys.stderr.write("warning: multiple ellipse nodes with in-degree 0; picking the first\n")
start_id = ellipse_zero_in[0]
else:
zero_in = [nid for nid in order if indeg[nid] == 0]
if len(zero_in) == 1:
start_id = zero_in[0]
elif len(zero_in) > 1:
sys.stderr.write("warning: no unique in-degree-0 node; picking the first\n")
start_id = zero_in[0]
elif order:
sys.stderr.write("warning: no start node found by heuristics; using the first node\n")
start_id = order[0]
else:
start_id = None
return nodes, edges, start_id
def build_html(title, nodes, edges, start_id):
"""One self-contained click-through page. nodes: {id:{label,type}}; edges:
[{source,target,label}, ...]; start_id: node id to begin the walk at."""
adjacency = {}
for e in edges:
adjacency.setdefault(e["source"], []).append({"target": e["target"], "label": e["label"]})
payload = json.dumps({"nodes": nodes, "edges": adjacency, "start": start_id}).replace("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a;min-height:100vh;display:flex;flex-direction:column;align-items:center}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{width:100%;max-width:640px;padding:16px 20px 4px}}
h1{{margin:0;font-size:16px;font-weight:600}}
#crumbs{{width:100%;max-width:640px;padding:6px 20px;display:flex;flex-wrap:wrap;gap:4px;
font-size:12px;color:#667}}
@media(prefers-color-scheme:dark){{#crumbs{{color:#9aa}}}}
#crumbs span:not(:last-child)::after{{content:" \\2192 ";margin:0 2px}}
main{{width:100%;max-width:640px;padding:12px 20px 28px;flex:1}}
#card{{background:#fff;border:1px solid #0002;border-left:4px solid #0d99ff;border-radius:12px;
padding:24px;box-shadow:0 1px 3px #0001}}
@media(prefers-color-scheme:dark){{#card{{background:#1e2226;border-color:#fff2;border-left-color:#0d99ff}}}}
#card.decision{{border-left-color:#d79b00}}
#card.end{{border-left-color:#82b366}}
#card.io{{border-left-color:#9673a6}}
#type{{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#889;margin:0 0 8px}}
#label{{font-size:19px;font-weight:600;white-space:pre-wrap;margin:0 0 20px}}
.choices{{display:flex;flex-direction:column;gap:8px}}
.choices button{{font:inherit;text-align:left;padding:10px 14px;border:1px solid #0002;
border-radius:8px;background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{.choices button{{background:#262b31;border-color:#fff2}}}}
.choices button:hover{{border-color:#0d99ff;color:#0d99ff}}
.ctl{{display:flex;gap:10px;margin-top:20px}}
.ctl button{{font:inherit;padding:6px 14px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{.ctl button{{background:#262b31;border-color:#fff2}}}}
.ctl button:hover{{border-color:#0d99ff}}
.ctl button:disabled{{opacity:.4;cursor:default}}
#endmsg{{display:none;color:#82b366;font-weight:600;margin:0}}
</style></head><body>
<header><h1>{html.escape(title)}</h1></header>
<div id="crumbs"></div>
<main>
<div id="card">
<p id="type"></p>
<p id="label"></p>
<div class="choices" id="choices"></div>
<p id="endmsg">End of path -- nothing more to check.</p>
</div>
<div class="ctl">
<button id="back">← Back</button>
<button id="restart">↻ Restart</button>
</div>
</main>
<script>
const DATA={payload};
let path=[DATA.start];
const $=id=>document.getElementById(id);
function render(){{
const cur=path[path.length-1];
const node=DATA.nodes[cur]||{{label:String(cur),type:"process"}};
$("card").className=node.type;
$("type").textContent=node.type;
$("label").textContent=node.label;
const choices=DATA.edges[cur]||[];
const box=$("choices");box.innerHTML="";
$("endmsg").style.display=choices.length?"none":"block";
choices.forEach(c=>{{
const b=document.createElement("button");
const target=DATA.nodes[c.target]||{{}};
b.textContent=c.label||(choices.length===1?"Continue":(target.label||c.target));
b.onclick=()=>{{path.push(c.target);render();}};
box.appendChild(b);
}});
$("back").disabled=path.length<2;
const crumbs=$("crumbs");crumbs.innerHTML="";
path.forEach((id,i)=>{{
const s=document.createElement("span");
s.textContent=(DATA.nodes[id]||{{}}).label||id;
if(i<path.length-1){{
s.style.cursor="pointer";
s.onclick=()=>{{path=path.slice(0,i+1);render();}};
}}
crumbs.appendChild(s);
}});
}}
$("back").onclick=()=>{{if(path.length>1){{path.pop();render();}}}};
$("restart").onclick=()=>{{path=[DATA.start];render();}};
render();
</script></body></html>
"""
def main():
ap = argparse.ArgumentParser(description="Turn a flowchart .drawio into a click-through HTML runbook.")
ap.add_argument("file")
ap.add_argument("-o", "--output", help="output .html (default: alongside input)")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
nodes, edges, start_id = parse(args.file)
if not nodes:
sys.exit(f"error: no nodes found in {args.file}")
if start_id is None:
sys.exit(f"error: no start node found in {args.file}")
title = os.path.splitext(os.path.basename(args.file))[0]
out = args.output or os.path.splitext(args.file)[0] + ".html"
with open(out, "w", encoding="utf-8") as f:
f.write(build_html(title, nodes, edges, start_id))
sys.stderr.write(f"wrote {out} ({len(nodes)} nodes, {len(edges)} edges)\n")
if __name__ == "__main__":
main()
scripts/rustimports.py
#!/usr/bin/env python3
"""Extract a Rust crate's module-use graph as autolayout graph JSON.
The Rust counterpart to pyimports.py / jsimports.py / goimports.py. Treats each
.rs file as a module (path-derived: src/foo/bar.rs -> module foo::bar; main.rs /
lib.rs / mod.rs name the enclosing module), and records intra-crate `use` edges
resolved through Rust's path roots:
use crate::a::b::C; -> edge to module a::b
use super::sibling; -> resolved against the current module's parent
use self::child::Item;-> resolved against the current module
use other_crate::...; / use std::...; -> external, ignored
Brace groups (`use crate::a::{B, C};`, `use crate::{a, b};`) are expanded.
Transitive reduction is on by default so the diagram stays readable.
python3 rustimports.py ./mycrate --group -o graph.json
python3 autolayout.py graph.json -o diagram.drawio
Parsing is regex-based, not a full parser: inline `mod { ... }` blocks are not
split out, `#[cfg]`-gated modules are always included, and 2015-edition bare
intra-crate paths (without `crate::`) are not resolved.
Usage: python3 rustimports.py <crate_dir> [-o graph.json] [--direction TB|LR]
[--group] [--no-reduce]
"""
import argparse
import json
import os
import re
import subprocess
import sys
USE = re.compile(r"\buse\s+([^;]+);")
def crate_name(root):
cargo = os.path.join(root, "Cargo.toml")
if os.path.exists(cargo):
m = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"',
open(cargo, encoding="utf-8", errors="ignore").read())
if m:
return m.group(1)
return "crate"
def discover(root):
"""Map module path (tuple of segments; () is the crate root) -> file path."""
root = os.path.abspath(root)
src = os.path.join(root, "src") if os.path.isdir(os.path.join(root, "src")) else root
modules = {}
for dirpath, dirs, files in os.walk(src):
dirs[:] = [d for d in dirs if d != "target" and not d.startswith(".")]
for fn in files:
if not fn.endswith(".rs"):
continue
parts = os.path.relpath(os.path.join(dirpath, fn), src)[:-3].split(os.sep)
if parts[-1] == "mod":
parts = parts[:-1]
if len(parts) == 1 and parts[0] in ("main", "lib"):
parts = [] # crate root
modules[tuple(parts)] = os.path.join(dirpath, fn)
return modules, src
def split_top(inner):
"""Split a brace group on top-level commas, ignoring nested braces."""
out, depth, cur = [], 0, ""
for ch in inner:
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if ch == "," and depth == 0:
out.append(cur)
cur = ""
else:
cur += ch
if cur.strip():
out.append(cur)
return out
def base_segments(prefix, current):
"""Classify a `use` path prefix into intra-crate base segments, or None."""
segs = [s for s in (p.strip() for p in prefix.split("::")) if s]
if not segs:
return None
if segs[0] == "crate":
return segs[1:]
if segs[0] == "self":
return list(current) + segs[1:]
if segs[0] == "super":
n = 0
while segs and segs[0] == "super":
n += 1
segs = segs[1:]
if n > len(current):
return None # climbs above the crate root
return list(current)[: len(current) - n] + segs
return None # std / external crate
def resolve(parts, modules, current):
"""Longest known module prefix of `parts` (a tuple), or None."""
if not parts:
return () if () in modules and () != tuple(current) else None
p = list(parts)
while p:
if tuple(p) in modules and tuple(p) != tuple(current):
return tuple(p)
p = p[:-1]
return None
def edges_of(current, path, modules):
"""Intra-crate module paths used by the module at `current`."""
found = set()
try:
src = open(path, encoding="utf-8", errors="ignore").read()
except OSError:
return found
for stmt in USE.findall(src):
if "{" in stmt:
prefix = stmt[: stmt.index("{")]
inner = stmt[stmt.index("{") + 1: stmt.rindex("}")] if "}" in stmt else ""
leaves = split_top(inner)
else:
prefix, leaves = stmt, [None]
base = base_segments(prefix, current)
if base is None:
continue
for leaf in leaves:
segs = list(base)
if leaf:
first = leaf.strip().split("::")[0].split()[0]
if first and first not in ("self", "*"):
segs.append(first)
target = resolve(tuple(segs), modules, current)
if target is not None and target != current:
found.add(target)
return found
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def main():
ap = argparse.ArgumentParser(description="Rust module-use graph -> autolayout graph JSON.")
ap.add_argument("crate", help="crate directory (contains Cargo.toml and/or src/)")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="box modules by their parent module path (nested)")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
args = ap.parse_args()
modules, _ = discover(args.crate)
if not modules:
sys.exit(f"error: no .rs modules found under {args.crate}")
name = crate_name(args.crate)
mid = lambda parts: name if not parts else "::".join(parts)
edges = sorted({(mid(m), mid(t)) for m, path in modules.items()
for t in edges_of(m, path, modules)})
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce([mid(m) for m in modules], edges)
def node(parts):
d = {"id": mid(parts), "label": name if not parts else parts[-1]}
if args.group and len(parts) > 1:
d["group"] = "/".join(parts[:-1]) # parent module path -> nested boxes
return d
graph = {
"direction": args.direction,
"nodes": [node(m) for m in modules],
"edges": [{"source": s, "target": t} for s, t in edges],
}
text = json.dumps(graph, indent=2)
if args.output:
open(args.output, "w", encoding="utf-8").write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()
scripts/seqlayout.py
#!/usr/bin/env python3
"""Deterministic sequence-diagram layout: message list JSON -> .drawio XML.
Sequence diagrams are the easiest type to get wrong by hand-placing
coordinates (lifelines, activation bars and message arrows all share exact
x/y math) and the least suited to Graphviz — but their geometry is pure
arithmetic: participants split the x axis, messages advance the y axis.
This script computes all of it, using the same official styles as
references/diagram-types.md (umlLifeline shapes, block/open arrows).
python3 seqlayout.py seq.json -o diagram.drawio
Input JSON:
{
"title": "Login flow", # optional page name
"participants": [
{"id": "u", "label": "User", "actor": true},
{"id": "s", "label": "Server"} # order = left-to-right order
],
"messages": [
{"from": "u", "to": "s", "label": "POST /login"}, # sync (solid, filled arrow)
{"from": "s", "to": "s", "label": "validate()"}, # self message
{"from": "s", "to": "u", "label": "200 OK", "return": true}, # return (grey dashed)
{"from": "u", "to": "s", "label": "notify", "async": true}, # async (dashed, open arrow)
{"note": "token cached", "over": "s"} # note beside a lifeline
]
}
Activation bars are automatic: a sync/async message opens a bar on the
target, a return message closes the sender's bar, and bars still open at the
end run to the bottom. Override per message with "activate": false (don't
open on target) or "deactivate": true (close the sender's bar after this
message). Arrows attach to the bar edge when a bar is active, else to the
lifeline. Fragments (alt/loop/opt frames) are out of scope — add them in
draw.io afterwards.
Usage: python3 seqlayout.py <seq.json> [-o diagram.drawio]
"""
import argparse
import json
import sys
from xml.sax.saxutils import escape
LIFELINE_W, HEADER_H = 100, 40
BAR_W = 10
TOP, ROW, SELF_ROW, NOTE_ROW, BOTTOM_PAD = 40, 50, 70, 60, 40
MIN_SPACING = 200
LIFELINE = ("shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;"
"container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;"
f"portConstraint=eastwest;size={HEADER_H};")
# Actor lifelines render a stick figure in the header — anchor the name at
# the header bottom (white-backed) so figure and label don't overlap.
ACTOR = LIFELINE + ("participant=umlActor;verticalAlign=bottom;"
"spacingBottom=-14;labelBackgroundColor=#ffffff;")
BAR = "html=1;points=[];perimeter=orthogonalPerimeter;outlineConnect=0;fillColor=#ffffff;"
NOTE = ("shape=note;whiteSpace=wrap;html=1;size=14;fillColor=#fff2cc;"
"strokeColor=#d6b656;")
SYNC = "html=1;verticalAlign=bottom;endArrow=block;curved=0;rounded=0;"
ASYNC = "html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
RETURN = ("html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
"strokeColor=#999999;fontColor=#999999;")
def attr(value):
return escape(str(value), {'"': """, "\n": "
"})
def frac(y, top, height):
return round(max(0.0, min(1.0, (y - top) / height)), 4)
def layout(spec):
parts = spec["participants"]
if not parts:
sys.exit("error: no participants")
order = {p["id"]: i for i, p in enumerate(parts)}
if len(order) != len(parts):
sys.exit("error: duplicate participant ids")
# x axis: uniform spacing, widened if any label needs it (~7px/char).
spacing = max([MIN_SPACING] + [7 * len(str(p.get("label", p["id"]))) + 80 for p in parts])
spacing = -(-spacing // 10) * 10 # snap up to the grid
cx = {p["id"]: TOP + i * spacing + LIFELINE_W // 2 for i, p in enumerate(parts)}
# y axis: walk the messages once, assigning each row a y position and
# tracking one open activation bar per participant ({pid: start_y}).
y = TOP + HEADER_H + 50
rows, open_bar, bars = [], {}, [] # bars: (pid, y0, y1)
def close(pid, at):
if pid in open_bar:
bars.append((pid, open_bar.pop(pid), at))
for i, m in enumerate(spec.get("messages", [])):
if "note" in m:
rows.append(("note", m, y))
y += NOTE_ROW
continue
src, dst = m["from"], m["to"]
if src not in order or dst not in order:
sys.exit(f"error: message {i} references unknown participant")
is_return = m.get("return", False)
if src == dst:
rows.append(("self", m, y))
y += SELF_ROW
continue
rows.append(("msg", m, y))
if is_return:
close(src, y) # returning ends the caller's work
elif m.get("activate", True) and dst not in open_bar:
open_bar[dst] = y # call starts work on the target
if m.get("deactivate"):
close(src, y)
y += ROW
height = y + BOTTOM_PAD - TOP
for pid in list(open_bar):
close(pid, TOP + height - BOTTOM_PAD)
cells = []
for p in parts:
style = ACTOR if p.get("actor") else LIFELINE
cells.append(
f' <mxCell id="{attr(p["id"])}" value="{attr(p.get("label", p["id"]))}" '
f'style="{style}" vertex="1" parent="1">\n'
f' <mxGeometry x="{cx[p["id"]] - LIFELINE_W // 2}" y="{TOP}" '
f'width="{LIFELINE_W}" height="{height}" as="geometry"/>\n'
" </mxCell>")
# Activation bars: children of their lifeline (coordinates relative to it).
bar_of = {} # pid -> list of (y0, y1, cell_id)
for n, (pid, y0, y1) in enumerate(bars):
bid = f"act{n}"
bar_of.setdefault(pid, []).append((y0, y1, bid))
cells.append(
f' <mxCell id="{bid}" value="" style="{BAR}" vertex="1" '
f'parent="{attr(pid)}">\n'
f' <mxGeometry x="{LIFELINE_W // 2 - BAR_W // 2}" y="{y0 - TOP}" '
f'width="{BAR_W}" height="{y1 - y0}" as="geometry"/>\n'
" </mxCell>")
def anchor(pid, my, side):
"""(cell_id, exitX-style fragment values) for a message endpoint: the
activation bar's edge when one is active at this y, else the lifeline."""
for y0, y1, bid in bar_of.get(pid, []):
if y0 <= my <= y1:
return bid, (1 if side == "right" else 0), frac(my, y0, y1 - y0)
return pid, 0.5, frac(my, TOP, height)
for i, (kind, m, my) in enumerate(rows):
if kind == "note":
pid = m.get("over")
if pid not in cx:
sys.exit(f"error: note {i} is over unknown participant {pid!r}")
w = max(120, 7 * len(str(m["note"])) + 30)
cells.append(
f' <mxCell id="note{i}" value="{attr(m["note"])}" style="{NOTE}" '
f'vertex="1" parent="1">\n'
f' <mxGeometry x="{cx[pid] + 20}" y="{my - 20}" '
f'width="{min(w, spacing - 60)}" height="40" as="geometry"/>\n'
" </mxCell>")
continue
src, dst = m["from"], m["to"]
style = RETURN if m.get("return") else ASYNC if m.get("async") else SYNC
if kind == "self":
sid, sx, sy = anchor(src, my, "right")
_, tx, ty = anchor(src, my + 30, "right")
loop_x = cx[src] + 60
cells.append(
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(sid)}">\n'
f' <mxGeometry relative="1" as="geometry">\n'
f' <Array as="points">'
f'<mxPoint x="{loop_x}" y="{my}"/><mxPoint x="{loop_x}" y="{my + 30}"/>'
f'</Array>\n'
" </mxGeometry>\n"
" </mxCell>")
continue
rightward = order[src] < order[dst]
sid, sx, sy = anchor(src, my, "right" if rightward else "left")
tid, tx, ty = anchor(dst, my, "left" if rightward else "right")
cells.append(
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(tid)}">\n'
f' <mxGeometry relative="1" as="geometry"/>\n'
" </mxCell>")
name = attr(spec.get("title", "Sequence"))
return (
f'<mxfile>\n <diagram id="seqlayout" name="{name}">\n'
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
" <root>\n"
' <mxCell id="0"/>\n'
' <mxCell id="1" parent="0"/>\n'
+ "\n".join(cells)
+ "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
)
def main():
ap = argparse.ArgumentParser(description="Sequence-diagram JSON -> draw.io XML.")
ap.add_argument("input", help="sequence JSON file")
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
args = ap.parse_args()
with open(args.input, encoding="utf-8") as f:
spec = json.load(f)
xml = layout(spec)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
print(f"wrote {args.output} ({len(spec['participants'])} participants, "
f"{len(spec.get('messages', []))} messages)", file=sys.stderr)
else:
sys.stdout.write(xml)
if __name__ == "__main__":
main()
scripts/shapesearch.py
#!/usr/bin/env python3
"""Search 10k+ official draw.io shapes for their exact style strings.
Resolves a keyword query (e.g. "aws lambda", "uml actor", "k8s pod") to the
matching palette shapes so a diagram can use the real draw.io `style=` string
instead of a hand-guessed one. Covers AWS / Azure / GCP / Cisco / Kubernetes /
UML / BPMN / P&ID / electrical / flowchart / network / general shape sets.
Based on the search in jgraph/drawio-mcp (Apache-2.0): tag map with exact +
Soundex matching, strict AND first, scored OR fallback. The matched set is
identical to upstream; the one addition is a tiebreaker that, among shapes with
the same tag score, prefers ones whose title contains the query terms verbatim
(so "dynamodb" returns the shape titled "DynamoDB", not a neighbor merely tagged
with it). The bundled index (data/shape-index.json.gz) is the upstream draw.io
shape data — see data/SHAPE-INDEX-NOTICE.md.
Usage:
python3 shapesearch.py "aws lambda" [--limit N] [--json]
"""
import argparse
import gzip
import json
import os
import re
import sys
INDEX = os.path.join(os.path.dirname(__file__), "..", "data", "shape-index.json.gz")
_SOUNDEX_MAP = "01230120022455012603010202" # A..Z digit codes
_TRAIL = re.compile(r"\.*\d*$") # strip trailing digits/dots before soundex
def soundex(name):
if not name:
return ""
s = [name[0].upper()]
si = 1
for ch in name[1:]:
c = ord(ch.upper()) - 65
if 0 <= c <= 25 and _SOUNDEX_MAP[c] != "0":
code = _SOUNDEX_MAP[c]
if code != s[si - 1]:
s.append(code)
si += 1
if si > 3:
break
s += ["0"] * (4 - len(s))
return "".join(s[:4])
def build_tag_map(shapes):
"""tag (and its Soundex) -> set of shape indices."""
tag_map = {}
for i, shape in enumerate(shapes):
raw = shape.get("tags")
if not raw:
continue
seen = set()
for token in re.sub(r"[/,()]", " ", raw.lower()).split(" "):
if len(token) < 2 or token in seen:
continue
seen.add(token)
tag_map.setdefault(token, set()).add(i)
sx = soundex(_TRAIL.sub("", token))
if sx and sx != token and sx not in seen:
seen.add(sx)
tag_map.setdefault(sx, set()).add(i)
return tag_map
def split_compound(token):
"""'pid2misc' -> ['pid','misc']; 'discInst' -> ['disc','inst']."""
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", token)
spaced = re.sub(r"([a-zA-Z])(\d)", r"\1 \2", spaced)
spaced = re.sub(r"(\d)([a-zA-Z])", r"\1 \2", spaced)
return [p for p in spaced.lower().split() if len(p) >= 2]
def match_term(tag_map, term):
exact = set(tag_map.get(term, set()))
phonetic = set()
sx = soundex(_TRAIL.sub("", term))
if sx and sx != term:
phonetic = {i for i in tag_map.get(sx, set()) if i not in exact}
return exact, phonetic
def search(shapes, tag_map, query, limit):
if not query:
return []
terms, seen = [], set()
for raw in query.lower().split():
subs = split_compound(raw) or ([raw] if len(raw) >= 2 else [])
for t in subs:
if t not in seen:
seen.add(t)
terms.append(t)
if not terms:
return []
term_matches = [match_term(tag_map, t) for t in terms]
# Strict AND across all terms first.
and_set = None
for exact, phonetic in term_matches:
combined = exact | phonetic
and_set = combined if and_set is None else (and_set & combined)
if not and_set:
break
# Score: +1.0 exact, +0.5 Soundex-only, per term. AND results if any, else OR.
scores = {}
pool = and_set if and_set else None
for exact, phonetic in term_matches:
for idx in exact:
if pool is None or idx in pool:
scores[idx] = scores.get(idx, 0) + 1.0
for idx in phonetic:
if (pool is None or idx in pool) and idx not in exact:
scores[idx] = scores.get(idx, 0) + 0.5
# Rank by tag score desc, then by how many query terms appear verbatim in the
# title, then casefolded title, then index. The title-hit tiebreak (our one
# addition over upstream) only reorders *within* an equal tag-score group, so
# e.g. the shape literally titled "DynamoDB" ranks above a neighbor that is
# merely tagged `dynamodb` (like "Attribute"). The trailing index keeps ties
# deterministic.
term_set = set(terms)
def title_hits(idx):
toks = set(re.split(r"[^a-z0-9]+", shapes[idx].get("title", "").casefold()))
return len(term_set & toks)
ranked = sorted(scores, key=lambda i: (-scores[i], -title_hits(i),
shapes[i].get("title", "").casefold(), i))
return [{"style": shapes[i]["style"], "w": shapes[i]["w"],
"h": shapes[i]["h"], "title": shapes[i]["title"]} for i in ranked[:limit]]
def main():
ap = argparse.ArgumentParser(description="Search official draw.io shapes for their style strings.")
ap.add_argument("query", help='keywords, e.g. "aws lambda" or "uml actor"')
ap.add_argument("--limit", type=int, default=10)
ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
args = ap.parse_args()
if not os.path.exists(INDEX):
sys.exit(f"error: shape index not found at {INDEX}")
with gzip.open(INDEX, "rt", encoding="utf-8") as f:
shapes = json.load(f)
results = search(shapes, build_tag_map(shapes), args.query, args.limit)
if not results:
sys.exit(f"no shapes matched {args.query!r}")
if args.json:
print(json.dumps(results, indent=2, ensure_ascii=False))
else:
for r in results:
print(f"{r['title']} ({r['w']}x{r['h']})\n {r['style']}")
if __name__ == "__main__":
main()
scripts/sqlerd.py
#!/usr/bin/env python3
"""Extract an ER diagram from SQL DDL as autolayout graph JSON.
Parses ``CREATE TABLE`` statements (regex + paren matching — no SQL library),
one node per table listing its columns with PK/FK markers, and one
crow's-foot edge per foreign key (many side at the referencing table).
The output feeds autolayout.py:
python3 sqlerd.py schema.sql -o graph.json
python3 autolayout.py graph.json -o erd.drawio
Understood per table: column name + type, inline ``PRIMARY KEY`` /
``REFERENCES tab(col)``, table-level ``PRIMARY KEY (...)`` and
``[CONSTRAINT x] FOREIGN KEY (col) REFERENCES tab(col)``. Quoted identifiers
("t", `t`, [t]) and ``schema.table`` prefixes are normalized; edges land only
on tables defined in the scanned files. Dialect-specific clauses beyond that
(partitioning, generated columns, …) are simply ignored — worst case a column
line is skipped, never a wrong edge.
Usage: python3 sqlerd.py <file.sql-or-dir> [-o graph.json]
[--direction TB|LR] [--group] [--no-types]
"""
import argparse
import glob
import json
import os
import re
import sys
TABLE_STYLE = ("rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;"
"spacingLeft=6;spacingTop=4;fillColor=#dae8fc;strokeColor=#6c8ebf;")
# orthogonalEdgeStyle (not entityRelationEdgeStyle) so the edge honours the
# obstacle-avoiding waypoints dot computed; ER arrows give the crow's foot.
ER_EDGE = ("edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=11;"
"labelBackgroundColor=#ffffff;"
"startArrow=ERmany;startFill=0;endArrow=ERone;endFill=0;")
_COMMENT = re.compile(r"/\*.*?\*/|--[^\n]*", re.S)
_CREATE = re.compile(r"\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"`\[\]]+)\s*\(",
re.I)
_FK = re.compile(r"FOREIGN\s+KEY\s*\(([^)]+)\)\s*REFERENCES\s+([\w.\"`\[\]]+)\s*(?:\(([^)]+)\))?",
re.I)
_PK = re.compile(r"PRIMARY\s+KEY\s*\(([^)]+)\)", re.I)
_INLINE_REF = re.compile(r"\bREFERENCES\s+([\w.\"`\[\]]+)", re.I)
_SKIP = re.compile(r"^\s*(CONSTRAINT|UNIQUE|CHECK|KEY|INDEX|FULLTEXT|SPATIAL|EXCLUDE|LIKE)\b",
re.I)
def ident(raw):
"""Normalize an identifier: strip quoting, keep the last dotted part."""
name = raw.strip().strip('"`[]').split(".")[-1].strip('"`[]')
return name.lower()
def split_columns(body):
"""Split a CREATE TABLE body on top-level commas."""
items, depth, cur = [], 0, []
for ch in body:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
items.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
if "".join(cur).strip():
items.append("".join(cur).strip())
return items
def parse_tables(text):
"""{table: {"schema", "columns": [(name, type)], "pks": set, "fks": [(col, table)]}}"""
text = _COMMENT.sub("", text)
tables = {}
for m in _CREATE.finditer(text):
raw_name = m.group(1)
depth, i = 1, m.end()
while i < len(text) and depth:
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
i += 1
body = text[m.end():i - 1]
name = ident(raw_name)
parts = raw_name.strip().strip('"`[]').split(".")
schema = ident(parts[-2]) if len(parts) > 1 else ""
cols, pks, fks = [], set(), []
for item in split_columns(body):
fk = _FK.search(item)
if fk:
for col in fk.group(1).split(","):
fks.append((ident(col), ident(fk.group(2))))
continue
pk = _PK.search(item)
if pk and _SKIP.match(item) is None and item.upper().lstrip().startswith("PRIMARY"):
pks.update(ident(c) for c in pk.group(1).split(","))
continue
if _SKIP.match(item):
continue
toks = item.split()
if len(toks) < 2:
continue
col, ctype = ident(toks[0]), toks[1].rstrip(",")
cols.append((col, ctype))
if re.search(r"\bPRIMARY\s+KEY\b", item, re.I):
pks.add(col)
ref = _INLINE_REF.search(item)
if ref:
fks.append((col, ident(ref.group(1))))
tables[name] = {"schema": schema, "columns": cols, "pks": pks, "fks": fks}
return tables
def main():
ap = argparse.ArgumentParser(description="SQL DDL -> ER diagram graph JSON.")
ap.add_argument("path", help=".sql file or directory containing .sql files")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true", help="group tables by schema")
ap.add_argument("--no-types", action="store_true",
help="list column names only (hide the SQL types)")
args = ap.parse_args()
files = ([args.path] if os.path.isfile(args.path)
else sorted(glob.glob(os.path.join(args.path, "**", "*.sql"), recursive=True)))
tables = {}
for path in files:
with open(path, encoding="utf-8") as f:
tables.update(parse_tables(f.read()))
if not tables:
sys.exit(f"error: no CREATE TABLE statements found under {args.path}")
nodes, edges = [], []
for name, t in tables.items():
fk_cols = {c for c, _ in t["fks"]}
lines = [name]
for col, ctype in t["columns"]:
mark = "PK " if col in t["pks"] else "FK " if col in fk_cols else ""
lines.append(f"{mark}{col}" + ("" if args.no_types else f": {ctype}"))
width = max(160, -(-max(7 * len(l) + 30 for l in lines) // 10) * 10)
height = -(-(30 + 20 * len(t["columns"])) // 10) * 10
node = {"id": name, "label": "\n".join(lines), "style": TABLE_STYLE,
"width": width, "height": height}
if args.group and t["schema"]:
node["group"] = t["schema"]
nodes.append(node)
for col, ref in t["fks"]:
if ref in tables and ref != name:
edges.append({"source": name, "target": ref, "label": col,
"style": ER_EDGE})
graph = {"direction": args.direction, "nodes": nodes, "edges": edges}
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"{len(nodes)} tables, {len(edges)} foreign keys\n")
if __name__ == "__main__":
main()
scripts/svgflow.py
#!/usr/bin/env python3
"""Make a diagram's edges *flow* — an animated data-flow SVG.
Exports a .drawio to SVG (or takes an .svg directly) and turns every edge into a
marching-ants animation: dashes travel along each connector in the direction of
the arrow, so the diagram shows data/flow moving through it. The result is a
single self-contained .svg that loops forever in any browser — nice for a README
(GitHub renders SVG), a docs page, or a slide background.
python3 svgflow.py architecture.drawio -o architecture-flow.svg
python3 svgflow.py already-exported.svg -o flow.svg
Edges are found by draw.io's own marker: connector *lines* carry
`pointer-events="stroke"` (shape outlines and arrowheads use `="all"`), so only
the real edges animate — arrowheads and shapes stay put. `--speed` sets seconds
per cycle, `--dash` the dash pattern, `--reverse` flips the flow direction.
Usage: python3 svgflow.py <file.drawio|file.svg> [-o out.svg]
[--speed SEC] [--dash "6 4"] [--reverse]
"""
import argparse
import os
import re
import subprocess
import sys
import tempfile
EDGE_PATH = re.compile(r'(<path )((?:(?!/?>)[^>])*pointer-events="stroke"(?:(?!/?>)[^>])*/?>)')
def to_svg(path):
"""Return SVG text for a .drawio (export via CLI) or .svg (read directly)."""
if path.lower().endswith(".svg"):
with open(path, encoding="utf-8") as f:
return f.read()
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "d.svg")
r = subprocess.run(["drawio", "-x", "-f", "svg", "-o", out, path],
capture_output=True)
if r.returncode != 0 or not os.path.exists(out):
sys.exit("error: draw.io SVG export failed (is the draw.io CLI installed?)")
with open(out, encoding="utf-8") as f:
return f.read()
def animate(svg, speed, dash, reverse):
"""Tag edge paths and inject the flow keyframes. Returns (svg, edge_count)."""
svg, n = EDGE_PATH.subn(r'\1class="dio-flow" \2', svg)
# One dash+gap of travel per cycle => seamless loop. Reverse flips the sign.
period = sum(float(x) for x in dash.split()) or 10
offset = period if reverse else -period
style = (f"<style>.dio-flow{{stroke-dasharray:{dash};"
f"animation:dio-flow {speed}s linear infinite;}}"
f"@keyframes dio-flow{{to{{stroke-dashoffset:{offset:g};}}}}</style>")
svg = re.sub(r"(<svg\b[^>]*>)", r"\1" + style, svg, count=1)
return svg, n
def main():
ap = argparse.ArgumentParser(description="Animate a diagram's edges into a flowing SVG.")
ap.add_argument("file", help=".drawio (exported to SVG) or an .svg")
ap.add_argument("-o", "--output", help="output .svg (default: <name>-flow.svg)")
ap.add_argument("--speed", type=float, default=1.2, help="seconds per flow cycle (default 1.2)")
ap.add_argument("--dash", default="6 4", help='dash pattern, e.g. "6 4" (default)')
ap.add_argument("--reverse", action="store_true", help="flow toward the source")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.exit(f"error: {args.file} not found")
svg = to_svg(args.file)
if "<svg" not in svg:
sys.exit("error: no <svg> element found in the exported output")
svg, n = animate(svg, args.speed, args.dash, args.reverse)
if n == 0:
sys.stderr.write("warning: no edges found to animate "
"(a diagram with no connectors?)\n")
out = args.output or os.path.splitext(args.file)[0] + "-flow.svg"
with open(out, "w", encoding="utf-8") as f:
f.write(svg)
sys.stderr.write(f"wrote {out} ({n} edge{'s' if n != 1 else ''} animated)\n")
if __name__ == "__main__":
main()
scripts/tfimports.py
#!/usr/bin/env python3
"""Extract a Terraform configuration's resource graph as autolayout graph JSON.
Parses ``.tf`` files with a small regex + brace-matching pass (no HCL library
needed), builds resource-reference edges (``aws_iam_role.lambda.arn`` inside
another resource's body -> edge), and resolves each resource type to its
official draw.io cloud icon via the bundled shape index — AWS (aws4 set),
Azure (azure2 set) and GCP (Google Cloud icon set). The output feeds
autolayout.py:
python3 tfimports.py ./infra -o graph.json
python3 autolayout.py graph.json -o infra.drawio
Nodes are the ``resource`` and ``module`` blocks declared in the scanned files;
data sources, variables, locals and providers are ignored. A reference is any
``type.name`` / ``module.name`` token in a resource body that matches a
declared node — attribute chains (``aws_s3_bucket.logs.arn``), ``"${...}"``
interpolations and ``depends_on`` entries all count. Transitive reduction
(Graphviz ``tred``) keeps big graphs readable; ``--no-reduce`` keeps every
edge. Heredoc bodies with unbalanced braces are the one known parse limit.
Usage: python3 tfimports.py <dir-or-file.tf> [-o graph.json]
[--direction TB|LR] [--group] [--no-reduce] [--no-icons]
"""
import argparse
import glob
import importlib.util
import json
import os
import re
import subprocess
import sys
# provider prefix of the resource type -> (icon query prefix, style predicate).
# The predicate pins results to the modern shape set for that cloud — a bare
# keyword search happily returns another vendor's icon (e.g. "kubernetes
# deployment" -> Azure Arc), so set filtering is what makes resolution safe.
PROVIDERS = {
"aws": ("aws", lambda st: "mxgraph.aws4" in st),
"azurerm": ("azure", lambda st: "img/lib/azure2" in st),
"azuread": ("azure", lambda st: "img/lib/azure2" in st),
"google": ("gcp", lambda st: "editableCssRules" in st),
}
# Resource types whose derived query ("aws lambda function") misses or mis-hits
# the intended icon; values are the query that finds it. Keep alphabetical.
QUERY_OVERRIDES = {
"aws_alb": "aws elastic load balancing",
"aws_apigatewayv2_api": "aws api gateway",
"aws_autoscaling_group": "aws ec2 auto scaling",
"aws_cloudwatch_log_group": "aws cloudwatch",
"aws_db_instance": "aws rds",
"aws_dynamodb_table": "aws dynamodb",
"aws_ecr_repository": "aws elastic container registry",
"aws_ecs_cluster": "aws elastic container service",
"aws_ecs_service": "aws elastic container service",
"aws_ecs_task_definition": "aws elastic container service",
"aws_efs_file_system": "aws elastic file system",
"aws_eks_cluster": "aws elastic kubernetes service",
"aws_elasticache_cluster": "aws elasticache",
"aws_iam_policy": "aws identity and access management",
"aws_instance": "aws ec2",
"aws_kms_key": "aws key management service",
"aws_lambda_function": "aws lambda",
"aws_lb": "aws elastic load balancing",
"aws_rds_cluster": "aws aurora",
"aws_s3_bucket": "aws simple storage service",
"aws_secretsmanager_secret": "aws secrets manager",
"aws_sfn_state_machine": "aws step functions",
"aws_sns_topic": "aws simple notification service",
"aws_sqs_queue": "aws simple queue service",
"azurerm_app_service": "azure app services",
"azurerm_application_gateway": "azure application gateways",
"azurerm_cosmosdb_account": "azure cosmos db",
"azurerm_kubernetes_cluster": "azure kubernetes services",
"azurerm_linux_function_app": "azure function apps",
"azurerm_linux_virtual_machine": "azure virtual machine",
"azurerm_linux_web_app": "azure app services",
"azurerm_mssql_database": "azure sql database",
"azurerm_mssql_server": "azure sql database",
"azurerm_servicebus_namespace": "azure service bus",
"azurerm_storage_account": "azure storage accounts",
"azurerm_virtual_network": "azure virtual networks",
"azurerm_windows_function_app": "azure function apps",
"azurerm_windows_virtual_machine": "azure virtual machine",
"azurerm_windows_web_app": "azure app services",
"google_cloudfunctions2_function": "gcp cloud functions",
"google_cloudfunctions_function": "gcp cloud functions",
"google_compute_instance": "gcp compute engine",
"google_container_cluster": "gcp kubernetes engine",
"google_redis_instance": "gcp memorystore",
"google_sql_database_instance": "gcp cloud sql",
"google_storage_bucket": "gcp cloud storage",
}
_COMMENT = re.compile(r"/\*.*?\*/|(?:#|//)[^\n]*", re.S)
_BLOCK = re.compile(r'^[ \t]*(resource|module)[ \t]+"([\w.-]+)"(?:[ \t]+"([\w.-]+)")?[ \t]*\{', re.M)
_REF = re.compile(r"\b([a-z][a-z0-9_]*\.[A-Za-z_][A-Za-z0-9_-]*)")
def parse_blocks(text):
"""Yield (kind, label1, label2, body) for resource/module blocks."""
text = _COMMENT.sub("", text)
for m in _BLOCK.finditer(text):
depth, i = 1, m.end()
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
yield m.group(1), m.group(2), m.group(3), text[m.end():i - 1]
def load_shapesearch():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shapesearch.py")
spec = importlib.util.spec_from_file_location("shapesearch", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class IconResolver:
"""Resolve a Terraform resource type to an official draw.io icon style."""
def __init__(self):
import gzip
self.ss = load_shapesearch()
with gzip.open(self.ss.INDEX, "rt", encoding="utf-8") as f:
self.shapes = json.load(f)
self.tag_map = self.ss.build_tag_map(self.shapes)
self.cache = {}
def _and_styles(self, words):
"""Style strings of shapes whose tags match EVERY query word.
shapesearch.search() falls back to scored OR matching when the AND set
is empty — fine interactively, but here a partial match means a visibly
wrong icon, so results outside this set are rejected and the caller's
back-off handles the miss (a plain box beats a wrong icon)."""
idxs = None
for t in words:
exact, phonetic = self.ss.match_term(self.tag_map, t)
s = exact | phonetic
idxs = s if idxs is None else idxs & s
if not idxs:
return set()
return {self.shapes[i]["style"] for i in idxs}
def resolve(self, rtype):
if rtype in self.cache:
return self.cache[rtype]
provider = rtype.split("_", 1)[0]
hit = None
if provider in PROVIDERS:
prefix, want = PROVIDERS[provider]
words = (QUERY_OVERRIDES.get(rtype) or
f"{prefix} {rtype.split('_', 1)[1].replace('_', ' ')}").split()
# Back off one trailing word at a time: "aws lambda event source
# mapping" eventually matches on "aws lambda".
while len(words) > 1 and hit is None:
allowed = self._and_styles(words)
good = [r for r in
self.ss.search(self.shapes, self.tag_map, " ".join(words), 40)
if r["style"] in allowed and want(r["style"])
and "group" not in r["style"].lower()]
# Prefer aws4 service icons (resIcon=) over scenario glyphs.
hit = next((r for r in good if "resIcon=" in r["style"]), None) or \
(good[0] if good else None)
words = words[:-1]
if hit and max(hit["w"], hit["h"]) < 44:
# Some sets (GCP) ship tiny nominal sizes; scale up so the icon
# is not dwarfed by its label. aspect=fixed keeps the ratio.
f = 48 / max(hit["w"], hit["h"])
hit = dict(hit, w=round(hit["w"] * f), h=round(hit["h"] * f))
self.cache[rtype] = hit
return hit
def transitive_reduce(nodes, edges):
"""Drop edges implied by a longer path, via Graphviz `tred`."""
idx = {n: i for i, n in enumerate(nodes)}
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
try:
out = subprocess.run(["tred"], input=dot, capture_output=True,
text=True, check=True).stdout
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
return edges
rev = {i: n for n, i in idx.items()}
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
def main():
ap = argparse.ArgumentParser(description="Terraform resource graph -> autolayout graph JSON.")
ap.add_argument("path", help=".tf file or directory containing .tf files")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group resources into containers by service (aws_s3_* -> s3)")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
ap.add_argument("--no-icons", action="store_true",
help="plain boxes instead of official cloud icons")
args = ap.parse_args()
files = ([args.path] if os.path.isfile(args.path)
else sorted(glob.glob(os.path.join(args.path, "**", "*.tf"), recursive=True)))
blocks = []
for path in files:
with open(path, encoding="utf-8") as f:
blocks.extend(parse_blocks(f.read()))
if not blocks:
sys.exit(f"error: no resource/module blocks found under {args.path}")
declared = {} # node id -> (rtype or None, name, body)
for kind, l1, l2, body in blocks:
nid = f"{l1}.{l2}" if kind == "resource" else f"module.{l1}"
declared[nid] = (l1 if kind == "resource" else None, l2 or l1, body)
edges = sorted({(nid, ref) for nid, (_, _, body) in declared.items()
for ref in _REF.findall(body) if ref in declared and ref != nid})
raw = len(edges)
if not args.no_reduce:
edges = transitive_reduce(list(declared), edges)
resolver = None if args.no_icons else IconResolver()
unmatched = []
nodes = []
for nid, (rtype, name, _) in declared.items():
node = {"id": nid, "label": name}
icon = resolver.resolve(rtype) if resolver and rtype else None
if icon:
node.update(style=icon["style"], width=icon["w"], height=icon["h"])
else:
# No icon: keep the type visible on the box (second line).
node["label"] = f"{name}\n{rtype}" if rtype else f"module {name}"
if rtype:
unmatched.append(rtype)
if args.group and rtype and "_" in rtype:
node["group"] = rtype.split("_")[1]
nodes.append(node)
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": s, "target": t} for s, t in edges]}
if resolver:
# Icon labels render below the shape — reserve extra layout spacing.
graph.update(ranksep=0.7, nodesep=0.6)
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(nodes)} nodes, {len(edges)} edges{note}\n")
if unmatched:
sys.stderr.write("no icon for: " + ", ".join(sorted(set(unmatched))) + "\n")
if __name__ == "__main__":
main()
scripts/tfstate.py
#!/usr/bin/env python3
"""Draw the cloud resources ACTUALLY deployed, from `terraform show -json`.
Where tfimports.py reads the *declared* config (`.tf` files), this reads the
*real* state: what Terraform recorded as provisioned. It is provider-agnostic
(the JSON is uniform across AWS / Azure / GCP), expands `count`/`for_each` into
their real instances, keeps module nesting, and reuses tfimports' icon resolver
so every resource shows its official cloud icon. The output feeds autolayout.py:
terraform show -json | python3 tfstate.py - -o graph.json
python3 autolayout.py graph.json -o deployed.drawio
Input is the JSON `terraform show -json` prints — from live state (no argument)
or a saved plan (`terraform show -json plan.tfplan`) — as a file path or `-` for
stdin. Nodes are the managed resource instances (data sources are ignored);
edges come from the dependencies Terraform recorded in state (`depends_on`).
`--group` boxes resources by their module; `--no-icons` forces plain boxes.
Usage: terraform show -json | python3 tfstate.py - [-o graph.json]
[--direction TB|LR] [--group] [--no-reduce] [--no-icons]
"""
import argparse
import importlib.util
import json
import os
import sys
def load_tfimports():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tfimports.py")
spec = importlib.util.spec_from_file_location("tfimports", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def walk_module(mod, out):
"""Collect (address, type, name, index, module_path, depends_on) for every
managed resource, recursing into child modules."""
addr = mod.get("address", "") # "" for root, else module.x[...]
for r in mod.get("resources") or []:
if r.get("mode") == "data":
continue
out.append((r.get("address"), r.get("type"), r.get("name"),
r.get("index"), addr, r.get("depends_on") or []))
for child in mod.get("child_modules") or []:
walk_module(child, out)
def main():
ap = argparse.ArgumentParser(description="`terraform show -json` -> autolayout graph JSON.")
ap.add_argument("input", help="`terraform show -json` output file, or - for stdin")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--group", action="store_true",
help="group resources into containers by module")
ap.add_argument("--no-reduce", action="store_true",
help="keep every edge (skip transitive reduction)")
ap.add_argument("--no-icons", action="store_true",
help="plain boxes instead of official cloud icons")
args = ap.parse_args()
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
sys.exit(f"error: input is not valid JSON ({exc}) — feed `terraform show -json`")
# State: top-level "values"; saved plan: "planned_values".
root = ((data.get("values") or data.get("planned_values") or {}).get("root_module")) or {}
resources = []
walk_module(root, resources)
if not resources:
sys.exit("error: no managed resources found in the Terraform state/plan")
addresses = {r[0] for r in resources}
def targets(dep):
"""Instance addresses a depends_on entry names. State records the
un-indexed address (`aws_subnet.this`) for a resource with several
instances (`aws_subnet.this[0]`), so expand by prefix too."""
if dep in addresses:
return {dep}
return {a for a in addresses if a.startswith(dep + "[")}
edges = sorted({(addr, t) for addr, _, _, _, _, deps in resources
for dep in deps for t in targets(dep) if t != addr})
tf = load_tfimports()
raw = len(edges)
if not args.no_reduce and edges:
edges = tf.transitive_reduce(list(addresses), edges)
resolver = None if args.no_icons else tf.IconResolver()
unmatched, nodes = [], []
for addr, rtype, name, index, mpath, _ in resources:
label = name if index is None else f"{name}[{index}]"
node = {"id": addr, "label": label}
icon = resolver.resolve(rtype) if resolver and rtype else None
if icon:
node.update(style=icon["style"], width=icon["w"], height=icon["h"])
else:
node["label"] = f"{label}\n{rtype}" if rtype else label
if rtype:
unmatched.append(rtype)
if args.group and mpath:
node["group"] = mpath
nodes.append(node)
graph = {"direction": args.direction, "nodes": nodes,
"edges": [{"source": s, "target": t} for s, t in edges]}
if resolver:
# Icon labels render below the shape — reserve extra layout spacing.
graph.update(ranksep=0.7, nodesep=0.6)
out = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(out)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(out)
note = "" if args.no_reduce else f" (reduced from {raw})"
sys.stderr.write(f"{len(nodes)} resources, {len(edges)} edges{note}\n")
if unmatched:
sys.stderr.write("no icon for: " + ", ".join(sorted(set(unmatched))) + "\n")
if __name__ == "__main__":
main()
scripts/timelapse.py
#!/usr/bin/env python3
"""Animate how a codebase's architecture grew, across its git history.
Walks the git history of a directory, re-runs one of the bundled importers at
each sampled commit (the tree is pulled with ``git archive`` — the working copy
is never touched), lays each out and exports a PNG frame, then assembles a
single self-contained HTML player (frames embedded as base64, play / step
controls, no external files or CDNs). Open it in any browser to watch the
modules and edges appear over time.
python3 timelapse.py skills/drawio-skill/scripts --importer pyimports
# -> architecture-evolution.html
The importer is any of the bundled graph extractors (pyimports, jsimports,
goimports, rustimports, pyclasses, tfimports, k8simports, composeimports,
sqlerd); it is run against the archived directory with the same positional
"path" argument they all take, so point the path at the project/package/infra
root the importer expects. Extra importer flags pass through via
``--importer-args`` (e.g. ``--importer-args "--group"``).
Commits that touched the directory are sampled evenly (always keeping the first
and last) down to ``--max-frames``; a commit where the importer finds nothing
(the path did not exist yet) is skipped. Needs git, the importer's requirements,
Graphviz (autolayout) and the draw.io CLI — the same tools the importers use.
Usage: python3 timelapse.py <dir> [--importer NAME] [--importer-args STR]
[--max-frames N] [-o out.html] [--direction TB|LR] [--keep-frames]
"""
import argparse
import base64
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
IMPORTERS = {"pyimports", "jsimports", "goimports", "rustimports", "pyclasses",
"tfimports", "k8simports", "composeimports", "sqlerd"}
def git(root, *args):
"""Run a git command in `root`; return (returncode, stdout_bytes)."""
p = subprocess.run(["git", "-C", root, *args], capture_output=True)
return p.returncode, p.stdout
def sample_indices(total, n):
"""Evenly-spaced indices across range(total), always incl. first and last.
For total <= n every index is kept; otherwise n indices are picked so the
first (0) and last (total-1) are always present and the rest are spread
uniformly between them.
"""
if total <= 0:
return []
if total <= n or n <= 1:
return list(range(total))
return sorted({round(i * (total - 1) / (n - 1)) for i in range(n)})
def history(root, subpath):
"""Chronological [(hash, iso_date, subject), ...] of commits touching subpath."""
code, out = git(root, "log", "--format=%H%x09%aI%x09%s", "--", subpath or ".")
if code != 0:
return []
rows = []
for line in out.decode("utf-8", "replace").splitlines():
parts = line.split("\t", 2)
if len(parts) == 3:
rows.append(tuple(parts))
return list(reversed(rows)) # oldest first
def extract_tree(root, commit, subpath, dest):
"""Extract subpath of `commit` into dest via git archive. False if absent."""
code, tar_bytes = git(root, "archive", commit, subpath or ".")
if code != 0 or not tar_bytes:
return False
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf:
tf.extractall(dest) # git-authored tar; trusted
return True
def build_frame(importer, importer_args, work_path, direction, tmp):
"""Importer -> autolayout -> PNG for one commit. Returns (png_bytes, n, e) or None."""
graph_json = os.path.join(tmp, "graph.json")
imp = subprocess.run(
[sys.executable, os.path.join(HERE, importer + ".py"), work_path,
"-o", graph_json, *importer_args],
capture_output=True)
if imp.returncode != 0 or not os.path.exists(graph_json):
return None
graph = json.loads(open(graph_json, encoding="utf-8").read())
if not graph.get("nodes"):
return None
graph["direction"] = direction
open(graph_json, "w", encoding="utf-8").write(json.dumps(graph))
drawio = os.path.join(tmp, "frame.drawio")
lay = subprocess.run(
[sys.executable, os.path.join(HERE, "autolayout.py"), graph_json, "-o", drawio],
capture_output=True)
if lay.returncode != 0 or not os.path.exists(drawio):
return None
png = os.path.join(tmp, "frame.png")
exp = subprocess.run(["drawio", "-x", "-f", "png", "--width", "1600",
"-o", png, drawio], capture_output=True)
if exp.returncode != 0 or not os.path.exists(png):
return None
return open(png, "rb").read(), len(graph["nodes"]), len(graph["edges"])
def build_html(frames, title):
"""Self-contained HTML player for the frame list."""
data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(),
"hash": h[:9], "date": d[:10], "subj": s, "n": n, "e": e}
for png, h, d, s, n, e in frames]
peak = max((f["n"] for f in data), default=1) or 1
payload = json.dumps(data).replace("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
padding:12px 4px 6px;color:#556;font-size:13px}}
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
.cap b{{color:inherit;font-weight:600}}.cap .subj{{color:#1a1a1a}}
@media(prefers-color-scheme:dark){{.cap .subj{{color:#e8e8e8}}}}
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
button:hover{{border-color:#0d99ff}}
input[type=range]{{flex:1;accent-color:#0d99ff}}
</style></head><body>
<header><h1>{title}</h1></header>
<main>
<div id="stage"><img id="img" alt="architecture frame"></div>
<div class="cap">
<span><b id="idx"></b></span>
<span><b id="hash"></b> · <span id="date"></span></span>
<span class="subj" id="subj"></span>
<span id="counts"></span>
</div>
<div class="barwrap"><div class="bar" id="bar"></div></div>
<div class="ctl">
<button id="prev">‹ Prev</button>
<button id="play">▶ Play</button>
<button id="next">Next ›</button>
<input type="range" id="scrub" min="0" value="0">
</div>
</main>
<script>
const F={payload},PEAK={peak};
let i=0,timer=null;
const $=id=>document.getElementById(id);
$("scrub").max=F.length-1;
function show(k){{
i=(k+F.length)%F.length;const f=F[i];
$("img").src=f.img;$("idx").textContent=`Frame ${{i+1}} / ${{F.length}}`;
$("hash").textContent=f.hash;$("date").textContent=f.date;$("subj").textContent=f.subj;
$("counts").textContent=`${{f.n}} nodes · ${{f.e}} edges`;
$("bar").style.width=(6+94*f.n/PEAK)+"%";$("scrub").value=i;
}}
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
$("prev").onclick=()=>{{stop();show(i-1);}};
$("next").onclick=()=>{{stop();show(i+1);}};
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
$("play").onclick=()=>{{
if(timer){{stop();return;}}
$("play").textContent="⏸ Pause";
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},900);
}};
show(0);
</script></body></html>"""
def main():
ap = argparse.ArgumentParser(description="Git-history architecture time-lapse -> HTML player.")
ap.add_argument("path", help="directory to visualize (inside a git repo)")
ap.add_argument("--importer", default="pyimports",
help="bundled importer to run at each commit (default pyimports)")
ap.add_argument("--importer-args", default="",
help="extra args passed to the importer, e.g. \"--group\"")
ap.add_argument("--max-frames", type=int, default=10, help="sample down to N commits")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("-o", "--output", default="architecture-evolution.html")
ap.add_argument("--keep-frames", action="store_true",
help="also write the PNG frames next to the HTML")
args = ap.parse_args()
importer = args.importer[:-3] if args.importer.endswith(".py") else args.importer
if importer not in IMPORTERS:
sys.exit(f"error: unknown importer {importer!r} (choose one of: "
+ ", ".join(sorted(IMPORTERS)) + ")")
if not os.path.isdir(args.path):
sys.exit(f"error: {args.path} is not a directory")
code, top = git(args.path, "rev-parse", "--show-toplevel")
if code != 0:
sys.exit(f"error: {args.path} is not inside a git repository")
root = top.decode().strip()
subpath = os.path.relpath(os.path.abspath(args.path), root)
if subpath == ".":
subpath = ""
commits = history(root, subpath)
if not commits:
sys.exit(f"error: no commits touch {args.path}")
picked = [commits[i] for i in sample_indices(len(commits), args.max_frames)]
imp_args = args.importer_args.split()
frames = []
for n, (h, date, subj) in enumerate(picked, 1):
sys.stderr.write(f"[{n}/{len(picked)}] {h[:9]} {subj[:50]}\n")
with tempfile.TemporaryDirectory() as tmp:
if not extract_tree(root, h, subpath, tmp):
continue
work = os.path.join(tmp, subpath) if subpath else tmp
frame = build_frame(importer, imp_args, work, args.direction, tmp)
if frame is None:
sys.stderr.write(" (importer found nothing — skipped)\n")
continue
png, nn, ee = frame
frames.append((png, h, date, subj, nn, ee))
if args.keep_frames:
fp = f"{os.path.splitext(args.output)[0]}-frame{len(frames):02d}.png"
open(fp, "wb").write(png)
if not frames:
sys.exit("error: no frames produced (importer found nothing in any commit)")
title = f"Architecture evolution — {os.path.basename(os.path.abspath(args.path))}"
open(args.output, "w", encoding="utf-8").write(build_html(frames, title))
sys.stderr.write(f"wrote {args.output} ({len(frames)} frames)\n")
if __name__ == "__main__":
main()
scripts/tubemap.py
#!/usr/bin/env python3
"""Restyle a graph as a London-Underground-style metro map (Tube-Map Mode).
Input JSON describes coloured *lines* (each an ordered list of station ids) and the
*stations* they pass through, placed on an integer grid. The script snaps stations to
a pixel grid, routes every line segment octilinearly (horizontal / vertical / 45°
diagonal, inserting one bend when two stations are not already aligned), draws thick
coloured line strokes, marks interchange stations as white-fill black-ring circles and
regular stops as small white circles, and labels each station — the classic tube-map
look — as an editable `.drawio`.
python3 tubemap.py metro.json -o metro.drawio
Input schema (see references/tubemap.md for the full authoring guide):
{
"stations": {
"<id>": {"label": "...", "gx": <int>, "gy": <int>, "interchange": <bool?>}
},
"lines": [
{"name": "...", "color": "#rrggbb"?, "stations": ["<id>", "<id>", ...]}
]
}
Keep consecutive stations on a line horizontally, vertically, or 45°-diagonally aligned
for the cleanest routing; any other offset gets one automatic diagonal-then-straight
bend. A line with no "color" is assigned one from the default tube palette by order.
Usage: python3 tubemap.py <metro.json> [-o out.drawio] [--grid N]
"""
import argparse
import json
import sys
# Default palette (approx. real tube-line colours), cycled for lines lacking a "color".
TUBE_PALETTE = [
"#0098d4", # blue
"#007d32", # green
"#e1251b", # red
"#ee7c0e", # orange
"#9b0056", # magenta
"#00a4a7", # teal
"#ffce00", # yellow
"#894e24", # brown
]
def esc(s):
return (s.replace("&", "&").replace("<", "<").replace(">", ">")
.replace('"', """))
def octilinear_waypoints(x1, y1, x2, y2):
"""Waypoints so the path is horizontal, vertical, or 45°: diagonal then straight.
Returns [] when the two points are already octilinearly aligned, else a single bend
point (run the 45° diagonal for the shorter delta, then a straight axis segment).
"""
dx, dy = x2 - x1, y2 - y1
if dx == 0 or dy == 0 or abs(dx) == abs(dy):
return []
sx = 1 if dx > 0 else -1
sy = 1 if dy > 0 else -1
d = min(abs(dx), abs(dy))
if abs(dx) > abs(dy): # diagonal first, then horizontal into the target
return [(x1 + sx * d, y2)]
return [(x2, y1 + sy * d)] # diagonal first, then vertical into the target
def build(data, grid=110):
"""Build the tube-map `.drawio` XML string from the parsed metro description."""
stations = data.get("stations", {})
lines = data.get("lines", [])
if not stations:
sys.exit("error: no stations in input")
for ln in lines:
for sid in ln.get("stations", []):
if sid not in stations:
sys.exit(f"error: line {ln.get('name', '?')!r} references unknown "
f"station id {sid!r}")
ox = oy = 80
G = grid
def px(sid):
s = stations[sid]
return ox + int(s["gx"]) * G, oy + int(s["gy"]) * G
maxx = ox + max(int(s["gx"]) for s in stations.values()) * G + 220
maxy = oy + max(int(s["gy"]) for s in stations.values()) * G + 120
lw = max(8, G // 12)
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<mxfile host="drawio-skill" type="device">',
' <diagram id="tube" name="Tube Map">',
f' <mxGraphModel dx="0" dy="0" grid="0" gridSize="10" pageWidth="{maxx}" '
f'pageHeight="{maxy}" math="0" shadow="0" background="#ffffff">',
' <root><mxCell id="0"/><mxCell id="1" parent="0"/>']
nid = [2]
def cid():
c = nid[0]
nid[0] += 1
return c
# 1) Line strokes first, so station markers sit on top of them.
for i, ln in enumerate(lines):
col = ln.get("color") or TUBE_PALETTE[i % len(TUBE_PALETTE)]
sts = ln.get("stations", [])
for a, b in zip(sts, sts[1:]):
x1, y1 = px(a)
x2, y2 = px(b)
wps = octilinear_waypoints(x1, y1, x2, y2)
arr = ""
if wps:
pts = "".join(f'<mxPoint x="{wx}" y="{wy}"/>' for wx, wy in wps)
arr = f'<Array as="points">{pts}</Array>'
out.append(
f' <mxCell id="e{cid()}" edge="1" parent="1" '
f'style="endArrow=none;startArrow=none;strokeColor={col};strokeWidth={lw};'
f'rounded=1;html=1;edgeStyle=none;">'
f'<mxGeometry relative="1" as="geometry">'
f'<mxPoint x="{x1}" y="{y1}" as="sourcePoint"/>'
f'<mxPoint x="{x2}" y="{y2}" as="targetPoint"/>{arr}</mxGeometry></mxCell>')
# 2) Station markers + labels.
for sid, s in stations.items():
x, y = px(sid)
label = esc(str(s.get("label", sid)))
if s.get("interchange"):
r = lw + 6
marker = (f'ellipse;fillColor=#ffffff;strokeColor=#111111;strokeWidth=3;'
f'html=1;')
else:
r = lw - 1
marker = (f'ellipse;fillColor=#ffffff;strokeColor=#555555;strokeWidth=2;'
f'html=1;')
out.append(
f' <mxCell id="s{cid()}" vertex="1" parent="1" style="{marker}" '
f'value=""><mxGeometry x="{x - r}" y="{y - r}" width="{2 * r}" '
f'height="{2 * r}" as="geometry"/></mxCell>')
out.append(
f' <mxCell id="l{cid()}" vertex="1" parent="1" '
f'style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontStyle=1;'
f'fontColor=#222222;labelBackgroundColor=#ffffff;" value="{label}">'
f'<mxGeometry x="{x + lw + 8}" y="{y - 12}" width="170" height="24" '
f'as="geometry"/></mxCell>')
out.append(' </root></mxGraphModel></diagram></mxfile>')
return "\n".join(out), len(stations), len(lines)
def main():
ap = argparse.ArgumentParser(description="Restyle a graph as a metro / tube map.")
ap.add_argument("input", help="metro JSON (or - for stdin)")
ap.add_argument("-o", "--output", help="output .drawio (default: stdout)")
ap.add_argument("--grid", type=int, default=110, help="grid pitch in px (default 110)")
args = ap.parse_args()
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
sys.exit(f"error: bad JSON in {args.input}: {exc}")
xml, n_st, n_ln = build(data, grid=args.grid)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(xml)
sys.stderr.write(f"wrote {args.output} ({n_st} stations, {n_ln} lines)\n")
else:
sys.stdout.write(xml)
if __name__ == "__main__":
main()
scripts/validate.py
#!/usr/bin/env python3
"""Deterministic structural linter for .drawio files.
Catches the class of mistakes a vision self-check is slow and unreliable at:
dangling edge endpoints, duplicate or reserved ids, broken parent references,
and (as warnings) off-grid geometry, overlapping sibling nodes, and edge
routing defects. Runs without launching draw.io, so it is a fast pre-check
before the visual review step.
python3 validate.py diagram.drawio
Edge routing checks (warnings): an edge segment crossing a non-incident leaf
vertex ("routes through vertex"), and two edges crossing each other ("edges X
and Y cross") — the two defects the SKILL.md step-5 self-check looks for
("Edge-shape overlap", "Stacked edges"), but caught here deterministically.
Routing is only knowable from the XML when an edge carries explicit waypoints
(``<Array as="points">``) — exactly the hand-routed case the SKILL.md tells
authors to use to route around shapes. Edges with no waypoints are auto-routed
by draw.io at render time (the path is not stored), so they are NOT geometry-
checked here, keeping these warnings free of false positives. Endpoints honour
``exitX/exitY``/``entryX/entryY`` when present, else the node centre, and
absolute positions are resolved through parent containers.
Exit status is non-zero when any error (or, with --strict, any warning) is
found, so it can gate a workflow. Compressed (non-XML) diagram pages are
skipped with a warning — this skill always writes uncompressed XML.
Usage: python3 validate.py <file.drawio> [--strict]
"""
import argparse
import sys
import xml.etree.ElementTree as ET
RESERVED = {"0", "1"}
def rect(cell):
"""Return (x, y, w, h) floats for a cell's geometry, or None if absent/bad.
x/y default to 0 when omitted: draw.io treats a missing position as the
origin, and container-managed children (table rows, swimlane/UML-class
lines under tableLayout) legitimately omit x/y while keeping width/height.
Only width/height are required to be present and numeric.
"""
g = cell.find("mxGeometry")
if g is None:
return None
try:
return (float(g.get("x", "0")), float(g.get("y", "0")),
float(g.get("width", "nan")), float(g.get("height", "nan")))
except ValueError:
return None
def is_edge_label(cell):
"""True for a draw.io edge label / relative-positioned child vertex.
These legitimately omit width/height: their position is given relative to a
parent edge (style ``edgeLabel``) or via ``relative="1"`` geometry. Treating
them as normal vertices wrongly flags them as missing/invalid geometry.
"""
if "edgeLabel" in (cell.get("style") or ""):
return True
g = cell.find("mxGeometry")
return g is not None and g.get("relative") == "1"
def overlap(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
# --- Edge routing geometry -------------------------------------------------
#
# These helpers reason about edge paths. They only apply to edges with explicit
# waypoints (the route is otherwise computed by draw.io at render time and not
# stored in the XML), so the checks never guess an auto-routed path.
def style_num(style, key):
"""Return float value of ``key=`` in a draw.io style string, or None."""
for part in (style or "").split(";"):
if part.startswith(key + "="):
try:
return float(part.split("=", 1)[1])
except ValueError:
return None
return None
def abs_rect(cell, by_id):
"""Absolute (x, y, w, h) of a vertex, summing parent-container offsets.
Children of a container use coordinates relative to the container origin, so
an edge spanning containers needs absolute positions to be compared.
"""
r = rect(cell)
if r is None or any(v != v for v in r):
return None
x, y, w, h = r
parent, seen = cell.get("parent"), set()
while parent and parent in by_id and parent not in seen:
seen.add(parent)
p = by_id[parent]
if p.get("vertex") == "1":
pr = rect(p)
if pr and not any(v != v for v in pr):
x += pr[0]
y += pr[1]
parent = p.get("parent")
return (x, y, w, h)
def endpoint(edge, end, by_id):
"""Absolute (x, y) where ``edge`` meets its source/target vertex.
Honours exitX/exitY (source) and entryX/entryY (target) if the style pins
them; otherwise the vertex centre. Returns None if the vertex is unresolved.
"""
vid = edge.get(end)
if not vid or vid not in by_id:
return None
box = abs_rect(by_id[vid], by_id)
if box is None:
return None
x, y, w, h = box
style = edge.get("style") or ""
fx = style_num(style, "exitX" if end == "source" else "entryX")
fy = style_num(style, "exitY" if end == "source" else "entryY")
return (x + (fx if fx is not None else 0.5) * w,
y + (fy if fy is not None else 0.5) * h)
def edge_waypoints(edge):
"""Explicit <Array as="points"> waypoints of an edge as [(x, y), ...]."""
g = edge.find("mxGeometry")
if g is None:
return []
arr = g.find("Array")
if arr is None:
return []
pts = []
for pt in arr.findall("mxPoint"):
px, py = pt.get("x"), pt.get("y")
if px is not None and py is not None:
try:
pts.append((float(px), float(py)))
except ValueError:
pass
return pts
def edge_route(edge, by_id):
"""Absolute polyline [(x, y), ...] for a waypointed edge, or None.
Returns None when the edge has no explicit waypoints (auto-routed; path
unknown) or an endpoint cannot be resolved.
"""
waypoints = edge_waypoints(edge)
if not waypoints:
return None
s, t = endpoint(edge, "source", by_id), endpoint(edge, "target", by_id)
if s is None or t is None:
return None
return [s] + waypoints + [t]
def _orient(a, b, c):
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
def segments_cross(p1, p2, p3, p4):
"""True if segments p1p2 and p3p4 properly cross (interior intersection).
Proper crossing only: collinear overlap and shared-endpoint touches return
False, so edges meeting at a common node or grazing a corner are not flagged.
"""
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4)
def _point_in_rect(p, box, eps=1e-6):
x, y, w, h = box
return x + eps < p[0] < x + w - eps and y + eps < p[1] < y + h - eps
def route_hits_rect(points, box):
"""True if a polyline enters a rectangle's interior or crosses a border."""
x, y, w, h = box
corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
borders = list(zip(corners, corners[1:] + corners[:1]))
for a, b in zip(points, points[1:]):
if _point_in_rect(a, box) or _point_in_rect(b, box):
return True
if any(segments_cross(a, b, c, d) for c, d in borders):
return True
return False
def routes_cross(pa, pb):
"""True if any segment of polyline pa properly crosses any of pb."""
for a1, a2 in zip(pa, pa[1:]):
for b1, b2 in zip(pb, pb[1:]):
if segments_cross(a1, a2, b1, b2):
return True
return False
def geometry_warnings(cells, ids, parents):
"""Edge-through-vertex and edge-crossing warnings for waypointed edges."""
warns = []
routed = [] # (edge_id, polyline, {source, target})
for c in cells:
if c.get("edge") == "1":
pts = edge_route(c, ids)
if pts:
routed.append((c.get("id"), pts,
{c.get("source"), c.get("target")}))
# Edge routes through an unrelated leaf vertex (containers wrap children, so
# an edge legitimately traverses them — restrict to leaves, as overlap does).
leaves = [(c.get("id"), abs_rect(c, ids)) for c in cells
if c.get("vertex") == "1" and c.get("id") not in parents
and not is_edge_label(c)]
leaves = [(vid, box) for vid, box in leaves if box]
for eid, pts, ends in routed:
for vid, box in leaves:
if vid not in ends and route_hits_rect(pts, box):
warns.append(f"edge {eid!r} routes through vertex {vid!r}")
# Edge-edge crossings (both routes known).
for i in range(len(routed)):
for j in range(i + 1, len(routed)):
(ia, pa, _), (ib, pb, _) = routed[i], routed[j]
if routes_cross(pa, pb):
warns.append(f"edges {ia!r} and {ib!r} cross")
return warns
def check_page(diagram):
"""Return (errors, warnings) for one <diagram> page."""
name = diagram.get("name", "?")
model = diagram.find("mxGraphModel")
if model is None:
if (diagram.text or "").strip():
return [], [f"page {name!r}: compressed, skipped (cannot lint)"]
return [f"page {name!r}: no <mxGraphModel>"], []
root = model.find("root")
# Normalize UserObject/object wrappers (used for links & metadata): the id
# lives on the wrapper, geometry/style on the inner mxCell — fold the two
# into one cell so edges referencing the wrapper id resolve.
cells = []
for child in (root if root is not None else []):
if child.tag == "mxCell":
cells.append(child)
elif child.tag in ("UserObject", "object"):
inner = child.find("mxCell")
if inner is not None:
inner.set("id", child.get("id", ""))
cells.append(inner)
errors, warns = [], []
ids = {}
for c in cells:
cid = c.get("id")
if cid in ids:
errors.append(f"duplicate id {cid!r}")
ids[cid] = c
parents = {c.get("parent") for c in cells} # ids that have children
for c in cells:
cid, parent = c.get("id"), c.get("parent")
is_v, is_e = c.get("vertex") == "1", c.get("edge") == "1"
if parent is not None and parent not in ids:
errors.append(f"cell {cid!r} parent {parent!r} does not exist")
for end in ("source", "target"):
ref = c.get(end)
if ref and ref not in ids:
errors.append(f"edge {cid!r} {end} {ref!r} does not exist")
if (is_v or is_e) and cid in RESERVED:
errors.append(f"cell {cid!r} reuses reserved id 0/1")
if is_v and not is_edge_label(c):
r = rect(c)
if r is None or any(v != v for v in r): # None or NaN
errors.append(f"vertex {cid!r} has missing/invalid geometry")
else:
x, y, w, h = r
if w <= 0 or h <= 0:
warns.append(f"vertex {cid!r} non-positive size {w:g}x{h:g}")
if x < 0 or y < 0:
warns.append(f"vertex {cid!r} negative position ({x:g},{y:g})")
# Sibling overlap: only leaf vertices (containers legitimately wrap children).
boxes = [(c.get("id"), c.get("parent"), rect(c)) for c in cells
if c.get("vertex") == "1" and c.get("id") not in parents and rect(c)
and not any(v != v for v in rect(c))]
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
(ia, pa, ra), (ib, pb, rb) = boxes[i], boxes[j]
if pa == pb and overlap(ra, rb):
warns.append(f"vertices {ia!r} and {ib!r} overlap")
warns += geometry_warnings(cells, ids, parents)
return errors, warns
def main():
ap = argparse.ArgumentParser(description="Lint a .drawio file for structural errors.")
ap.add_argument("file")
ap.add_argument("--strict", action="store_true", help="treat warnings as failure too")
ap.add_argument("--score", action="store_true",
help="also print a readability score (lower is better) — "
"useful for comparing layout variants of the same graph")
args = ap.parse_args()
try:
tree = ET.parse(args.file)
except (ET.ParseError, OSError) as exc:
sys.exit(f"error: cannot parse {args.file}: {exc}")
pages = tree.getroot().findall("diagram") or [tree.getroot()]
errors, warns = [], []
for page in pages:
e, w = check_page(page)
errors += e
warns += w
for w in warns:
print(f"warning: {w}")
for e in errors:
print(f"error: {e}")
print(f"{len(errors)} error(s), {len(warns)} warning(s)")
if args.score:
# Weighted by how badly each defect hurts readability. Comparable only
# across variants of the SAME graph (same nodes/edges).
through = sum(1 for w in warns if "routes through" in w)
cross = sum(1 for w in warns if " cross" in w)
olap = sum(1 for w in warns if " overlap" in w)
print(f"score: {20 * through + 10 * cross + 5 * olap} "
f"({through} through-vertex, {cross} crossings, {olap} overlaps)")
if errors or (args.strict and warns):
sys.exit(1)
if __name__ == "__main__":
main()
SKILL.md
---
name: drawio-skill
version: 2.1.0
description: Use when the user requests diagrams, flowcharts, architecture diagrams, ER diagrams, UML / sequence / class diagrams, SysML / MBSE diagrams (block definition, internal block, requirement, parametric), BPMN business process diagrams, swimlane / cross-functional flowcharts, network topology, cloud architecture from Terraform or Kubernetes manifests, ML/DL model figures (Transformer/CNN/LSTM), mind maps, or any visualization. Also use proactively when explaining systems with 3+ components, complex data flows, or relationships that benefit from visual representation. Best suited when the diagram needs custom styling, rich shape vocabulary, swimlanes, or exportable images (PNG/SVG/PDF/JPG). Generates .drawio XML and exports locally via the native draw.io desktop CLI.
license: MIT
homepage: https://github.com/Agents365-ai/drawio-skill
compatibility: Requires draw.io desktop app CLI on PATH (macOS/Linux/Windows). Self-check step requires a vision-enabled model (e.g., Claude Sonnet/Opus); gracefully skipped if unavailable. Optional auto-layout (scripts/autolayout.py) needs Graphviz (dot).
platforms: [macos, linux, windows]
metadata: {"openclaw":{"requires":{"anyBins":["draw.io","drawio"]},"emoji":"📐","os":["darwin","linux","win32"],"install":[{"id":"brew-drawio","kind":"brew","formula":"drawio","bins":["drawio"],"label":"Install draw.io via Homebrew","os":["darwin"]},{"id":"brew-graphviz","kind":"brew","formula":"graphviz","bins":["dot"],"label":"Install Graphviz for optional autolayout.py","os":["darwin"],"optional":true}]},"hermes":{"tags":["drawio","diagram","flowchart","architecture","visualization","uml"],"category":"design","requires_tools":["drawio","draw.io"],"related_skills":["mermaid","excalidraw","plantuml"]},"author":"Agents365-ai","version":"2.1.0"}
---
# Draw.io Diagrams
## Overview
Generate `.drawio` XML files and export to PNG/SVG/PDF/JPG locally using the native draw.io desktop app CLI.
**Supported formats:** PNG, SVG, PDF, JPG — no browser automation needed.
PNG, SVG, and PDF exports support `--embed-diagram` (`-e`) — the exported file contains the full diagram XML, so opening it in draw.io recovers the editable diagram. Use double extensions (`name.drawio.png`) to signal embedded XML.
## When to use / when NOT to use
**Use this skill for:** polished, precise diagrams (architecture, network, strict UML, ERD), anything needing solid opaque fills, 10,000+ stock/branded shapes, swimlanes, or custom geometry, exported as editable PNG/SVG/PDF.
**Do NOT use it — route elsewhere — for:**
- A casual hand-drawn / whiteboard look → **excalidraw** or **tldraw**.
- Diagrams-as-code that live in git / render in Markdown → **mermaid** (general) or **plantuml** (UML).
- Freeform infinite-canvas sketching or freehand strokes → **tldraw**.
## Bundled resources
When the workflow references one of these, read it on demand — none of them need to be in context up front.
| File | Read it when |
| --- | --- |
| `references/toolbox.md` | You're **not sure which bundled script fits** a request, or want to chain several — a map of all 31 scripts grouped by use-case (author / import code / import IaC / import API spec / live infra / compare / annotate / reverse-export / utilities) with an "I have X, I want Y → use Z" guide |
| `references/xml-authoring.md` | You're about to **hand-write `.drawio` XML** (workflow step 3) — file skeleton, shape/edge cells, containers, connection distribution, palette, spacing/grid rules. Not needed when a bundled generator writes the XML |
| `references/mermaid-authoring.md` | The diagram is a **standard type with no custom styling/icon needs** (flowchart, state, gantt, mindmap, timeline, journey, pie, …) and the CLI is **≥ v30** — author it as Mermaid text and let the CLI convert to native `.drawio` (structure only, layout free). Also documents the CLI's ELK `--layout` pass for XML |
| `references/diagram-types.md` | The user names a specific diagram type (ERD, UML class, sequence, C4, architecture, ML/DL, flowchart, SysML, BPMN, network topology, swimlane) |
| `references/shapes.md` + `scripts/shapesearch.py` | The diagram needs a **specific shape** — a cloud icon (AWS/Azure/GCP), Cisco/Kubernetes/network symbol, UML/BPMN/ER/electrical/P&ID element — or any time you'd otherwise guess a `style=` string. `shapesearch.py "<keywords>"` returns the exact official style for 10k+ shapes |
| `scripts/aiicons.py` | The diagram involves an **AI/LLM brand** (OpenAI, Claude, Gemini, Mistral, Llama, HuggingFace, Ollama, LangChain, …) — `aiicons.py "<brand>"` returns a draw.io `image` style for the brand logo (lobe-icons via CDN; `--embed` to inline). draw.io has no built-in AI logos. See `references/shapes.md` → "AI / LLM brand logos" |
| `references/style-presets.md` | The user asks to learn / save / list / set-default / delete a style preset, or you've resolved an active preset and need the application rules |
| `references/style-extraction.md` | You're inside the Learn flow and need the extraction procedure (called from `style-presets.md`) |
| `references/troubleshooting.md` | An export fails, vision rejects a PNG, or a rendering looks wrong |
| `scripts/repair_png.py` | After every `-e` PNG export — fixes draw.io's truncated IEND chunk (issue #8) |
| `scripts/encode_drawio_url.py` | The CLI is unavailable and you need a browser-fallback diagrams.net URL (`--edit` for an editable editor URL) |
| `references/autolayout.md` | The diagram is large or layout-heavy (dependency/call graph, code structure, >~15 nodes) and you want Graphviz to place nodes + route edges instead of hand-placing coordinates |
| `scripts/pyimports.py` · `jsimports.py` · `goimports.py` · `rustimports.py` | The user wants to visualize a **Python, JS/TS, Go, or Rust project** structure — extracts the import graph (transitive-reduced, optional `--group` containers, nested by sub-package) for autolayout |
| `scripts/pyclasses.py` | The user wants a **Python class hierarchy / class diagram** — extracts classes + inheritance edges (boxed by module with `--group`) for autolayout |
| `scripts/tfimports.py` · `k8simports.py` · `composeimports.py` | The user wants to visualize **declared** infrastructure (**Terraform** `.tf`, **Kubernetes** manifests, or **docker-compose**) — extracts the resource/service reference graph (official AWS/Azure/GCP/K8s icons for tf/k8s; service boxes + volume cylinders for compose) for autolayout |
| `scripts/tfstate.py` · `dockerimports.py` (+ `k8simports.py`) | The user wants to draw **what is ACTUALLY running / deployed** — pipe `terraform show -json` (deployed state), `docker inspect $(docker ps -q)` (live containers), or `kubectl get all,ing,cm,secret,pvc -o json` (live cluster, via k8simports) and get the real topology with the same official icons. See `references/live-infra.md` |
| `scripts/drawiodiff.py` | The user wants to **compare / diff two diagrams or two snapshots** ("what changed", infra drift) — `drawiodiff.py old.drawio new.drawio -o diff.json` emits a colour-coded graph (added=green, removed=red, changed=orange, same=grey) for autolayout. Matches by cell id (importer/live-snapshot output) or `--by-label` (hand-drawn) |
| `scripts/timelapse.py` | The user wants an **architecture time-lapse / to see how a codebase's structure evolved over git history** — `timelapse.py <dir> --importer pyimports` re-runs an importer at each sampled commit and assembles a self-contained HTML player (embedded frames, play/step controls). Best on a package with real import edges (point `<dir>` at the module root) |
| `scripts/explain.py` | The user wants to **describe / document / summarize an existing `.drawio` in words** (reverse of generating one) — `explain.py diagram.drawio` emits structured Markdown: components grouped by container/tier, relations (`A —label→ B`), per-page sections for multi-page/C4. Good for a README/PR summary or a text-only read-out |
| `scripts/drawio2pptx.py` | The user wants a **PowerPoint deck / slides from a diagram** — `drawio2pptx.py diagram.drawio -o deck.pptx` puts each page on its own 16:9 slide (page name as title), so a multi-page **C4 model** becomes a ready-to-present deck. Needs `python-pptx` (`pip install python-pptx`) + the draw.io CLI |
| `scripts/drawiohtml.py` | The user wants a **shareable interactive viewer** for a diagram (pan / zoom / search, no draw.io needed) — `drawiohtml.py diagram.drawio -o viewer.html` inlines every page's SVG into ONE self-contained HTML with page tabs, drag-pan, wheel-zoom, node search (Enter cycles + centres matches) and **working drill-down links** (a C4 model's `data:page/id` links switch tabs). No server, no external requests — send the file to anyone |
| `scripts/svgflow.py` | The user wants an **animated / "flowing" diagram** (data-flow, moving edges) — `svgflow.py diagram.drawio -o flow.svg` exports to SVG and makes every edge a marching-ants animation (dashes travel along the arrows). Self-contained looping `.svg` that renders on GitHub / any browser; `--speed` / `--dash` / `--reverse` |
| `scripts/drawio2mermaid.py` | The user wants to **convert a `.drawio` into Mermaid text** (diagrams-as-code for a Markdown file that GitHub renders) — `drawio2mermaid.py diagram.drawio` emits a `flowchart` (containers → `subgraph`s, edge labels kept, cylinder/rhombus shapes mapped); `--fenced` wraps in ```mermaid, multi-page → one graph per page. Structural only (styling/icons don't survive) |
| `scripts/sqlerd.py` | The user wants an **ER diagram from SQL DDL** — parses `CREATE TABLE` statements into per-table nodes (columns with PK/FK markers) and crow's-foot FK edges for autolayout |
| `scripts/ciimports.py` | The user wants a **CI pipeline diagram** (GitHub Actions workflows or GitLab CI) — `ciimports.py <repo-root>` reads `.github/workflows/*.yml` + `.gitlab-ci.yml` and emits jobs (runner, matrix size, reusable-workflow calls), `needs:` dependency edges, per-workflow trigger nodes, and stage/workflow containers for autolayout. Needs PyYAML |
| `scripts/openapiimports.py` | The user wants an **API diagram from an OpenAPI / Swagger spec** — `openapiimports.py spec.yaml` maps each operation to a node **coloured by HTTP method** (GET blue, POST green, PUT/PATCH orange, DELETE red) plus one node per component schema, with edges from operations to the schemas they use and between nested schemas. `--group` boxes by tag, `--no-schemas` shows just the endpoint surface; feeds autolayout |
| `scripts/heatmap.py` | The user wants to **colour an existing `.drawio` by data** (a cost / latency / traffic / error-rate heat map) — `heatmap.py diagram.drawio -m metrics.csv` matches each metric (CSV `key,value` or JSON `{key:value}`) to a node by id or label and recolours it along a gradient (`--palette heat\|cool\|warm`, `--reverse`), optionally scaling node size (`--size`) and adding a legend. Post-processes any diagram; export as usual |
| `scripts/seqlayout.py` | The user wants a **sequence diagram** — describe participants + messages as JSON and the script computes all lifeline/activation/arrow geometry deterministically (no hand-placed coordinates, no Graphviz needed) |
| `scripts/c4.py` | The user wants a **C4 model** (System Context / Container / Component) — levels JSON in, one multi-page `.drawio` out with official C4 shapes/colors and **click-to-drill-down** links between levels |
| `scripts/relabel.py` | The user wants a **language variant or bulk text swap of an existing `.drawio`** (e.g. an EN diagram re-labelled in Chinese for a bilingual README) — `relabel.py diagram.drawio --extract -o labels.json` dumps every label as an identity JSON map; translate the values (keep the keys), then `relabel.py diagram.drawio --map labels.json -o diagram_cn.drawio` swaps them with layout/styles/ids untouched |
| `scripts/restyle.py` | The user wants to **re-theme an EXISTING `.drawio`** ("make this dark", "apply my corporate style to this diagram") — `restyle.py diagram.drawio --preset <name>` remaps every vertex fill/stroke to the preset palette by hue, applies font/extras (dark fontColor, edge color, background), and leaves layout, shapes, and edge routing untouched. Presets resolve like Step 0 (user dir, then built-ins) |
| `scripts/edgeports.py` | Edges **stack on top of each other where they meet a shape** — the usual swimlane/cross-functional complaint, and anywhere a node has several connections leaving the same side. `edgeports.py diagram.drawio` pins `exitX/exitY`+`entryX/entryY`: it picks the side of each node facing the other endpoint, then spreads that side's edges over evenly-spaced slots **ordered by the far endpoint's position**, so they keep their relative order instead of crossing. Resolves absolute coordinates through swimlane parents, skips ends you already pinned, and is idempotent. It is a **port assigner, not a router** — it separates lines at the shape boundary, it will not stop an edge crossing an unrelated shape mid-run (add waypoints for that) |
| `scripts/validate.py` | You generated a `.drawio` (especially via autolayout or for a large hand-placed diagram) and want a fast deterministic structural lint (dangling edges, dup/reserved ids, broken parents, overlaps) before the vision self-check. `--score` prints a readability score for comparing layout variants |
| `scripts/raster2drawio.py` | The user has an **image of a diagram** (whiteboard photo, legacy PNG, Visio screenshot) and wants an **editable `.drawio`** — read the image with your own vision, extract nodes/edges as JSON (schema + full workflow in `references/derasterize.md`), then `raster2drawio.py graph.json -o out.drawio` honours those coordinates/labels/shapes; nodes missing `x`/`y` fall back to `autolayout.py` placement |
| `scripts/buildup.py` | The user wants a diagram to **build itself node-by-node** as a video/GIF (a construction time-lapse of ONE static diagram — distinct from `timelapse.py`'s git-history animation) — `buildup.py diagram.drawio` reveals cells in topological (dependency) order into a self-contained HTML player (play/pause/step/scrub); `--gif` also exports an animated GIF (needs Pillow). Needs the draw.io CLI |
| `scripts/compress.py` | The user wants an **executive / boardroom summary of a big diagram** — collapses clusters (pure-Python label propagation, no networkx) into one labeled node each with aggregated inter-cluster edges, emitting a 2-page `.drawio` (exec view + click-to-drill-down into the full original). Claude can rename clusters semantically afterward. Needs Graphviz `dot` |
| `scripts/runbook.py` | The user wants a flowchart/decision-tree `.drawio` turned into a **click-through triage app** (on-call runbook) — `runbook.py flow.drawio` reads the XML (no draw.io CLI needed) and emits a self-contained HTML runbook: current-step text, per-edge choice buttons, breadcrumb trail, Back/Restart, end-state on terminal nodes |
| `scripts/prdiff.py` | You're setting up **automated PR diagram review** in CI — for every `.drawio` changed between two git refs it renders base/head/diff PNGs and emits a Markdown report; ships with a composite GitHub Action (`.github/actions/drawio-diff/`) that posts a sticky PR comment. See `references/pr-bot.md` |
| `scripts/tubemap.py` | The user wants a **metro / subway / tube map** — a system, pipeline, or journey drawn as coloured transit lines with octilinear (H/V/45°) routing, white interchange circles, and station stops. Compose a metro JSON (lines = ordered stations on an integer grid, shared stations = interchanges), then `tubemap.py metro.json -o metro.drawio`. Stdlib-only; schema + the one grid rule in `references/tubemap.md` |
## Prerequisites
The draw.io desktop app must be installed and the CLI accessible:
**macOS sandbox / sandbox isolation note (e.g., codex.app):** In some sandboxed macOS environments, invoking the draw.io desktop CLI (even `drawio --version`) can crash the draw.io process or produce no output. If that happens, treat the CLI as **unavailable in this sandbox isolation** — do not keep retrying inside the sandbox. Prefer a **non-sandboxed host environment** (outside sandbox isolation) for any CLI export work, or use the browser fallback / XML-only outputs.
```bash
# macOS (Homebrew — recommended; CLI binary is `drawio`, not `draw.io`)
brew install --cask drawio
drawio --version
# macOS (full path if not in PATH)
/Applications/draw.io.app/Contents/MacOS/draw.io --version
# Windows
"C:\Program Files\draw.io\draw.io.exe" --version
# Linux
drawio --version
```
Install draw.io desktop if missing:
- macOS: `brew install --cask drawio` or download from <https://github.com/jgraph/drawio-desktop/releases>
- Windows: download installer from <https://github.com/jgraph/drawio-desktop/releases>
- Linux: download `.deb`/`.rpm` from <https://github.com/jgraph/drawio-desktop/releases> — **do not use snap** (AppArmor sandbox denies secrets/keyring on servers, causes crash)
## Workflow
Before starting the workflow, assess whether the user's request is specific enough. If key details are missing, ask 1-3 focused questions:
- **Diagram type** — which preset? (ERD, UML, Sequence, Architecture, ML/DL, Flowchart, SysML, BPMN, Network, Swimlane, or general)
- **Output format** — PNG (default), SVG, PDF, or JPG?
- **Output location** — default is the user's working dir; honor any explicit path the user gives (e.g. "put it in `./artifacts/`"). Don't ask if they didn't mention one.
- **Scope/fidelity** — how many components? Any specific technologies or labels?
Skip clarification if the request already specifies these details or is clearly simple (e.g., "draw a flowchart of X").
**Step 0 — Resolve active preset.** Determine which (if any) user-defined style preset applies to this generation.
- Scan the user's message for a phrase that clearly names a style preset: "use my `<name>` style", "with my `<name>` style", "in `<name>` mode", "in the style of `<name>`". A bare `with <name>` does **not** count — "draw a diagram with redis" names a component, not a style. If a clear match is found → active preset = `<name>`.
- Else, check `~/.drawio-skill/styles/` for any file with `"default": true`. If found → active preset = that one.
- Else → no preset active; fall through to the built-in color/shape/edge conventions for the rest of the workflow.
Load the preset JSON from `~/.drawio-skill/styles/<name>.json`, falling back to `<this-skill-dir>/styles/built-in/<name>.json`. If the named preset exists in neither location, tell the user the name is unknown, list the available presets (user dir + built-in), and stop — do **not** silently fall back to defaults.
When a preset loads successfully, mention it in the first line of the reply: *"Using preset `<name>` (confidence: `<level>`)."* See `references/style-presets.md` → "Applying a preset" for how the preset changes color/shape/edge/font decisions.
1. **Check deps** — **resolve which name the binary has on this system** and use that name verbatim in every subsequent command in this workflow. Try in order: (a) `drawio --version` (the canonical name for Homebrew cask, jgraph `.deb`/`.rpm`, Arch AUR), (b) `draw.io --version` (older builds, some custom symlinks, some distro packages), (c) macOS `.app` direct: `/Applications/draw.io.app/Contents/MacOS/draw.io --version`, (d) Windows: `"C:\Program Files\draw.io\draw.io.exe" --version`. The first one that prints a version is your binary; remember the exact path/name and substitute it for `drawio` in every export command below. **Do not copy the example commands verbatim if your binary is named differently** — the examples use `drawio` only because it's the most common. On macOS-Homebrew, `drawio` is just a thin wrapper script that execs `/Applications/draw.io.app/Contents/MacOS/draw.io` — they run the same engine, so candidate (c) is only needed when the `drawio` wrapper is absent (e.g. the app was installed by drag-and-drop without the cask). **Also note the major version** the command printed: **≥ 30** unlocks Mermaid→`.drawio` conversion and the ELK `--layout` pass (see `references/mermaid-authoring.md`); on **≤ 29** both are unavailable — `.mmd` input fails and `--layout` corrupts argument parsing — so never emit those flags there.
2. **Plan** — identify shapes, relationships, layout (LR or TB), group by tier/layer
3. **Generate** — produce the `.drawio` file, choosing the authoring mode: **(a) Mermaid → CLI convert** when the diagram is a standard type with no custom styling/icon needs **and** the CLI is ≥ v30 — write a `.mmd` and run `drawio -x -f xml -o <name>.drawio <name>.mmd`, see `references/mermaid-authoring.md` (structure only; layout comes free; never `--layout` afterwards). **(b) Hand-written XML** for custom styling, vendor icons, swimlanes, precise geometry — **read `references/xml-authoring.md` first** (skeleton, cell forms, palette, spacing rules). **(c) A bundled generator** for the data-driven cases below. **For large or layout-heavy diagrams (dependency/call graphs, code structure, >~15 nodes), don't hand-place** — describe the graph as JSON and run `python3 <this-skill-dir>/scripts/autolayout.py graph.json -o <name>.drawio` to compute node positions + orthogonal edge routing via Graphviz (see `references/autolayout.md`; add `--tune` to auto-pick the more readable direction). For a **Python / JS-TS / Go / Rust project**, the matching importer (`scripts/pyimports.py`, `jsimports.py`, `goimports.py`, or `rustimports.py`) extracts the import graph (transitive-reduced; add `--group` to box modules by sub-package, nested for deep trees) ready for autolayout; for a **Python class hierarchy**, `scripts/pyclasses.py` extracts classes + inheritance instead; for **Terraform / Kubernetes / docker-compose** (`scripts/tfimports.py`, `k8simports.py`, `composeimports.py`), the importer extracts the resource/service reference graph — tf/k8s nodes resolve to their official cloud icons automatically; to draw **what is actually running** rather than the declared config, pipe `terraform show -json` into `scripts/tfstate.py` or `docker inspect $(docker ps -q)` into `scripts/dockerimports.py` (`k8simports.py` already accepts live `kubectl get ... -o json`) — see `references/live-infra.md`; for an **ER diagram from SQL DDL**, `scripts/sqlerd.py` parses `CREATE TABLE` into table nodes + crow's-foot FK edges; for an **API diagram from an OpenAPI / Swagger spec**, `scripts/openapiimports.py` maps operations (coloured by HTTP method) + component schemas into a graph for autolayout; for a **CI pipeline diagram** (GitHub Actions / GitLab CI), `scripts/ciimports.py` extracts jobs, `needs:` edges, triggers, and stage/workflow containers. To turn any generated `.drawio` into a **metric heat map** — recolour nodes by a CSV/JSON of cost/latency/traffic/errors — run `python3 <this-skill-dir>/scripts/heatmap.py <name>.drawio -m metrics.csv` (matches on cell id or label; `--palette`, `--size`, legend). For a **sequence diagram**, skip autolayout entirely — describe participants + messages as JSON and run `python3 <this-skill-dir>/scripts/seqlayout.py seq.json -o <name>.drawio` (deterministic lifeline/activation/arrow geometry; see the script docstring for the JSON schema). For a **C4 model**, `python3 <this-skill-dir>/scripts/c4.py c4.json -o <name>.drawio` emits the full multi-page Context→Container→Component set with drill-down links (schema in the script docstring). For complex architecture diagrams with many visible edge labels, give labels `labelBackgroundColor=#ffffff;fontSize=11` and use edge geometry `x`/`y` offsets plus `<mxPoint as="offset" />` to move long labels into nearby whitespace instead of relying on draw.io's default midpoint placement. For hand-placed diagrams where edges cross shapes (architecture, network topology, deployment, UML), fix the routing in the XML — run `python3 <this-skill-dir>/scripts/edgeports.py <name>.drawio` to distribute stacked edges over each shape's perimeter automatically, then add `<Array as="points">` waypoints or widen node spacing for any edge still crossing a shape mid-run (see `references/xml-authoring.md`). **No CLI flag reroutes edges without moving nodes**: every `--layout` preset is an ELK *node* layout that re-places vertices, and an unrecognised value opens a modal error dialog that hangs headless runs. draw.io's obstacle-avoiding router is editor-side only. After generating any `.drawio`, run `python3 <this-skill-dir>/scripts/validate.py <name>.drawio` for a fast structural lint (dangling edges, dup ids, overlaps) before exporting. Default output dir is the user's working dir; if the user specified an output path or directory (e.g. `./artifacts/`, `docs/images/`), use that instead — `mkdir -p` the target dir first. Apply the same dir choice to PNG/SVG/PDF exports in steps 4 and 7.
4. **Export draft** — run CLI to produce a preview PNG. **Do NOT pass `-e`** at this step — the embedded `zTXt mxGraphModel` chunk it adds causes vision APIs (Claude included) to return 400 "Could not process image" in step 5. **Cap the preview width with `--width 2000` (not `-s 2`)** — Claude's vision API rejects images larger than 2576×2576px with "Unable to resize image — dimensions exceed the 2576x2576px limit", and `-s 2` on a medium-or-larger diagram easily overshoots that ceiling. Save the clean preview as `<name>.png` (single extension). Embedding and full-resolution scale are for the final export only (step 7).
5. **Self-check** — use the agent's built-in vision capability to read the exported PNG, catch obvious issues, auto-fix before showing user (requires a vision-enabled model such as Claude Sonnet/Opus). If reading the PNG returns a 400 / "Could not process image" error, you almost certainly exported with `-e` by mistake — re-export without `-e` and retry once. If it still fails, skip self-check and continue to step 6.
6. **Review loop** — show image to user, collect feedback, apply targeted XML edits, re-export, repeat until approved
7. **Final export** — re-export the approved version to all requested formats. Use `-e` here (PNG/SVG/PDF) so the deliverable stays editable in draw.io; save as `<name>.drawio.png` to signal embedded XML. **For PNG with `-e`, run `python3 <this-skill-dir>/scripts/repair_png.py <name>.drawio.png` immediately after** — draw.io's CLI truncates the IEND chunk in `-e` PNG output (8 bytes missing), producing a corrupt file that vision APIs and strict PNG decoders reject (issue #8). Report file paths.
**If `drawio --version` crashes or prints nothing (common in restricted macOS sandbox isolation like codex.app):**
- Do not keep retrying CLI invocations inside the sandbox.
- Skip steps 4, 5, 6, and 7 (CLI export + PNG-based review) and use **Browser fallback** (`scripts/encode_drawio_url.py`) or deliver the `.drawio` XML only.
- If the user needs PNG/SVG/PDF outputs, ask them to run the export commands in a **non-sandboxed host environment** (outside sandbox isolation) and share the resulting files.
Escalation rule:
- If the binary exists on PATH (or known app path exists) but execution fails with abnormal exit, empty output, Electron startup failure, display/session error, or likely sandbox restriction, prefer one escalated retry before falling back.
- If the binary is missing entirely, do not escalate just to search more aggressively; go to install guidance or fallback.
### Step 5: Self-Check
After exporting the draft PNG, use the agent's vision capability (e.g., Claude's image input) to read the image and check for these issues before showing the user. If the agent does not support vision, skip self-check and show the PNG directly.
**Important:** the draft PNG read here must have been exported **without** `-e`. Draw.io's `-e` flag emits a PNG with a truncated IEND chunk (8 bytes of type+CRC missing) that the Anthropic vision API rejects with 400 "Could not process image" (issue #8). The simplest fix for the preview step is to skip `-e` entirely; the final export in step 7 keeps `-e` and runs the repair snippet. If you see the 400 error here, re-export without `-e` and retry once; if it still fails (any other reason), skip self-check and proceed to step 6.
| Check | What to look for | Auto-fix action |
| ------- | ----------------- | ----------------- |
| Overlapping shapes | Two or more shapes stacked on top of each other | Shift shapes apart by ≥200px |
| Clipped labels | Text cut off at shape boundaries | Increase shape width/height to fit label |
| Missing connections | Arrows that don't visually connect to shapes | Verify `source`/`target` ids match existing cells |
| Off-canvas shapes | Shapes at negative coordinates or far from the main group | Move to positive coordinates near the cluster |
| Edge-shape overlap | An edge/arrow visually crosses through an unrelated shape | Add waypoints (`<Array as="points">`) to route around the shape, or increase spacing between shapes |
| Stacked edges | Multiple edges overlap each other on the same path | Distribute entry/exit points across the shape perimeter (use different exitX/entryX values) |
| Edge-label overlap | Edge text overlaps another label, line, or node in the exported PNG | Keep the label on the edge, add a white label background, and move it locally with edge geometry `x`/`y` offsets into adjacent whitespace |
- Max **2 self-check rounds** — if issues remain after 2 fixes, show the user anyway
- Re-export after each fix and re-read the new PNG
### Step 6: Review Loop
After self-check, show the exported image and ask the user for feedback.
**Targeted edit rules** — for each type of feedback, apply the minimal XML change:
| User request | XML edit action |
| ------------- | ---------------- |
| Change color of X | Find `mxCell` by `value` matching X, update `fillColor`/`strokeColor` in `style` |
| Add a new node | Append a new `mxCell` vertex with next available `id`, position near related nodes |
| Remove a node | Delete the `mxCell` vertex and any edges with matching `source`/`target` |
| Move shape X | Update `x`/`y` in the `mxGeometry` of the matching `mxCell` |
| Resize shape X | Update `width`/`height` in the `mxGeometry` of the matching `mxCell` |
| Add arrow from A to B | Append a new `mxCell` edge with `source`/`target` matching A and B ids |
| Change label text | Update the `value` attribute of the matching `mxCell` |
| Change layout direction | **Full regeneration** — rebuild XML with new orientation |
**Rules:**
- For single-element changes: edit existing XML in place — preserves layout tuning from prior iterations
- For layout-wide changes (e.g., swap LR↔TB, "start over"): regenerate full XML
- Overwrite the same `{name}.png` (no `-e`) each iteration — do not create `v1`, `v2`, `v3` files. `-e` is reserved for the final export in step 7.
- After applying edits, re-export and show the updated image
- Loop continues until user says approved / done / LGTM
- **Safety valve:** after 5 iteration rounds, suggest the user open the `.drawio` file in draw.io desktop for fine-grained adjustments
### Step 7: Final Export
Once the user approves:
- Export to all requested formats (PNG, SVG, PDF, JPG) — default to PNG if not specified
- Report file paths for both the `.drawio` source file and exported image(s)
- **Auto-launch:** offer to open the `.drawio` file in draw.io desktop for fine-tuning — `open diagram.drawio` (macOS), `xdg-open` (Linux), `start` (Windows)
- Confirm files are saved and ready to use
## Style Presets
A **style preset** is a named JSON file capturing a user's visual preferences (palette, shapes, font, edges). When active, it fully replaces the built-in color/shape conventions in this skill.
**Lookup order** when SKILL.md's Step 0 resolves a preset name:
1. `~/.drawio-skill/styles/<name>.json` — user presets (survive `git pull`)
2. `<this-skill-dir>/styles/built-in/<name>.json` — shipped built-ins (`default`, `corporate`, `handdrawn`, `colorblind-safe`, `dark`)
Always lowercase the user-provided name before any file operation — the schema enforces lowercase.
**For everything else — Learn flow (extracting a preset from a file), management ops (list/default/delete/rename), application rules (color lookup, shape keywords, edges, fonts, extras, interaction with diagram-type presets), and validation — read `references/style-presets.md`.** It's only needed when the user invokes those flows or when an active preset must be applied to the current generation.
## Authoring .drawio XML
**Before hand-writing any `.drawio` XML (step 3), read `references/xml-authoring.md`** — file skeleton, shape/edge cell forms, containers, connection-point distribution, color palette, and spacing/grid rules all live there. Skip it only when a bundled generator writes the XML for you (`autolayout.py` + importers, `seqlayout.py`).
Two rules worth stating even here: never reuse ids `0`/`1` (reserved root cells), and every edge `mxCell` needs a `<mxGeometry relative="1" as="geometry" />` child — self-closing edge cells do not render.
## Export
### Commands
There are **two** export modes:
- **Preview / self-check** (step 4 of the workflow) — no `-e`. Output `diagram.png`. Required for vision self-check; using `-e` here triggers a 400 "Could not process image" error from the vision API (issue #8).
- **Final / deliverable** (step 7) — pass `-e`. Output `diagram.drawio.png`. The embedded XML keeps the file editable in draw.io.
> All commands below write `drawio` as a placeholder for the binary you resolved in Step 1. If your binary is on PATH as `draw.io` (with dot — some older or distro-packaged installs), substitute `draw.io` throughout. If only the macOS `.app` or Windows `.exe` is available, use the full path variant shown a few lines down.
```bash
# Preview PNG (use this in step 4, before self-check) — NO -e, width-capped to stay under vision's 2576px ceiling
drawio -x -f png --width 2000 -o diagram.png input.drawio
# Final PNG (step 7, after user approval) — WITH -e, double extension
drawio -x -f png -e -s 2 -o diagram.drawio.png input.drawio
# macOS — full path (if not in PATH); preview / final variants
/Applications/draw.io.app/Contents/MacOS/draw.io -x -f png --width 2000 -o diagram.png input.drawio
/Applications/draw.io.app/Contents/MacOS/draw.io -x -f png -e -s 2 -o diagram.drawio.png input.drawio
# Windows
"C:\Program Files\draw.io\draw.io.exe" -x -f png -e -s 2 -o diagram.drawio.png input.drawio
# Linux (headless — requires xvfb-run; on servers add HOME and --disable-gpu)
export HOME=${HOME:-/tmp}
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
drawio -x -f png -e -s 2 -o diagram.drawio.png input.drawio --disable-gpu
# Running as root (CI / Docker)? Append --no-sandbox AT THE END (placing it earlier makes drawio treat it as the input filename)
# SVG export (final — -e is safe; SVG is text)
drawio -x -f svg -e -o diagram.svg input.drawio
# PDF export (final)
drawio -x -f pdf -e -o diagram.pdf input.drawio
# Custom output directory (e.g. CI artifacts dir) — create if missing, then export there
mkdir -p ./artifacts && drawio -x -f png -e -s 2 -o ./artifacts/diagram.drawio.png input.drawio
```
### Post-export PNG repair (required after `-e` PNG export)
draw.io CLI truncates the IEND chunk when emitting `-e` PNGs — the file ends with the 4-byte IEND length field but the `IEND` type + CRC (8 bytes) are missing. Result: vision APIs return 400 "Could not process image" and strict PNG decoders error out. SVG/PDF are unaffected.
Run this immediately after every `-e` PNG export:
```bash
python3 <this-skill-dir>/scripts/repair_png.py diagram.drawio.png
```
The script's `endswith(IEND)` guard makes it a no-op once draw.io fixes the bug upstream — safe to run unconditionally.
**Key flags:**
- `-x` — export mode (required)
- `-f` — format: `png`, `svg`, `pdf`, `jpg`
- `-e` — embed diagram XML in output (PNG, SVG, PDF) — exported file remains editable in draw.io. **Skip for the preview PNG used in step 5 self-check** — `-e` PNGs have a truncated IEND chunk that vision APIs reject (issue #8). For final PNG export, keep `-e` and run `scripts/repair_png.py` (see Post-export PNG repair). SVG/PDF unaffected.
- `-s` — scale: `1`, `2`, `3` (2 recommended for final PNG; do NOT use for the step-4 preview — see `--width`)
- `--width <px>` — target width in pixels (no short form; `-w` does **not** exist and silently breaks the input-file parser). Use `--width 2000` for the step-4 preview to keep the PNG under Claude's 2576×2576 vision ceiling. There's also a `--height <px>` flag for tall-narrow diagrams. Don't combine `--width` with `-s`.
- `-o` — output file path; accepts any directory (e.g. `./artifacts/diagram.drawio.png`) — `mkdir -p` the target dir first. Use `.drawio.png` double extension when embedding.
- `--layout <preset|json>` — **CLI ≥ v30 only** — Post-generate layout pass on XML input. Accepts **only** the ELK presets `verticalFlow`, `horizontalFlow`, `verticalTree`, `horizontalTree`, `radialTree`, `organic`, or a JSON layout array — all of them re-place nodes as well as routing edges; alternative to `autolayout.py` when Graphviz is missing. **Any other value opens a modal `Unknown layout:` dialog that hangs a headless run** — there is no edge-routing-only mode on the CLI. Never combine with Mermaid-converted files (already laid out). On ≤ 29 this flag breaks argument parsing — don't emit it. See `references/mermaid-authoring.md`
- `-b` — border width around diagram (default: 0, recommend 10)
- `-t` — transparent background (PNG only)
- `--page-index <n>` — export one page of a multi-page file. **1-based** in current drawio-desktop (verified on 29.7.8: `--page-index 2` exports the second page; older docs claimed 0-based). Default: first page. `--page-range 2..3` also works
### Browser fallback (no CLI needed)
When the draw.io desktop CLI is unavailable, generate a client-side URL:
```bash
python3 <this-skill-dir>/scripts/encode_drawio_url.py input.drawio # read-only viewer
python3 <this-skill-dir>/scripts/encode_drawio_url.py --edit input.drawio # opens in the editor
```
Default prints a `https://viewer.diagrams.net/...#R…` viewer URL; `--edit` prints a `https://app.diagrams.net/...#create=…` URL that opens straight into the editable editor. Either way the diagram XML is `encodeURIComponent`-encoded, deflate-compressed, and base64'd into the URL fragment — the fragment (after `#`) is never sent to the server, so nothing is uploaded. The `encodeURIComponent` step is mandatory: without it, any diagram containing a literal `%` or non-ASCII (e.g. CJK) label makes the browser throw "URI malformed" and the diagram never opens.
Open the URL with `open "$URL"` (macOS) / `xdg-open "$URL"` (Linux). On **WSL2 / Windows**, `cmd.exe` drops the `#fragment` — write a `.url` shortcut file and open that instead (see `references/troubleshooting.md` → "WSL2 / Windows specifics").
### Fallback chain
When tools are unavailable, degrade gracefully:
| Scenario | Behavior |
| ---------- | ---------- |
| draw.io CLI missing, Python available | Use browser fallback (diagrams.net URL) |
| draw.io CLI missing, Python missing | Generate `.drawio` XML only; instruct user to open in draw.io desktop or diagrams.net manually |
| draw.io CLI crashes / no output in macOS sandbox isolation | Treat CLI as unavailable in-sandbox; use browser fallback / XML-only; ask user to run CLI exports in a non-sandboxed host environment |
| Vision unavailable for self-check | Skip self-check (step 5); proceed directly to showing user the exported PNG |
| Export fails (Chromium/display issues) | On Linux, retry with `xvfb-run -a`; if still failing, deliver `.drawio` XML and suggest manual export |
| Export fails on Linux server (headless) | Try in order: (1) `xvfb-run -a`, (2) append `--no-sandbox` at the very end if root, (3) add `--disable-gpu`, (4) `export HOME=/tmp`, (5) install apt deps (`libgtk-3-0 libnotify4 libnss3 libgbm1 libasound2t64` etc.), (6) fall back to [tomkludy/drawio-renderer](https://hub.docker.com/r/tomkludy/drawio-renderer) Docker (REST API for headless export) |
### Checking if drawio is in PATH
```bash
# Prefer the Homebrew / Linux-package binary name (no dot)
if command -v drawio &>/dev/null; then
DRAWIO="drawio"
# Fall back to the dot-named binary (older installs, manual symlinks)
elif command -v draw.io &>/dev/null; then
DRAWIO="draw.io"
# macOS .app bundle (binary inside the bundle keeps the dot)
elif [ -f "/Applications/draw.io.app/Contents/MacOS/draw.io" ]; then
DRAWIO="/Applications/draw.io.app/Contents/MacOS/draw.io"
# WSL2: the CLI is the Windows desktop exe, reached via /mnt/c (note the space)
elif grep -qi microsoft /proc/version 2>/dev/null && [ -f "/mnt/c/Program Files/draw.io/draw.io.exe" ]; then
DRAWIO="/mnt/c/Program Files/draw.io/draw.io.exe"
else
echo "drawio not found — install from https://github.com/jgraph/drawio-desktop/releases (Homebrew: brew install --cask drawio)"
fi
```
On **WSL2 / native Windows**, opening exported files and browser-fallback URLs needs path conversion + a `.url`-file workaround (`cmd.exe` drops URL `#fragment`s) — see the "WSL2 / Windows specifics" section in `references/troubleshooting.md`.
## Common Mistakes
When something looks wrong (export fails, vision rejects a PNG, layout broken, edges misroute), see `references/troubleshooting.md` for a row-by-row mistake → fix table.
## Diagram Type Presets
When the user requests a specific diagram type, read `references/diagram-types.md` for the matching preset (shapes, edges, layout direction). Pick by user phrasing:
| User says | Section in `references/diagram-types.md` |
| --- | --- |
| "ER diagram", "schema diagram", "data model" | ERD |
| "UML class diagram", "class diagram" | UML Class |
| "sequence diagram", "interaction diagram", "lifeline" | Sequence |
| "architecture", "system diagram", "service diagram" | Architecture |
| "neural network", "model architecture", "ML diagram", "deep learning" | ML / Deep Learning Model |
| "flowchart", "decision tree", "process flow" | Flowchart |
| "C4", "system context diagram", "container diagram", "component diagram" | C4 Model |
| "SysML", "MBSE", "block definition diagram", "internal block diagram", "requirement diagram", "parametric diagram" | SysML |
| "BPMN", "business process", "process model", "pool and lanes", "workflow diagram" | BPMN |
| "network topology", "network diagram", "LAN/WAN", "subnet", "firewall diagram" | Network Topology |
| "swimlane diagram", "cross-functional flowchart", "who does what", "handoff diagram" | Cross-Functional Flowchart |
The diagram-type preset sets **structural** style keywords. If a user style preset is also active (see `## Style Presets`), keep the structural keywords and layer color/font/edge/extras on top — read `references/style-presets.md` → "Interaction with diagram-type presets" for the merge rules.
styles/built-in/colorblind-safe.json
{
"$schema": "../schema.json",
"name": "colorblind-safe",
"version": 1,
"default": false,
"source": { "type": "built-in" },
"confidence": "high",
"palette": {
"primary": { "fillColor": "#ccedff", "strokeColor": "#0072b2" },
"success": { "fillColor": "#ccfff1", "strokeColor": "#009e73" },
"warning": { "fillColor": "#fff8cc", "strokeColor": "#8f7c00" },
"accent": { "fillColor": "#ffefcc", "strokeColor": "#e69f00" },
"danger": { "fillColor": "#ffe3cc", "strokeColor": "#d55e00" },
"neutral": { "fillColor": "#e6e6e6", "strokeColor": "#555555" },
"secondary": { "fillColor": "#f1dae7", "strokeColor": "#cc79a7" }
},
"roles": {
"service": "primary",
"database": "success",
"queue": "warning",
"gateway": "accent",
"error": "danger",
"external": "neutral",
"security": "secondary"
},
"shapes": {
"service": "rounded=1",
"database": "shape=cylinder3",
"queue": "rounded=1",
"decision": "rhombus",
"external": "rounded=1;dashed=1",
"container": "swimlane;startSize=30"
},
"font": {
"fontFamily": "Helvetica",
"fontSize": 12,
"titleFontSize": 14,
"titleBold": true
},
"edges": {
"style": "edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
"arrow": "endArrow=classic;endFill=1",
"dashedFor": ["external"]
},
"extras": {
"sketch": false,
"globalStrokeWidth": 2
}
}
styles/built-in/corporate.json
{
"$schema": "../schema.json",
"name": "corporate",
"version": 1,
"default": false,
"source": { "type": "built-in" },
"confidence": "high",
"palette": {
"primary": { "fillColor": "#e3f2fd", "strokeColor": "#1565c0" },
"success": { "fillColor": "#e8f5e9", "strokeColor": "#2e7d32" },
"warning": { "fillColor": "#fff9c4", "strokeColor": "#f57c00" },
"accent": { "fillColor": "#fff3e0", "strokeColor": "#e65100" },
"danger": { "fillColor": "#ffebee", "strokeColor": "#c62828" },
"neutral": { "fillColor": "#eceff1", "strokeColor": "#455a64" },
"secondary": { "fillColor": "#f3e5f5", "strokeColor": "#6a1b9a" }
},
"roles": {
"service": "primary",
"database": "success",
"queue": "warning",
"gateway": "accent",
"error": "danger",
"external": "neutral",
"security": "secondary"
},
"shapes": {
"service": "rounded=0",
"database": "shape=cylinder3",
"queue": "rounded=0",
"decision": "rhombus",
"external": "rounded=0;dashed=1",
"container": "swimlane;startSize=30"
},
"font": {
"fontFamily": "Arial",
"fontSize": 11,
"titleFontSize": 13,
"titleBold": true
},
"edges": {
"style": "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1",
"arrow": "endArrow=classic;endFill=1",
"dashedFor": ["optional", "async"]
},
"extras": {
"sketch": false,
"globalStrokeWidth": 1
}
}
styles/built-in/dark.json
{
"$schema": "../schema.json",
"name": "dark",
"version": 1,
"default": false,
"source": { "type": "built-in" },
"confidence": "high",
"palette": {
"primary": { "fillColor": "#004870", "strokeColor": "#33b6ff" },
"success": { "fillColor": "#007052", "strokeColor": "#33ffc7" },
"warning": { "fillColor": "#5a4916", "strokeColor": "#d7b85b" },
"accent": { "fillColor": "#705100", "strokeColor": "#ffc633" },
"danger": { "fillColor": "#502220", "strokeColor": "#c4716e" },
"neutral": { "fillColor": "#383838", "strokeColor": "#999999" },
"secondary": { "fillColor": "#3d2c45", "strokeColor": "#a182b0" }
},
"roles": {
"service": "primary",
"database": "success",
"queue": "warning",
"gateway": "accent",
"error": "danger",
"external": "neutral",
"security": "secondary"
},
"shapes": {
"service": "rounded=1",
"database": "shape=cylinder3",
"queue": "rounded=1",
"decision": "rhombus",
"external": "rounded=1;dashed=1",
"container": "swimlane;startSize=30"
},
"font": {
"fontFamily": "Helvetica",
"fontSize": 12,
"titleFontSize": 14,
"titleBold": true
},
"edges": {
"style": "edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
"arrow": "endArrow=classic;endFill=1",
"dashedFor": ["external"]
},
"extras": {
"sketch": false,
"globalStrokeWidth": 1,
"background": "#1e1e1e",
"fontColor": "#f0f0f0",
"edgeColor": "#bbbbbb"
}
}
styles/built-in/default.json
{
"$schema": "../schema.json",
"name": "default",
"version": 1,
"default": false,
"source": { "type": "built-in" },
"confidence": "high",
"palette": {
"primary": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf" },
"success": { "fillColor": "#d5e8d4", "strokeColor": "#82b366" },
"warning": { "fillColor": "#fff2cc", "strokeColor": "#d6b656" },
"accent": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00" },
"danger": { "fillColor": "#f8cecc", "strokeColor": "#b85450" },
"neutral": { "fillColor": "#f5f5f5", "strokeColor": "#666666" },
"secondary": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6" }
},
"roles": {
"service": "primary",
"database": "success",
"queue": "warning",
"gateway": "accent",
"error": "danger",
"external": "neutral",
"security": "secondary"
},
"shapes": {
"service": "rounded=1",
"database": "shape=cylinder3",
"queue": "rounded=1",
"decision": "rhombus",
"external": "rounded=1;dashed=1",
"container": "swimlane;startSize=30"
},
"font": {
"fontFamily": "Helvetica",
"fontSize": 12,
"titleFontSize": 14,
"titleBold": true
},
"edges": {
"style": "edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
"arrow": "endArrow=classic;endFill=1",
"dashedFor": []
},
"extras": {
"sketch": false,
"globalStrokeWidth": 1
}
}
styles/built-in/handdrawn.json
{
"$schema": "../schema.json",
"name": "handdrawn",
"version": 1,
"default": false,
"source": { "type": "built-in" },
"confidence": "high",
"palette": {
"primary": { "fillColor": "#ffe4b5", "strokeColor": "#b8651e" },
"success": { "fillColor": "#def0dc", "strokeColor": "#5c8a49" },
"warning": { "fillColor": "#fff4cc", "strokeColor": "#b8901a" },
"accent": { "fillColor": "#ffd9b3", "strokeColor": "#c25100" },
"danger": { "fillColor": "#ffcdbf", "strokeColor": "#a53d3d" },
"neutral": { "fillColor": "#f5e6d3", "strokeColor": "#8b7355" },
"secondary": { "fillColor": "#e6d7e8", "strokeColor": "#7b4397" }
},
"roles": {
"service": "primary",
"database": "success",
"queue": "warning",
"gateway": "accent",
"error": "danger",
"external": "neutral",
"security": "secondary"
},
"shapes": {
"service": "rounded=1",
"database": "shape=cylinder3",
"queue": "rounded=1",
"decision": "rhombus",
"external": "rounded=1;dashed=1",
"container": "swimlane;startSize=30"
},
"font": {
"fontFamily": "Helvetica",
"fontSize": 12,
"titleFontSize": 14,
"titleBold": true
},
"edges": {
"style": "edgeStyle=orthogonalEdgeStyle;curved=1;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
"arrow": "endArrow=classic;endFill=1",
"dashedFor": ["optional"]
},
"extras": {
"sketch": true,
"globalStrokeWidth": 2
}
}
styles/schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/Agents365-ai/drawio-skill/styles/schema.json",
"title": "drawio-skill preset",
"type": "object",
"required": [
"name",
"version",
"palette",
"roles",
"shapes",
"font",
"edges"
],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*$"
},
"version": {
"type": "integer",
"const": 1
},
"default": {
"type": "boolean"
},
"confidence": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"source": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"xml",
"image",
"built-in",
"hand-authored"
]
},
"path": {
"type": "string"
},
"extracted_at": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
}
}
},
"palette": {
"type": "object",
"additionalProperties": false,
"required": [
"primary",
"success",
"warning",
"accent",
"danger",
"neutral",
"secondary"
],
"properties": {
"primary": {
"$ref": "#/$defs/colorPair"
},
"success": {
"$ref": "#/$defs/colorPair"
},
"warning": {
"$ref": "#/$defs/colorPair"
},
"accent": {
"$ref": "#/$defs/colorPair"
},
"danger": {
"$ref": "#/$defs/colorPair"
},
"neutral": {
"$ref": "#/$defs/colorPair"
},
"secondary": {
"$ref": "#/$defs/colorPair"
}
}
},
"roles": {
"type": "object",
"additionalProperties": false,
"properties": {
"service": {
"$ref": "#/$defs/slotName"
},
"database": {
"$ref": "#/$defs/slotName"
},
"queue": {
"$ref": "#/$defs/slotName"
},
"gateway": {
"$ref": "#/$defs/slotName"
},
"error": {
"$ref": "#/$defs/slotName"
},
"external": {
"$ref": "#/$defs/slotName"
},
"security": {
"$ref": "#/$defs/slotName"
}
}
},
"shapes": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"properties": {
"service": {
"type": "string"
},
"database": {
"type": "string"
},
"queue": {
"type": "string"
},
"decision": {
"type": "string"
},
"external": {
"type": "string"
},
"container": {
"type": "string"
}
}
},
"font": {
"type": "object",
"additionalProperties": false,
"required": [
"fontFamily",
"fontSize"
],
"properties": {
"fontFamily": {
"type": "string"
},
"fontSize": {
"type": "integer",
"minimum": 8,
"maximum": 36
},
"titleFontSize": {
"type": "integer",
"minimum": 8,
"maximum": 48
},
"titleBold": {
"type": "boolean"
}
}
},
"edges": {
"type": "object",
"additionalProperties": false,
"required": [
"style",
"arrow"
],
"properties": {
"style": {
"type": "string"
},
"arrow": {
"type": "string"
},
"dashedFor": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"extras": {
"type": "object",
"additionalProperties": false,
"properties": {
"sketch": {
"type": "boolean"
},
"globalStrokeWidth": {
"type": "number",
"minimum": 0.5,
"maximum": 6
},
"background": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"fontColor": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"edgeColor": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
}
}
}
},
"$defs": {
"colorPair": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"fillColor",
"strokeColor"
],
"properties": {
"fillColor": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"strokeColor": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
}
}
}
]
},
"slotName": {
"type": "string",
"enum": [
"primary",
"success",
"warning",
"accent",
"danger",
"neutral",
"secondary"
]
}
}
}