@littledragon_wxl/drawio-style-graph 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/README_zh.md +171 -0
  5. package/SKILL.md +419 -0
  6. package/data/SHAPE-INDEX-NOTICE.md +17 -0
  7. package/data/lobe-icons.json +878 -0
  8. package/data/shape-index.json.gz +0 -0
  9. package/package.json +43 -0
  10. package/references/autolayout.md +125 -0
  11. package/references/diagram-types.md +83 -0
  12. package/references/shapes.md +151 -0
  13. package/references/style-application-guide.md +120 -0
  14. package/references/style-diagram-matrix.md +159 -0
  15. package/references/style-extraction.md +255 -0
  16. package/references/style-presets.md +110 -0
  17. package/references/styles/style-1-flat-icon.md +79 -0
  18. package/references/styles/style-2-dark-terminal.md +80 -0
  19. package/references/styles/style-3-blueprint.md +84 -0
  20. package/references/styles/style-4-notion-clean.md +78 -0
  21. package/references/styles/style-5-glassmorphism.md +85 -0
  22. package/references/styles/style-6-claude-official.md +84 -0
  23. package/references/styles/style-7-openai.md +94 -0
  24. package/references/styles/style-8-dark-luxury.md +109 -0
  25. package/references/troubleshooting.md +63 -0
  26. package/scripts/aiicons.py +201 -0
  27. package/scripts/autolayout.py +341 -0
  28. package/scripts/encode_drawio_url.py +58 -0
  29. package/scripts/goimports.py +141 -0
  30. package/scripts/jsimports.py +162 -0
  31. package/scripts/pyclasses.py +156 -0
  32. package/scripts/pyimports.py +153 -0
  33. package/scripts/repair_png.py +37 -0
  34. package/scripts/rustimports.py +203 -0
  35. package/scripts/shapesearch.py +162 -0
  36. package/scripts/validate.py +137 -0
  37. package/styles/built-in/corporate.json +49 -0
  38. package/styles/built-in/default.json +49 -0
  39. package/styles/built-in/handdrawn.json +49 -0
  40. package/styles/schema-drawio.json +112 -0
  41. package/styles/schema.json +213 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """Find AI / LLM brand logos (OpenAI, Claude, Gemini, ...) as draw.io styles.
3
+
4
+ draw.io's bundled shape libraries have no modern AI/LLM brand logos, so an
5
+ "LLM app architecture" renders as generic boxes. This resolves a brand name to a
6
+ draw.io `image` style that references the matching SVG from the lobe-icons set
7
+ (https://github.com/lobehub/lobe-icons, MIT) on the unpkg CDN.
8
+
9
+ python3 aiicons.py "openai"
10
+ python3 aiicons.py "claude" --json
11
+ python3 aiicons.py "langchain" --variant mono --size 48
12
+
13
+ The icon is referenced by URL (data/lobe-icons.json carries only the name list,
14
+ not the assets), so draw.io fetches it from the CDN when the diagram is rendered
15
+ or opened. That means **network is required at render time**; an offline export
16
+ draws a blank box. Use --embed to fetch the SVG once and inline it as a
17
+ self-contained data URI instead (portable, no network at render time).
18
+
19
+ The logos are trademarks of their respective owners and are referenced here for
20
+ identification only — the same basis on which draw.io ships AWS/Azure icons.
21
+
22
+ Usage: python3 aiicons.py <query> [--limit N] [--variant color|mono|text]
23
+ [--size PX] [--embed] [--json] [--list]
24
+ """
25
+ import argparse
26
+ import base64
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import urllib.request
32
+
33
+ MANIFEST = os.path.join(os.path.dirname(__file__), "..", "data", "lobe-icons.json")
34
+ STYLE = ("shape=image;html=1;imageAspect=0;aspect=fixed;"
35
+ "verticalLabelPosition=bottom;verticalAlign=top;image=")
36
+ _VARIANT = re.compile(r"-(color|text)$")
37
+
38
+ # Common RAG/LLM data stores that lobe-icons lacks, mapped to simple-icons
39
+ # slugs (https://simpleicons.org, CC0). Served from the simple-icons CDN. Each
40
+ # slug below is verified to return HTTP 200 at https://cdn.simpleicons.org/<slug>.
41
+ _SIMPLEICONS_CDN = "https://cdn.simpleicons.org/"
42
+ _SUPPLEMENT = {
43
+ "qdrant": "qdrant",
44
+ "milvus": "milvus",
45
+ "supabase": "supabase",
46
+ "redis": "redis",
47
+ "postgresql": "postgresql",
48
+ "mongodb": "mongodb",
49
+ "elasticsearch": "elasticsearch",
50
+ "neo4j": "neo4j",
51
+ "kafka": "apachekafka",
52
+ "clickhouse": "clickhouse",
53
+ "duckdb": "duckdb",
54
+ "mysql": "mysql",
55
+ "sqlite": "sqlite",
56
+ "cassandra": "apachecassandra",
57
+ "snowflake": "snowflake",
58
+ "databricks": "databricks",
59
+ "mariadb": "mariadb",
60
+ "couchbase": "couchbase",
61
+ }
62
+
63
+
64
+ def families(icons):
65
+ """base brand name -> set of its variant filenames (without .svg)."""
66
+ fam = {}
67
+ for name in icons:
68
+ base = _VARIANT.sub("", name)
69
+ fam.setdefault(base, set()).add(name)
70
+ return fam
71
+
72
+
73
+ def squish(s):
74
+ return re.sub(r"[^a-z0-9]", "", s.lower())
75
+
76
+
77
+ def search(fam, query, limit):
78
+ """Rank brand bases against the query (squished + per-token matching)."""
79
+ q = squish(query)
80
+ tokens = [t for t in re.findall(r"[a-z0-9]+", query.lower()) if t]
81
+ scored = {}
82
+ for base in fam:
83
+ b = squish(base)
84
+ s = 0
85
+ if q and q == b:
86
+ s = 100
87
+ elif q and b.startswith(q):
88
+ s = 60
89
+ elif q and q in b:
90
+ s = 40
91
+ for t in tokens:
92
+ if t == b:
93
+ s = max(s, 90)
94
+ elif len(t) >= 3 and b.startswith(t):
95
+ s = max(s, 50)
96
+ elif len(t) >= 3 and t in b:
97
+ s = max(s, 30)
98
+ if s:
99
+ scored[base] = s
100
+ return sorted(scored, key=lambda base: (-scored[base], base))[:limit]
101
+
102
+
103
+ def search_supplement(query):
104
+ """Fall back to the simple-icons supplement (exact or substring match)."""
105
+ q = squish(query)
106
+ if not q:
107
+ return None
108
+ if q in _SUPPLEMENT:
109
+ return q
110
+ for brand in _SUPPLEMENT:
111
+ if q in brand or brand in q:
112
+ return brand
113
+ return None
114
+
115
+
116
+ def pick_variant(base, variants, prefer):
117
+ order = {"color": ["-color", "", "-text"],
118
+ "mono": ["", "-color", "-text"],
119
+ "text": ["-text", "-color", ""]}[prefer]
120
+ for suffix in order:
121
+ cand = base + suffix
122
+ if cand in variants:
123
+ return cand
124
+ return next(iter(sorted(variants)), None)
125
+
126
+
127
+ def main():
128
+ ap = argparse.ArgumentParser(description="Find AI/LLM brand logos as draw.io styles (lobe-icons via CDN).")
129
+ ap.add_argument("query", nargs="?", help='brand name, e.g. "openai" or "claude"')
130
+ ap.add_argument("--limit", type=int, default=8)
131
+ ap.add_argument("--variant", choices=["color", "mono", "text"], default="color")
132
+ ap.add_argument("--size", type=int, default=48, help="cell width/height in px (icons are square)")
133
+ ap.add_argument("--embed", action="store_true",
134
+ help="inline the SVG as a data URI (fetches it now; portable, no network at render time)")
135
+ ap.add_argument("--json", action="store_true")
136
+ ap.add_argument("--list", action="store_true", help="list all brand names and exit")
137
+ args = ap.parse_args()
138
+
139
+ if not os.path.exists(MANIFEST):
140
+ sys.exit(f"error: manifest not found at {MANIFEST}")
141
+ manifest = json.load(open(MANIFEST, encoding="utf-8"))
142
+ fam = families(manifest["icons"])
143
+ cdn = manifest["cdn"]
144
+
145
+ if args.list:
146
+ for base in sorted(fam):
147
+ print(base)
148
+ return
149
+ if not args.query:
150
+ ap.error("a query is required (or use --list)")
151
+
152
+ matches = search(fam, args.query, args.limit)
153
+
154
+ results = []
155
+ if matches:
156
+ for base in matches:
157
+ file = pick_variant(base, fam[base], args.variant)
158
+ url = f"{cdn}{file}.svg"
159
+ if args.embed:
160
+ try:
161
+ svg = urllib.request.urlopen(url, timeout=15).read()
162
+ except Exception as exc: # noqa: BLE001 - report and skip
163
+ sys.stderr.write(f"warning: could not fetch {url} ({exc})\n")
164
+ continue
165
+ # Rewrite the 1em intrinsic size so draw.io scales the inlined SVG.
166
+ svg = svg.replace(b'width="1em"', b'width="24"').replace(b'height="1em"', b'height="24"')
167
+ image = "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
168
+ else:
169
+ image = url
170
+ results.append({"brand": base, "file": file, "w": args.size, "h": args.size,
171
+ "style": STYLE + image})
172
+ else:
173
+ # lobe has no logo for this brand; fall back to the simple-icons supplement.
174
+ brand = search_supplement(args.query)
175
+ if brand:
176
+ slug = _SUPPLEMENT[brand]
177
+ url = _SIMPLEICONS_CDN + slug
178
+ image = url
179
+ if args.embed:
180
+ try:
181
+ svg = urllib.request.urlopen(url, timeout=15).read()
182
+ image = "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
183
+ except Exception as exc: # noqa: BLE001 - keep the CDN URL
184
+ sys.stderr.write(f"warning: could not fetch {url} ({exc}); using CDN URL\n")
185
+ results.append({"brand": brand, "file": f"simpleicons:{slug}",
186
+ "w": args.size, "h": args.size, "style": STYLE + image})
187
+
188
+ if not results:
189
+ sys.exit(f"no logo for {args.query!r} — for a data store try a cylinder "
190
+ f"(shape=cylinder3) or shapesearch.py '{args.query} database'")
191
+
192
+ if args.json:
193
+ print(json.dumps(results, indent=2, ensure_ascii=False))
194
+ else:
195
+ for r in results:
196
+ shown = r["style"] if len(r["style"]) < 160 else r["style"][:157] + "..."
197
+ print(f"{r['brand']} ({r['file']}, {r['w']}x{r['h']})\n {shown}")
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ """Auto-layout a logical graph into draw.io XML using Graphviz.
3
+
4
+ Minimal layout pass for the drawio skill: takes a graph (nodes + edges as
5
+ JSON), runs `dot` to position the nodes, and emits a .drawio file with the
6
+ mxGeometry x/y filled in. draw.io routes the edges itself (orthogonal style).
7
+ This removes the manual-coordinate ceiling for medium/large diagrams.
8
+
9
+ Input JSON:
10
+ {
11
+ "direction": "TB", # TB (top-bottom, default) or LR (left-right)
12
+ "nodes": [
13
+ {"id": "a", "label": "Service A", "style": "rounded=1;...",
14
+ "width": 120, "height": 60}
15
+ ],
16
+ "edges": [
17
+ {"source": "a", "target": "b", "label": "calls"}
18
+ ]
19
+ }
20
+ Only "id" is required per node; label defaults to id and style/width/height
21
+ have defaults. Node ids must be unique and must not be "0" or "1" (reserved
22
+ for the draw.io root cells). Requires Graphviz `dot` on PATH.
23
+
24
+ Usage: python3 autolayout.py graph.json [-o diagram.drawio]
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import shlex
30
+ import subprocess
31
+ import sys
32
+ from xml.sax.saxutils import escape
33
+
34
+ DEFAULT_W, DEFAULT_H = 120, 60
35
+ NODE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
36
+ EDGE_STYLE = "html=1;rounded=0;"
37
+ GROUP_STYLE = ("rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#999999;"
38
+ "verticalAlign=top;fontStyle=2;dashed=1;")
39
+ # Group colours come from the skill's own palette (styles/built-in/default.json)
40
+ # so there is a single source of truth, not a second list baked in here. When a
41
+ # grouped graph is laid out, each top-level group takes the next colour (cycled
42
+ # in a fixed, harmonious role order) so related modules read as a coloured
43
+ # cluster. Nodes that carry their own `style` keep it; only styleless grouped
44
+ # nodes are tinted. Disable with --mono.
45
+ _PALETTE_ORDER = ["primary", "success", "accent", "secondary", "warning", "danger", "neutral"]
46
+ _PALETTE_FILE = os.path.join(os.path.dirname(__file__), "..", "styles", "built-in", "default.json")
47
+ _FALLBACK_PALETTE = [("#dae8fc", "#6c8ebf"), ("#d5e8d4", "#82b366"), ("#ffe6cc", "#d79b00"),
48
+ ("#e1d5e7", "#9673a6"), ("#fff2cc", "#d6b656"), ("#f8cecc", "#b85450")]
49
+
50
+
51
+ def load_palette():
52
+ """Ordered (fill, stroke) list from the default preset's palette; fall back
53
+ to the same colours inline if the preset file can't be read."""
54
+ try:
55
+ with open(_PALETTE_FILE, encoding="utf-8") as fh:
56
+ pal = json.load(fh)["palette"]
57
+ colors = [(pal[r]["fillColor"], pal[r]["strokeColor"]) for r in _PALETTE_ORDER if r in pal]
58
+ if colors:
59
+ return colors
60
+ except (OSError, KeyError, ValueError):
61
+ pass
62
+ return _FALLBACK_PALETTE
63
+
64
+
65
+ PALETTE = load_palette()
66
+ # Uniform container padding; the title sits in the top pad (verticalAlign=top).
67
+ # dot's cluster margin is set to this same value so each container box equals
68
+ # dot's cluster box — which dot guarantees never overlaps, at any nesting depth.
69
+ GROUP_PAD = 24
70
+
71
+
72
+ def attr(value):
73
+ return escape(str(value), {'"': "&quot;"})
74
+
75
+
76
+ def dot_quote(value):
77
+ # Wrap as a DOT double-quoted string, escaping backslash and quote so ids
78
+ # with those characters can't corrupt the Graphviz input.
79
+ return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
80
+
81
+
82
+ def snap(value, grid=10):
83
+ # Align to the grid the skill uses everywhere (multiples of 10).
84
+ return int(round(value / grid) * grid)
85
+
86
+
87
+ def group_tree(nodes):
88
+ """Parse hierarchical `group` paths ("a/b") into a container tree.
89
+
90
+ Returns (gpath, direct, children, ordered):
91
+ gpath[node_id] = tuple of path segments (the node's deepest container)
92
+ direct[path] = node ids whose group is exactly this path
93
+ children[path] = child container paths
94
+ ordered = all container paths, shallow-to-deep (stable)
95
+ """
96
+ gpath, direct, paths = {}, {}, set()
97
+ for node in nodes:
98
+ g = node.get("group")
99
+ if g is None or str(g).strip("/") == "":
100
+ continue
101
+ t = tuple(str(g).strip("/").split("/"))
102
+ gpath[node["id"]] = t
103
+ direct.setdefault(t, []).append(node["id"])
104
+ for k in range(1, len(t) + 1):
105
+ paths.add(t[:k])
106
+ children = {}
107
+ for p in sorted(paths):
108
+ if len(p) > 1:
109
+ children.setdefault(p[:-1], []).append(p)
110
+ ordered = sorted(paths, key=lambda p: (len(p), p))
111
+ return gpath, direct, children, ordered
112
+
113
+
114
+ def build_dot(graph):
115
+ rankdir = "LR" if str(graph.get("direction", "TB")).upper() == "LR" else "TB"
116
+ # splines=ortho makes dot route edges as orthogonal polylines; we replay
117
+ # those bends as draw.io waypoints so edges go around nodes, not through them.
118
+ lines = [f"digraph G {{ rankdir={rankdir}; splines=ortho; node [shape=box fixedsize=true];"]
119
+ # Group nodes into (possibly nested) clusters so dot keeps each group
120
+ # together; a node's first appearance fixes its cluster, so list members
121
+ # before the size attributes. The cluster margin reserves room for the
122
+ # padded container boxes we draw below (extra on Y for the title strip) so
123
+ # neighbouring boxes do not overlap.
124
+ _, direct, children, ordered = group_tree(graph["nodes"])
125
+ cidx = {p: i for i, p in enumerate(ordered)}
126
+
127
+ def emit_cluster(p, pad):
128
+ lines.append(f'{pad}subgraph cluster_{cidx[p]} {{ margin={GROUP_PAD};')
129
+ for c in children.get(p, []):
130
+ emit_cluster(c, pad + " ")
131
+ lines.extend(f'{pad} {dot_quote(m)};' for m in direct.get(p, []))
132
+ lines.append(pad + "}")
133
+
134
+ for root in [p for p in ordered if len(p) == 1]:
135
+ emit_cluster(root, "")
136
+ for node in graph["nodes"]:
137
+ # Pass our pixel sizes to dot as inches so it lays out at the real size.
138
+ w = node.get("width", DEFAULT_W) / 72.0
139
+ h = node.get("height", DEFAULT_H) / 72.0
140
+ lines.append(f'{dot_quote(node["id"])} [width={w:.4f} height={h:.4f}];')
141
+ for edge in graph.get("edges", []):
142
+ lines.append(f'{dot_quote(edge["source"])} -> {dot_quote(edge["target"])};')
143
+ lines.append("}")
144
+ return "\n".join(lines)
145
+
146
+
147
+ def layout(dot_src):
148
+ """Run `dot -Tplain`; return (height_in, {id: (xc, yc)}, {(src, dst): [(x, y), ...]}).
149
+
150
+ Node coords are inches (bottom-left origin); each edge's value is the list
151
+ of orthogonal control points dot computed for routing, endpoints included.
152
+ """
153
+ try:
154
+ proc = subprocess.run(
155
+ ["dot", "-Tplain"], input=dot_src,
156
+ capture_output=True, text=True, check=True,
157
+ )
158
+ except FileNotFoundError:
159
+ sys.exit("error: Graphviz `dot` not found on PATH (brew install graphviz)")
160
+ except subprocess.CalledProcessError as exc:
161
+ sys.exit(f"error: dot failed: {exc.stderr.strip()}")
162
+ height, pos, edges = 0.0, {}, {}
163
+ for line in proc.stdout.splitlines():
164
+ tok = shlex.split(line)
165
+ if not tok:
166
+ continue
167
+ if tok[0] == "graph":
168
+ height = float(tok[3]) # graph scale width height
169
+ elif tok[0] == "node":
170
+ pos[tok[1]] = (float(tok[2]), float(tok[3])) # node name x y ...
171
+ elif tok[0] == "edge": # edge tail head n x1 y1 ... xn yn
172
+ n = int(tok[3])
173
+ edges[(tok[1], tok[2])] = [
174
+ (float(tok[4 + 2 * i]), float(tok[5 + 2 * i])) for i in range(n)
175
+ ]
176
+ return height, pos, edges
177
+
178
+
179
+ def group_style(stroke):
180
+ """Container box styled with a group's colour (coloured border + title)."""
181
+ return (f"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor={stroke};"
182
+ f"fontColor={stroke};verticalAlign=top;fontStyle=2;dashed=1;")
183
+
184
+
185
+ def to_drawio(graph, height, pos, edge_pts, color=True):
186
+ nodes = graph["nodes"]
187
+ # Absolute snapped rect for every placed node.
188
+ rects = {}
189
+ for node in nodes:
190
+ nid = node["id"]
191
+ if nid not in pos:
192
+ continue
193
+ w, h = node.get("width", DEFAULT_W), node.get("height", DEFAULT_H)
194
+ xc, yc = pos[nid]
195
+ x = snap(xc * 72 - w / 2)
196
+ y = snap((height - yc) * 72 - h / 2) # flip: dot origin is bottom-left
197
+ rects[nid] = (x, y, w, h)
198
+ # Parse the (possibly nested) group tree and assign each container a
199
+ # collision-free id and a title (the path's last segment, or a member's groupLabel).
200
+ gpath, direct, children, ordered = group_tree(nodes)
201
+ # Assign each top-level group a palette colour, in order of first appearance.
202
+ top_order = []
203
+ for node in nodes:
204
+ t = gpath.get(node["id"])
205
+ if t and t[0] not in top_order:
206
+ top_order.append(t[0])
207
+
208
+ def gcolor(seg):
209
+ return PALETTE[top_order.index(seg) % len(PALETTE)]
210
+
211
+ used = {n["id"] for n in nodes}
212
+ label_override = {}
213
+ for node in nodes:
214
+ if node["id"] in gpath and "groupLabel" in node:
215
+ label_override.setdefault(gpath[node["id"]], str(node["groupLabel"]))
216
+ gid, glabel = {}, {}
217
+ for i, p in enumerate(ordered):
218
+ cid = f"group_{i}"
219
+ while cid in used: # never collide with a node id
220
+ cid += "_"
221
+ used.add(cid)
222
+ gid[p] = cid
223
+ glabel[p] = label_override.get(p, p[-1])
224
+ # Container bounding box (members + nested children + uniform padding),
225
+ # computed deepest-first so a parent can wrap its already-sized children.
226
+ gbox = {}
227
+ for p in sorted(ordered, key=len, reverse=True):
228
+ xs = [(rects[m][0], rects[m][1], rects[m][0] + rects[m][2], rects[m][1] + rects[m][3])
229
+ for m in direct.get(p, []) if m in rects]
230
+ xs += [(gbox[c][0], gbox[c][1], gbox[c][0] + gbox[c][2], gbox[c][1] + gbox[c][3])
231
+ for c in children.get(p, []) if c in gbox]
232
+ if not xs:
233
+ continue
234
+ x0 = min(b[0] for b in xs) - GROUP_PAD
235
+ y0 = min(b[1] for b in xs) - GROUP_PAD
236
+ x1 = max(b[2] for b in xs) + GROUP_PAD
237
+ y1 = max(b[3] for b in xs) + GROUP_PAD
238
+ gbox[p] = (x0, y0, x1 - x0, y1 - y0)
239
+
240
+ # Shift everything positive: a container's top padding can push its top edge
241
+ # above the page origin. Only translates when something would be negative.
242
+ absx = [r[0] for r in rects.values()] + [b[0] for b in gbox.values()]
243
+ absy = [r[1] for r in rects.values()] + [b[1] for b in gbox.values()]
244
+ dx = GROUP_PAD - min(absx) if absx and min(absx) < 0 else 0
245
+ dy = GROUP_PAD - min(absy) if absy and min(absy) < 0 else 0
246
+
247
+ def rebase(x, y, parent_path):
248
+ """Absolute -> coordinates relative to parent_path's box (or shifted if top-level)."""
249
+ if parent_path is None:
250
+ return x + dx, y + dy, "1"
251
+ px, py, _, _ = gbox[parent_path]
252
+ return x - px, y - py, gid[parent_path]
253
+
254
+ cells = []
255
+ # Containers shallow-first so each parent precedes its children.
256
+ for p in ordered:
257
+ if p not in gbox:
258
+ continue
259
+ gx, gy, gw, gh = gbox[p]
260
+ x, y, parent = rebase(gx, gy, p[:-1] if len(p) > 1 else None)
261
+ gstyle = group_style(gcolor(p[0])[1]) if color else GROUP_STYLE
262
+ cells.append(
263
+ f' <mxCell id="{attr(gid[p])}" value="{attr(glabel[p])}" '
264
+ f'style="{gstyle}" vertex="1" parent="{attr(parent)}">\n'
265
+ f' <mxGeometry x="{x}" y="{y}" width="{gw}" height="{gh}" as="geometry"/>\n'
266
+ f" </mxCell>"
267
+ )
268
+ for node in nodes:
269
+ nid = node["id"]
270
+ if nid not in rects:
271
+ continue
272
+ rx, ry, w, h = rects[nid]
273
+ x, y, parent = rebase(rx, ry, gpath.get(nid) if gpath.get(nid) in gbox else None)
274
+ if node.get("style"):
275
+ style = node["style"] # explicit style always wins
276
+ elif color and nid in gpath:
277
+ fill, stroke = gcolor(gpath[nid][0]) # tint styleless nodes by group
278
+ style = f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};strokeColor={stroke};"
279
+ else:
280
+ style = NODE_STYLE
281
+ cells.append(
282
+ f' <mxCell id="{attr(nid)}" value="{attr(node.get("label", nid))}" '
283
+ f'style="{attr(style)}" vertex="1" parent="{attr(parent)}">\n'
284
+ f' <mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" as="geometry"/>\n'
285
+ f" </mxCell>"
286
+ )
287
+ for i, edge in enumerate(graph.get("edges", [])):
288
+ # Drop the first/last points (they sit on the node borders, where
289
+ # draw.io attaches anyway) and replay the interior bends as waypoints.
290
+ interior = edge_pts.get((edge["source"], edge["target"]), [])[1:-1]
291
+ if interior:
292
+ points = "".join(
293
+ f'<mxPoint x="{snap(x * 72) + dx}" y="{snap((height - y) * 72) + dy}"/>'
294
+ for x, y in interior
295
+ )
296
+ geom = (f'<mxGeometry relative="1" as="geometry">'
297
+ f'<Array as="points">{points}</Array></mxGeometry>')
298
+ else:
299
+ geom = '<mxGeometry relative="1" as="geometry"/>'
300
+ cells.append(
301
+ f' <mxCell id="e{i}" value="{attr(edge.get("label", ""))}" '
302
+ f'style="{EDGE_STYLE}" edge="1" parent="1" '
303
+ f'source="{attr(edge["source"])}" target="{attr(edge["target"])}">\n'
304
+ f" {geom}\n"
305
+ f" </mxCell>"
306
+ )
307
+ return (
308
+ '<mxfile>\n <diagram id="autolayout" name="Page-1">\n'
309
+ ' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
310
+ 'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
311
+ 'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
312
+ " <root>\n"
313
+ ' <mxCell id="0"/>\n'
314
+ ' <mxCell id="1" parent="0"/>\n'
315
+ + "\n".join(cells)
316
+ + "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
317
+ )
318
+
319
+
320
+ def main():
321
+ ap = argparse.ArgumentParser(description="Auto-layout a graph JSON into draw.io XML.")
322
+ ap.add_argument("input", help="graph JSON file")
323
+ ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
324
+ ap.add_argument("--mono", action="store_true",
325
+ help="don't colour groups by palette (monochrome boxes)")
326
+ args = ap.parse_args()
327
+ with open(args.input, encoding="utf-8") as f:
328
+ graph = json.load(f)
329
+ height, pos, edge_pts = layout(build_dot(graph))
330
+ xml = to_drawio(graph, height, pos, edge_pts, color=not args.mono)
331
+ if args.output:
332
+ with open(args.output, "w", encoding="utf-8") as f:
333
+ f.write(xml)
334
+ print(f"wrote {args.output} ({len(graph['nodes'])} nodes, "
335
+ f"{len(graph.get('edges', []))} edges)", file=sys.stderr)
336
+ else:
337
+ sys.stdout.write(xml)
338
+
339
+
340
+ if __name__ == "__main__":
341
+ main()
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Encode a .drawio XML file into a diagrams.net browser URL.
3
+
4
+ Used as the browser fallback when the draw.io desktop CLI is unavailable.
5
+ The diagram XML is carried in the URL fragment (after `#`), so nothing is
6
+ uploaded to any server.
7
+
8
+ Two modes:
9
+ (default) read-only viewer -> https://viewer.diagrams.net/...#R<payload>
10
+ --edit editable editor -> https://app.diagrams.net/...#create=<payload>
11
+
12
+ Usage: python3 encode_drawio_url.py [--edit] <path/to/input.drawio>
13
+ """
14
+ import base64
15
+ import json
16
+ import sys
17
+ import urllib.parse
18
+ import zlib
19
+
20
+
21
+ def _deflate_b64(xml: str) -> str:
22
+ # draw.io's loader runs JS decodeURIComponent on the inflated string, so the
23
+ # XML MUST be percent-encoded (encodeURIComponent) BEFORE deflate — otherwise
24
+ # a literal `%` or any non-ASCII (e.g. CJK) label makes the browser throw
25
+ # "URI malformed" and the diagram never opens. encodeURIComponent leaves
26
+ # only A-Za-z0-9 and -_.!~*'() unescaped, which `quote` reproduces here.
27
+ pre = urllib.parse.quote(xml, safe="!~*'()")
28
+ c = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
29
+ compressed = c.compress(pre.encode("utf-8")) + c.flush()
30
+ # Standard base64 (atob rejects url-safe -/_); strip newlines.
31
+ return base64.b64encode(compressed).decode("utf-8").replace("\n", "")
32
+
33
+
34
+ def encode(xml: str) -> str:
35
+ """Read-only viewer URL (mxGraph `#R` raw-inflate format)."""
36
+ return (
37
+ "https://viewer.diagrams.net/?tags=%7B%7D&lightbox=1&edit=_blank#R"
38
+ + urllib.parse.quote(_deflate_b64(xml), safe="")
39
+ )
40
+
41
+
42
+ def edit_url(xml: str) -> str:
43
+ """Editable editor URL — opens directly in the draw.io editor."""
44
+ payload = json.dumps({"type": "xml", "compressed": True, "data": _deflate_b64(xml)})
45
+ return (
46
+ "https://app.diagrams.net/?grid=0&pv=0&border=10&edit=_blank#create="
47
+ + urllib.parse.quote(payload, safe="")
48
+ )
49
+
50
+
51
+ if __name__ == "__main__":
52
+ args = [a for a in sys.argv[1:] if a != "--edit"]
53
+ if len(args) != 1:
54
+ print("usage: encode_drawio_url.py [--edit] <path>", file=sys.stderr)
55
+ sys.exit(2)
56
+ with open(args[0], "r", encoding="utf-8") as f:
57
+ xml = f.read()
58
+ print(edit_url(xml) if "--edit" in sys.argv[1:] else encode(xml))