feat: sync 12 new skills into catalog (total 31 skills), update manifest and README

This commit is contained in:
2026-08-27 11:47:43 +07:00
parent e1490f65ca
commit ab57d1ea81
87 changed files with 15489 additions and 68 deletions

View File

@@ -0,0 +1,205 @@
#!/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}")
with open(MANIFEST, encoding="utf-8") as f:
manifest = json.load(f)
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()

View File

@@ -0,0 +1,410 @@
#!/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 &#xa; 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), {'"': "&quot;", "\n": "&#xa;"})
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()

View File

@@ -0,0 +1,331 @@
#!/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()

View File

@@ -0,0 +1,163 @@
#!/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()

View File

@@ -0,0 +1,182 @@
#!/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()

View File

@@ -0,0 +1,118 @@
#!/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()

View File

@@ -0,0 +1,257 @@
#!/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()

View File

@@ -0,0 +1,148 @@
#!/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()
if args.input == "-":
text = sys.stdin.read()
else:
with open(args.input, encoding="utf-8") as f:
text = f.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()

View File

@@ -0,0 +1,153 @@
#!/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('"', "&quot;").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()

View File

@@ -0,0 +1,112 @@
#!/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()

View File

@@ -0,0 +1,164 @@
#!/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()

View File

@@ -0,0 +1,230 @@
#!/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", "--embed-svg-images",
"--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()

View File

@@ -0,0 +1,211 @@
#!/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()

View File

@@ -0,0 +1,58 @@
#!/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))

View File

@@ -0,0 +1,156 @@
#!/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()

View File

@@ -0,0 +1,144 @@
#!/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
with open(gomod, encoding="utf-8", errors="ignore") as f:
m = MODULE.search(f.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:
with open(path, encoding="utf-8", errors="ignore") as f:
src = f.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:
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(pkgs)} packages, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,226 @@
#!/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()

View File

@@ -0,0 +1,164 @@
#!/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:
with open(path, encoding="utf-8", errors="ignore") as f:
src = f.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:
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(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,242 @@
#!/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()

View File

@@ -0,0 +1,165 @@
#!/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()

View File

@@ -0,0 +1,191 @@
#!/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"![base]({base_r})")
if head_r:
lines.append(f"![head]({head_r})")
if diff_r:
lines.append(f"![diff]({diff_r})")
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()

View File

@@ -0,0 +1,158 @@
#!/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:
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.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:
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(classes)} classes, {len(edges)} inheritance edges{note}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,155 @@
#!/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:
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.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:
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(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,196 @@
#!/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 &#xa; 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), {'"': "&quot;", "\n": "&#xa;"})
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()

View File

@@ -0,0 +1,102 @@
#!/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()

View File

@@ -0,0 +1,37 @@
#!/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]}")

View File

@@ -0,0 +1,161 @@
#!/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()

View File

@@ -0,0 +1,241 @@
#!/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">&larr; Back</button>
<button id="restart">&#8635; 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()

View File

@@ -0,0 +1,205 @@
#!/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):
with open(cargo, encoding="utf-8", errors="ignore") as f:
m = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', f.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:
with open(path, encoding="utf-8", errors="ignore") as f:
src = f.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:
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(modules)} modules, {len(edges)} edges{note}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,222 @@
#!/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), {'"': "&quot;", "\n": "&#xa;"})
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()

View File

@@ -0,0 +1,162 @@
#!/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()

View File

@@ -0,0 +1,167 @@
#!/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()

View File

@@ -0,0 +1,85 @@
#!/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", "--embed-svg-images", "-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()

View File

@@ -0,0 +1,262 @@
#!/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()

View File

@@ -0,0 +1,132 @@
#!/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()
if args.input == "-":
text = sys.stdin.read()
else:
with open(args.input, encoding="utf-8") as f:
text = f.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()

View File

@@ -0,0 +1,257 @@
#!/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
with open(graph_json, encoding="utf-8") as f:
graph = json.loads(f.read())
if not graph.get("nodes"):
return None
graph["direction"] = direction
with open(graph_json, "w", encoding="utf-8") as f:
f.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
with open(png, "rb") as f:
png_data = f.read()
return png_data, 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"
with open(fp, "wb") as f:
f.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))}"
with open(args.output, "w", encoding="utf-8") as f:
f.write(build_html(frames, title))
sys.stderr.write(f"wrote {args.output} ({len(frames)} frames)\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,181 @@
#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
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()
if args.input == "-":
raw = sys.stdin.read()
else:
with open(args.input, encoding="utf-8") as f:
raw = f.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()

View File

@@ -0,0 +1,340 @@
#!/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()