#!/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 (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 [-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 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 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(" {title}

{title}

build-up frame
+
""" 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()