Files

332 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()