#!/usr/bin/env python3 """De-rasterize an image-extracted graph (JSON) into an editable .drawio. Turns a whiteboard photo, legacy PNG, or Visio screenshot into an editable diagram: Claude's own vision reads the image and extracts a JSON description of the nodes/edges (the workflow is documented in references/derasterize.md); this script turns that JSON into `.drawio` XML, honoring the coordinates, labels, shapes, and colors Claude read off the image. python3 raster2drawio.py graph.json -o out.drawio Input JSON: {"nodes": [{"id": "n1", "label": "API Gateway", "x": 120, "y": 60, "w": 160, "h": 60, "shape": "rect", "fill": "#dae8fc", "stroke": "#6c8ebf"}, {"id": "n2", "label": "Auth DB", "x": 360, "y": 60, "shape": "cylinder"}], "edges": [{"source": "n1", "target": "n2", "label": "HTTPS", "dashed": false, "arrow": true}]} Only "id" is required per node; label defaults to id, w/h default to 120/60, shape defaults to "rect" (choices: rect, rounded, ellipse, rhombus/diamond, cylinder, parallelogram, cloud, hexagon), fill/stroke default to the skill's palette blue. Edge "arrow" defaults to true (endArrow=none when false); "dashed" defaults to false. If ANY node is missing x or y, positions are not guessed: the graph is handed to autolayout.py (shelled out, requires Graphviz `dot`) to place it, and a note is written to stderr. Usage: python3 raster2drawio.py [-o out.drawio] """ import argparse import json import os import subprocess import sys import tempfile from xml.sax.saxutils import escape DEFAULT_W, DEFAULT_H = 120, 60 DEFAULT_FILL, DEFAULT_STROKE = "#dae8fc", "#6c8ebf" SHAPES = { "rect": "whiteSpace=wrap;html=1;", "rounded": "rounded=1;whiteSpace=wrap;html=1;", "ellipse": "ellipse;whiteSpace=wrap;html=1;", "rhombus": "rhombus;whiteSpace=wrap;html=1;", "diamond": "rhombus;whiteSpace=wrap;html=1;", "cylinder": "shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;", "parallelogram": "shape=parallelogram;whiteSpace=wrap;html=1;", "cloud": "ellipse;shape=cloud;whiteSpace=wrap;html=1;", "hexagon": "shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;", } EDGE_BASE = "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;" def attr(value): # Newlines in labels become so draw.io renders a line break (a raw # newline inside an XML attribute is normalized to a space by parsers). return escape(str(value), {'"': """, "\n": " "}) def node_style(node): base = SHAPES.get(node.get("shape", "rect"), SHAPES["rect"]) fill = node.get("fill", DEFAULT_FILL) stroke = node.get("stroke", DEFAULT_STROKE) return f"{base}fillColor={fill};strokeColor={stroke};" def edge_style(edge): style = EDGE_BASE if edge.get("dashed"): style += "dashed=1;" if edge.get("arrow") is False: style += "endArrow=none;" return style def to_drawio(nodes, edges): """Direct build: every node already has x/y. Mirrors autolayout.py's to_drawio() string-building, without the dot layout pass.""" cells = [] for node in nodes: w, h = node.get("w", DEFAULT_W), node.get("h", DEFAULT_H) cells.append( f' \n' f' \n' f" " ) for i, edge in enumerate(edges): cells.append( f' \n' f' \n' f" " ) return ( "\n" ' \n' ' \n' " \n" ' \n' ' \n' + "\n".join(cells) + "\n \n \n \n\n" ) def build_autolayout_graph(nodes, edges): """Same graph, in autolayout.py's input shape (positions dropped — dot will compute fresh ones for every node).""" return { "direction": "TB", "nodes": [ {"id": n["id"], "label": n.get("label", n["id"]), "style": node_style(n), "width": n.get("w", DEFAULT_W), "height": n.get("h", DEFAULT_H)} for n in nodes ], "edges": [ {"source": e["source"], "target": e["target"], "label": e.get("label", ""), "style": edge_style(e)} for e in edges ], } def run_autolayout(graph): """Shell out to the sibling autolayout.py; return the .drawio XML text.""" here = os.path.dirname(os.path.abspath(__file__)) autolayout = os.path.join(here, "autolayout.py") fd, graph_path = tempfile.mkstemp(suffix=".json") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(graph, f) r = subprocess.run([sys.executable, autolayout, graph_path], capture_output=True, text=True) finally: os.unlink(graph_path) if r.returncode != 0: sys.exit(f"error: autolayout.py failed: {r.stderr.strip()}") return r.stdout def main(): ap = argparse.ArgumentParser( description="Convert an image-extracted graph JSON into an editable .drawio.") ap.add_argument("input", help="graph JSON file, or - for stdin") ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)") args = ap.parse_args() if args.input == "-": raw = sys.stdin.read() else: try: with open(args.input, encoding="utf-8") as f: raw = f.read() except OSError as exc: sys.exit(f"error: cannot read {args.input}: {exc}") try: graph = json.loads(raw) except json.JSONDecodeError as exc: sys.exit(f"error: invalid JSON: {exc}") nodes = graph.get("nodes") or [] edges = graph.get("edges") or [] if not nodes: sys.exit("error: no nodes in input") for n in nodes: if "id" not in n: sys.exit("error: every node needs an 'id'") for e in edges: if "source" not in e or "target" not in e: sys.exit("error: every edge needs 'source' and 'target'") if any(n.get("x") is None or n.get("y") is None for n in nodes): xml = run_autolayout(build_autolayout_graph(nodes, edges)) sys.stderr.write( "note: some nodes had no x/y — positions were auto-placed via autolayout.py\n") else: xml = to_drawio(nodes, edges) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(xml) sys.stderr.write(f"wrote {args.output} ({len(nodes)} nodes, {len(edges)} edges)\n") else: sys.stdout.write(xml) if __name__ == "__main__": main()