@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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/README_zh.md +171 -0
- package/SKILL.md +419 -0
- package/data/SHAPE-INDEX-NOTICE.md +17 -0
- package/data/lobe-icons.json +878 -0
- package/data/shape-index.json.gz +0 -0
- package/package.json +43 -0
- package/references/autolayout.md +125 -0
- package/references/diagram-types.md +83 -0
- package/references/shapes.md +151 -0
- package/references/style-application-guide.md +120 -0
- package/references/style-diagram-matrix.md +159 -0
- package/references/style-extraction.md +255 -0
- package/references/style-presets.md +110 -0
- package/references/styles/style-1-flat-icon.md +79 -0
- package/references/styles/style-2-dark-terminal.md +80 -0
- package/references/styles/style-3-blueprint.md +84 -0
- package/references/styles/style-4-notion-clean.md +78 -0
- package/references/styles/style-5-glassmorphism.md +85 -0
- package/references/styles/style-6-claude-official.md +84 -0
- package/references/styles/style-7-openai.md +94 -0
- package/references/styles/style-8-dark-luxury.md +109 -0
- package/references/troubleshooting.md +63 -0
- package/scripts/aiicons.py +201 -0
- package/scripts/autolayout.py +341 -0
- package/scripts/encode_drawio_url.py +58 -0
- package/scripts/goimports.py +141 -0
- package/scripts/jsimports.py +162 -0
- package/scripts/pyclasses.py +156 -0
- package/scripts/pyimports.py +153 -0
- package/scripts/repair_png.py +37 -0
- package/scripts/rustimports.py +203 -0
- package/scripts/shapesearch.py +162 -0
- package/scripts/validate.py +137 -0
- package/styles/built-in/corporate.json +49 -0
- package/styles/built-in/default.json +49 -0
- package/styles/built-in/handdrawn.json +49 -0
- package/styles/schema-drawio.json +112 -0
- 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), {'"': """})
|
|
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))
|