Files

157 lines
6.0 KiB
Python

#!/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()