86 lines
3.7 KiB
Python
86 lines
3.7 KiB
Python
#!/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()
|