docs/agent/tools/gen_ba_drawio.py
#!/usr/bin/env python3
"""Generate docs/agent/ba-drawio.md from the BA's draw.io source file.
Source: the newest "TNSU_EDU *.drawio" in the repository root (or a path given
on the command line). Each draw.io page is rendered as Markdown:
- Pages whose shapes are connected by arrows become nested lists.
- Unconnected numbered items such as "4.3 ..." are attached to the module
whose text starts with "4.".
- Unconnected text boxes are attached to the connected shape directly above
them in the same column (used for the curriculum page).
- Pages built from grouped step boxes (flows) become numbered step lists, with
side notes attached to the step on the same row.
Re-run after the BA updates the file:
python3 docs/agent/tools/gen_ba_drawio.py
Uses the standard library only.
"""
import base64
import html
import re
import sys
import urllib.parse
import xml.etree.ElementTree as ET
import zlib
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
OUT = ROOT / "docs" / "agent" / "ba-drawio.md"
COLOUR_NAMES = {
"#fff2cc": "yellow",
"#dae8fc": "blue",
"#d5e8d4": "green",
"#f8cecc": "pink",
"#e1d5e7": "purple",
"#ffe6cc": "orange",
}
def find_source():
if len(sys.argv) > 1:
return Path(sys.argv[1])
files = sorted(ROOT.glob("TNSU_EDU*.drawio"), key=lambda p: p.stat().st_mtime)
if not files:
sys.exit("no TNSU_EDU*.drawio file in the repository root")
return files[-1]
def clean(value):
v = re.sub(r"<br\s*/?>|</div>|</p>|</li>", "\n", value or "", flags=re.I)
v = html.unescape(re.sub(r"<[^>]+>", "", v)).replace("\xa0", " ")
lines = []
for line in v.split("\n"):
# "Title- detail" written without a line break: split before the dash
lines += re.split(r"(?<=\S)(?=-\s)", line)
lines = [re.sub(r"\s+", " ", line).strip() for line in lines]
return [line for line in lines if line]
def number_key(lines):
"""Sort key from a leading "8." or "4.3" number; unnumbered items keep diagram order."""
m = re.match(r"(\d+)(?:\.(\d+))?", lines[0]) if lines else None
return (int(m.group(1)), int(m.group(2) or 0)) if m else None
def style(cell, key):
m = re.search(r"(?:^|;)" + key + r"=([^;]*)", cell.get("style") or "")
return m.group(1) if m else ""
def page_root(diagram):
model = diagram.find("mxGraphModel")
if model is not None:
return model.find("root")
data = zlib.decompress(base64.b64decode((diagram.text or "").strip()), -15).decode()
return ET.fromstring(urllib.parse.unquote(data)).find("root")
class Page:
def __init__(self, diagram):
self.name = diagram.get("name")
root = page_root(diagram)
self.cells = {c.get("id"): c for c in root.iter("mxCell")}
self.edges = [c for c in self.cells.values() if c.get("edge") == "1"]
self.verts = {cid: c for cid, c in self.cells.items() if c.get("vertex") == "1"}
def geo(self, cid):
"""Absolute x, y, w, h (child geometry is relative to its parent group)."""
c = self.cells[cid]
g = c.find("mxGeometry")
x, y, w, h = (float(g.get(k, 0)) for k in ("x", "y", "width", "height")) if g is not None else (0, 0, 0, 0)
parent = c.get("parent")
if parent in self.verts:
px, py, _, _ = self.geo(parent)
x, y = x + px, y + py
return x, y, w, h
def text(self, cid):
return clean(self.cells[cid].get("value"))
def kind(self, cid):
return (self.cells[cid].get("style") or "").split(";")[0]
def colour(cell):
fill = style(cell, "fillColor").lower()
return COLOUR_NAMES.get(fill, fill if fill and fill != "none" else "")
def bullet(lines):
"""First line as the item; following lines as its detail."""
head, *rest = lines
if rest:
return head + " (" + "; ".join(r.lstrip("- ").strip() for r in rest) + ")"
return head
def render_tree(page):
children = defaultdict(list)
has_parent = set()
for e in page.edges:
s, t = e.get("source"), e.get("target")
if s in page.verts and t in page.verts and s != t:
children[s].append(t)
has_parent.add(t)
connected = set(children) | has_parent
# Numbered loose items ("4.3 ...") belong to the module whose text starts with "4."
by_number = {}
for cid in connected:
lines = page.text(cid)
m = re.match(r"(\d+)\.\s", lines[0]) if lines else None
if m:
by_number[m.group(1)] = cid
extra = defaultdict(list)
loose = []
for cid in page.verts:
if cid in connected or not page.text(cid) or page.kind(cid) in ("group", "swimlane"):
continue
lines = page.text(cid)
m = re.match(r"(\d+)\.\d+", lines[0])
if m and m.group(1) in by_number:
extra[by_number[m.group(1)]].append(cid)
else:
loose.append(cid)
# Loose text boxes go under the connected shape directly above them in the same column.
for cid in loose:
x, y, _, _ = page.geo(cid)
best = None
for other in connected:
ox, oy, _, oh = page.geo(other)
if abs(ox - x) <= 40 and oy + oh <= y + 5:
if best is None or oy > page.geo(best)[1]:
best = other
extra[best].append(cid)
out = []
def show(cid, depth, seen):
c = page.cells[cid]
tag = f" `{colour(c)}`" if colour(c) else ""
out.append(" " * depth + f"- {bullet(page.text(cid)) or '(no text)'}{tag}")
if cid in seen:
return
seen.add(cid)
kids = children[cid]
if kids and all(number_key(page.text(k)) for k in kids):
kids = sorted(kids, key=lambda k: number_key(page.text(k)))
else:
kids = sorted(kids, key=lambda k: (page.geo(k)[0], page.geo(k)[1]))
for k in kids:
show(k, depth + 1, seen)
for k in sorted(extra.get(cid, []), key=lambda k: number_key(page.text(k)) or (999, page.geo(k)[1])):
lines = page.text(k)
tag = f" `{colour(page.cells[k])}`" if colour(page.cells[k]) else ""
if number_key(lines): # numbered sub-item: title plus its details
out.append(" " * (depth + 1) + f"- {bullet(lines)}{tag}")
continue
# A loose box may hold several items, each starting with "-".
items, cur = [], []
for line in lines:
if line.startswith("-") and cur:
items.append(cur)
cur = []
cur.append(line)
items.append(cur)
for it in items:
out.append(" " * (depth + 1) + f"- {' '.join(l.lstrip('- ').strip() for l in it)}{tag}")
seen = set()
roots = [cid for cid in children if cid not in has_parent]
for r in sorted(roots, key=lambda r: (page.geo(r)[1], page.geo(r)[0])):
show(r, 0, seen)
for cid in extra.get(None, []):
out.append(f"- {bullet(page.text(cid))}")
return out
def render_flow(page):
groups = [cid for cid in page.verts if page.kind(cid) == "group"]
groups.sort(key=lambda g: page.geo(g)[1])
lanes = [cid for cid in page.verts if page.kind(cid) == "swimlane"]
notes = [cid for cid in page.verts if page.kind(cid) == "text" and page.text(cid)]
out = []
if lanes:
out.append(f"Swimlane: **{' / '.join(page.text(lanes[0]))}**")
out.append("")
for i, g in enumerate(groups, 1):
members = [cid for cid, c in page.verts.items() if c.get("parent") == g]
members.sort(key=lambda m: page.geo(m)[1])
step = " ".join(page.text(members[0])) if members else ""
module = " ".join(page.text(members[1])) if len(members) > 1 else ""
gx, gy, gw, gh = page.geo(g)
out.append(f"{i}. **{step}**" + (f" (sub-module: {module})" if module else ""))
for n in notes:
nx, ny, _, _ = page.geo(n)
if gy - 20 <= ny <= gy + gh + 30 and abs(nx - gx) < 300:
lines = page.text(n)
parts = [p for line in lines for p in re.split(r"(?<=\S)-\s", line)]
for p in parts:
out.append(f" - {p.lstrip('- ').strip()}")
return out
def main():
src = find_source()
pages = [Page(d) for d in ET.parse(src).getroot().findall("diagram")]
lines = [
"# BA draw.io source (generated)",
"",
f"> Generated by `docs/agent/tools/gen_ba_drawio.py` from `{src.name}`. "
"Do not edit by hand; re-run the script after the BA updates the file. "
"For what this means for the project, read [03-modules.md](03-modules.md).",
"",
"Colours are the fill colours used in the diagram (their meaning has not been stated by the BA).",
"",
]
for page in pages:
lines += [f"## {page.name}", ""]
is_flow = any(page.kind(c) == "group" for c in page.verts)
lines += render_flow(page) if is_flow else render_tree(page)
lines.append("")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {OUT.relative_to(ROOT)} from {src.name} ({len(pages)} pages)")
if __name__ == "__main__":
main()