@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,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a Rust crate's module-use graph as autolayout graph JSON.
|
|
3
|
+
|
|
4
|
+
The Rust counterpart to pyimports.py / jsimports.py / goimports.py. Treats each
|
|
5
|
+
.rs file as a module (path-derived: src/foo/bar.rs -> module foo::bar; main.rs /
|
|
6
|
+
lib.rs / mod.rs name the enclosing module), and records intra-crate `use` edges
|
|
7
|
+
resolved through Rust's path roots:
|
|
8
|
+
|
|
9
|
+
use crate::a::b::C; -> edge to module a::b
|
|
10
|
+
use super::sibling; -> resolved against the current module's parent
|
|
11
|
+
use self::child::Item;-> resolved against the current module
|
|
12
|
+
use other_crate::...; / use std::...; -> external, ignored
|
|
13
|
+
|
|
14
|
+
Brace groups (`use crate::a::{B, C};`, `use crate::{a, b};`) are expanded.
|
|
15
|
+
Transitive reduction is on by default so the diagram stays readable.
|
|
16
|
+
|
|
17
|
+
python3 rustimports.py ./mycrate --group -o graph.json
|
|
18
|
+
python3 autolayout.py graph.json -o diagram.drawio
|
|
19
|
+
|
|
20
|
+
Parsing is regex-based, not a full parser: inline `mod { ... }` blocks are not
|
|
21
|
+
split out, `#[cfg]`-gated modules are always included, and 2015-edition bare
|
|
22
|
+
intra-crate paths (without `crate::`) are not resolved.
|
|
23
|
+
|
|
24
|
+
Usage: python3 rustimports.py <crate_dir> [-o graph.json] [--direction TB|LR]
|
|
25
|
+
[--group] [--no-reduce]
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
USE = re.compile(r"\buse\s+([^;]+);")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def crate_name(root):
|
|
38
|
+
cargo = os.path.join(root, "Cargo.toml")
|
|
39
|
+
if os.path.exists(cargo):
|
|
40
|
+
m = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"',
|
|
41
|
+
open(cargo, encoding="utf-8", errors="ignore").read())
|
|
42
|
+
if m:
|
|
43
|
+
return m.group(1)
|
|
44
|
+
return "crate"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover(root):
|
|
48
|
+
"""Map module path (tuple of segments; () is the crate root) -> file path."""
|
|
49
|
+
root = os.path.abspath(root)
|
|
50
|
+
src = os.path.join(root, "src") if os.path.isdir(os.path.join(root, "src")) else root
|
|
51
|
+
modules = {}
|
|
52
|
+
for dirpath, dirs, files in os.walk(src):
|
|
53
|
+
dirs[:] = [d for d in dirs if d != "target" and not d.startswith(".")]
|
|
54
|
+
for fn in files:
|
|
55
|
+
if not fn.endswith(".rs"):
|
|
56
|
+
continue
|
|
57
|
+
parts = os.path.relpath(os.path.join(dirpath, fn), src)[:-3].split(os.sep)
|
|
58
|
+
if parts[-1] == "mod":
|
|
59
|
+
parts = parts[:-1]
|
|
60
|
+
if len(parts) == 1 and parts[0] in ("main", "lib"):
|
|
61
|
+
parts = [] # crate root
|
|
62
|
+
modules[tuple(parts)] = os.path.join(dirpath, fn)
|
|
63
|
+
return modules, src
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def split_top(inner):
|
|
67
|
+
"""Split a brace group on top-level commas, ignoring nested braces."""
|
|
68
|
+
out, depth, cur = [], 0, ""
|
|
69
|
+
for ch in inner:
|
|
70
|
+
if ch == "{":
|
|
71
|
+
depth += 1
|
|
72
|
+
elif ch == "}":
|
|
73
|
+
depth -= 1
|
|
74
|
+
if ch == "," and depth == 0:
|
|
75
|
+
out.append(cur)
|
|
76
|
+
cur = ""
|
|
77
|
+
else:
|
|
78
|
+
cur += ch
|
|
79
|
+
if cur.strip():
|
|
80
|
+
out.append(cur)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def base_segments(prefix, current):
|
|
85
|
+
"""Classify a `use` path prefix into intra-crate base segments, or None."""
|
|
86
|
+
segs = [s for s in (p.strip() for p in prefix.split("::")) if s]
|
|
87
|
+
if not segs:
|
|
88
|
+
return None
|
|
89
|
+
if segs[0] == "crate":
|
|
90
|
+
return segs[1:]
|
|
91
|
+
if segs[0] == "self":
|
|
92
|
+
return list(current) + segs[1:]
|
|
93
|
+
if segs[0] == "super":
|
|
94
|
+
n = 0
|
|
95
|
+
while segs and segs[0] == "super":
|
|
96
|
+
n += 1
|
|
97
|
+
segs = segs[1:]
|
|
98
|
+
if n > len(current):
|
|
99
|
+
return None # climbs above the crate root
|
|
100
|
+
return list(current)[: len(current) - n] + segs
|
|
101
|
+
return None # std / external crate
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve(parts, modules, current):
|
|
105
|
+
"""Longest known module prefix of `parts` (a tuple), or None."""
|
|
106
|
+
if not parts:
|
|
107
|
+
return () if () in modules and () != tuple(current) else None
|
|
108
|
+
p = list(parts)
|
|
109
|
+
while p:
|
|
110
|
+
if tuple(p) in modules and tuple(p) != tuple(current):
|
|
111
|
+
return tuple(p)
|
|
112
|
+
p = p[:-1]
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def edges_of(current, path, modules):
|
|
117
|
+
"""Intra-crate module paths used by the module at `current`."""
|
|
118
|
+
found = set()
|
|
119
|
+
try:
|
|
120
|
+
src = open(path, encoding="utf-8", errors="ignore").read()
|
|
121
|
+
except OSError:
|
|
122
|
+
return found
|
|
123
|
+
for stmt in USE.findall(src):
|
|
124
|
+
if "{" in stmt:
|
|
125
|
+
prefix = stmt[: stmt.index("{")]
|
|
126
|
+
inner = stmt[stmt.index("{") + 1: stmt.rindex("}")] if "}" in stmt else ""
|
|
127
|
+
leaves = split_top(inner)
|
|
128
|
+
else:
|
|
129
|
+
prefix, leaves = stmt, [None]
|
|
130
|
+
base = base_segments(prefix, current)
|
|
131
|
+
if base is None:
|
|
132
|
+
continue
|
|
133
|
+
for leaf in leaves:
|
|
134
|
+
segs = list(base)
|
|
135
|
+
if leaf:
|
|
136
|
+
first = leaf.strip().split("::")[0].split()[0]
|
|
137
|
+
if first and first not in ("self", "*"):
|
|
138
|
+
segs.append(first)
|
|
139
|
+
target = resolve(tuple(segs), modules, current)
|
|
140
|
+
if target is not None and target != current:
|
|
141
|
+
found.add(target)
|
|
142
|
+
return found
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def transitive_reduce(nodes, edges):
|
|
146
|
+
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
|
147
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
148
|
+
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
|
149
|
+
try:
|
|
150
|
+
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
|
151
|
+
text=True, check=True).stdout
|
|
152
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
153
|
+
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
|
154
|
+
return edges
|
|
155
|
+
rev = {i: n for n, i in idx.items()}
|
|
156
|
+
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
ap = argparse.ArgumentParser(description="Rust module-use graph -> autolayout graph JSON.")
|
|
161
|
+
ap.add_argument("crate", help="crate directory (contains Cargo.toml and/or src/)")
|
|
162
|
+
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
|
163
|
+
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
164
|
+
ap.add_argument("--group", action="store_true",
|
|
165
|
+
help="box modules by their parent module path (nested)")
|
|
166
|
+
ap.add_argument("--no-reduce", action="store_true",
|
|
167
|
+
help="keep every edge (skip transitive reduction)")
|
|
168
|
+
args = ap.parse_args()
|
|
169
|
+
|
|
170
|
+
modules, _ = discover(args.crate)
|
|
171
|
+
if not modules:
|
|
172
|
+
sys.exit(f"error: no .rs modules found under {args.crate}")
|
|
173
|
+
name = crate_name(args.crate)
|
|
174
|
+
mid = lambda parts: name if not parts else "::".join(parts)
|
|
175
|
+
edges = sorted({(mid(m), mid(t)) for m, path in modules.items()
|
|
176
|
+
for t in edges_of(m, path, modules)})
|
|
177
|
+
raw = len(edges)
|
|
178
|
+
if not args.no_reduce:
|
|
179
|
+
edges = transitive_reduce([mid(m) for m in modules], edges)
|
|
180
|
+
|
|
181
|
+
def node(parts):
|
|
182
|
+
d = {"id": mid(parts), "label": name if not parts else parts[-1]}
|
|
183
|
+
if args.group and len(parts) > 1:
|
|
184
|
+
d["group"] = "/".join(parts[:-1]) # parent module path -> nested boxes
|
|
185
|
+
return d
|
|
186
|
+
|
|
187
|
+
graph = {
|
|
188
|
+
"direction": args.direction,
|
|
189
|
+
"nodes": [node(m) for m in modules],
|
|
190
|
+
"edges": [{"source": s, "target": t} for s, t in edges],
|
|
191
|
+
}
|
|
192
|
+
text = json.dumps(graph, indent=2)
|
|
193
|
+
if args.output:
|
|
194
|
+
open(args.output, "w", encoding="utf-8").write(text)
|
|
195
|
+
sys.stderr.write(f"wrote {args.output}\n")
|
|
196
|
+
else:
|
|
197
|
+
sys.stdout.write(text)
|
|
198
|
+
note = "" if args.no_reduce else f" (reduced from {raw})"
|
|
199
|
+
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Search 10k+ official draw.io shapes for their exact style strings.
|
|
3
|
+
|
|
4
|
+
Resolves a keyword query (e.g. "aws lambda", "uml actor", "k8s pod") to the
|
|
5
|
+
matching palette shapes so a diagram can use the real draw.io `style=` string
|
|
6
|
+
instead of a hand-guessed one. Covers AWS / Azure / GCP / Cisco / Kubernetes /
|
|
7
|
+
UML / BPMN / P&ID / electrical / flowchart / network / general shape sets.
|
|
8
|
+
|
|
9
|
+
Based on the search in jgraph/drawio-mcp (Apache-2.0): tag map with exact +
|
|
10
|
+
Soundex matching, strict AND first, scored OR fallback. The matched set is
|
|
11
|
+
identical to upstream; the one addition is a tiebreaker that, among shapes with
|
|
12
|
+
the same tag score, prefers ones whose title contains the query terms verbatim
|
|
13
|
+
(so "dynamodb" returns the shape titled "DynamoDB", not a neighbor merely tagged
|
|
14
|
+
with it). The bundled index (data/shape-index.json.gz) is the upstream draw.io
|
|
15
|
+
shape data — see data/SHAPE-INDEX-NOTICE.md.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 shapesearch.py "aws lambda" [--limit N] [--json]
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import gzip
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
INDEX = os.path.join(os.path.dirname(__file__), "..", "data", "shape-index.json.gz")
|
|
28
|
+
_SOUNDEX_MAP = "01230120022455012603010202" # A..Z digit codes
|
|
29
|
+
_TRAIL = re.compile(r"\.*\d*$") # strip trailing digits/dots before soundex
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def soundex(name):
|
|
33
|
+
if not name:
|
|
34
|
+
return ""
|
|
35
|
+
s = [name[0].upper()]
|
|
36
|
+
si = 1
|
|
37
|
+
for ch in name[1:]:
|
|
38
|
+
c = ord(ch.upper()) - 65
|
|
39
|
+
if 0 <= c <= 25 and _SOUNDEX_MAP[c] != "0":
|
|
40
|
+
code = _SOUNDEX_MAP[c]
|
|
41
|
+
if code != s[si - 1]:
|
|
42
|
+
s.append(code)
|
|
43
|
+
si += 1
|
|
44
|
+
if si > 3:
|
|
45
|
+
break
|
|
46
|
+
s += ["0"] * (4 - len(s))
|
|
47
|
+
return "".join(s[:4])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_tag_map(shapes):
|
|
51
|
+
"""tag (and its Soundex) -> set of shape indices."""
|
|
52
|
+
tag_map = {}
|
|
53
|
+
for i, shape in enumerate(shapes):
|
|
54
|
+
raw = shape.get("tags")
|
|
55
|
+
if not raw:
|
|
56
|
+
continue
|
|
57
|
+
seen = set()
|
|
58
|
+
for token in re.sub(r"[/,()]", " ", raw.lower()).split(" "):
|
|
59
|
+
if len(token) < 2 or token in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add(token)
|
|
62
|
+
tag_map.setdefault(token, set()).add(i)
|
|
63
|
+
sx = soundex(_TRAIL.sub("", token))
|
|
64
|
+
if sx and sx != token and sx not in seen:
|
|
65
|
+
seen.add(sx)
|
|
66
|
+
tag_map.setdefault(sx, set()).add(i)
|
|
67
|
+
return tag_map
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def split_compound(token):
|
|
71
|
+
"""'pid2misc' -> ['pid','misc']; 'discInst' -> ['disc','inst']."""
|
|
72
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", token)
|
|
73
|
+
spaced = re.sub(r"([a-zA-Z])(\d)", r"\1 \2", spaced)
|
|
74
|
+
spaced = re.sub(r"(\d)([a-zA-Z])", r"\1 \2", spaced)
|
|
75
|
+
return [p for p in spaced.lower().split() if len(p) >= 2]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def match_term(tag_map, term):
|
|
79
|
+
exact = set(tag_map.get(term, set()))
|
|
80
|
+
phonetic = set()
|
|
81
|
+
sx = soundex(_TRAIL.sub("", term))
|
|
82
|
+
if sx and sx != term:
|
|
83
|
+
phonetic = {i for i in tag_map.get(sx, set()) if i not in exact}
|
|
84
|
+
return exact, phonetic
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def search(shapes, tag_map, query, limit):
|
|
88
|
+
if not query:
|
|
89
|
+
return []
|
|
90
|
+
terms, seen = [], set()
|
|
91
|
+
for raw in query.lower().split():
|
|
92
|
+
subs = split_compound(raw) or ([raw] if len(raw) >= 2 else [])
|
|
93
|
+
for t in subs:
|
|
94
|
+
if t not in seen:
|
|
95
|
+
seen.add(t)
|
|
96
|
+
terms.append(t)
|
|
97
|
+
if not terms:
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
term_matches = [match_term(tag_map, t) for t in terms]
|
|
101
|
+
|
|
102
|
+
# Strict AND across all terms first.
|
|
103
|
+
and_set = None
|
|
104
|
+
for exact, phonetic in term_matches:
|
|
105
|
+
combined = exact | phonetic
|
|
106
|
+
and_set = combined if and_set is None else (and_set & combined)
|
|
107
|
+
if not and_set:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
# Score: +1.0 exact, +0.5 Soundex-only, per term. AND results if any, else OR.
|
|
111
|
+
scores = {}
|
|
112
|
+
pool = and_set if and_set else None
|
|
113
|
+
for exact, phonetic in term_matches:
|
|
114
|
+
for idx in exact:
|
|
115
|
+
if pool is None or idx in pool:
|
|
116
|
+
scores[idx] = scores.get(idx, 0) + 1.0
|
|
117
|
+
for idx in phonetic:
|
|
118
|
+
if (pool is None or idx in pool) and idx not in exact:
|
|
119
|
+
scores[idx] = scores.get(idx, 0) + 0.5
|
|
120
|
+
|
|
121
|
+
# Rank by tag score desc, then by how many query terms appear verbatim in the
|
|
122
|
+
# title, then casefolded title, then index. The title-hit tiebreak (our one
|
|
123
|
+
# addition over upstream) only reorders *within* an equal tag-score group, so
|
|
124
|
+
# e.g. the shape literally titled "DynamoDB" ranks above a neighbor that is
|
|
125
|
+
# merely tagged `dynamodb` (like "Attribute"). The trailing index keeps ties
|
|
126
|
+
# deterministic.
|
|
127
|
+
term_set = set(terms)
|
|
128
|
+
|
|
129
|
+
def title_hits(idx):
|
|
130
|
+
toks = set(re.split(r"[^a-z0-9]+", shapes[idx].get("title", "").casefold()))
|
|
131
|
+
return len(term_set & toks)
|
|
132
|
+
|
|
133
|
+
ranked = sorted(scores, key=lambda i: (-scores[i], -title_hits(i),
|
|
134
|
+
shapes[i].get("title", "").casefold(), i))
|
|
135
|
+
return [{"style": shapes[i]["style"], "w": shapes[i]["w"],
|
|
136
|
+
"h": shapes[i]["h"], "title": shapes[i]["title"]} for i in ranked[:limit]]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main():
|
|
140
|
+
ap = argparse.ArgumentParser(description="Search official draw.io shapes for their style strings.")
|
|
141
|
+
ap.add_argument("query", help='keywords, e.g. "aws lambda" or "uml actor"')
|
|
142
|
+
ap.add_argument("--limit", type=int, default=10)
|
|
143
|
+
ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
144
|
+
args = ap.parse_args()
|
|
145
|
+
|
|
146
|
+
if not os.path.exists(INDEX):
|
|
147
|
+
sys.exit(f"error: shape index not found at {INDEX}")
|
|
148
|
+
with gzip.open(INDEX, "rt", encoding="utf-8") as f:
|
|
149
|
+
shapes = json.load(f)
|
|
150
|
+
|
|
151
|
+
results = search(shapes, build_tag_map(shapes), args.query, args.limit)
|
|
152
|
+
if not results:
|
|
153
|
+
sys.exit(f"no shapes matched {args.query!r}")
|
|
154
|
+
if args.json:
|
|
155
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
156
|
+
else:
|
|
157
|
+
for r in results:
|
|
158
|
+
print(f"{r['title']} ({r['w']}x{r['h']})\n {r['style']}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic structural linter for .drawio files.
|
|
3
|
+
|
|
4
|
+
Catches the class of mistakes a vision self-check is slow and unreliable at:
|
|
5
|
+
dangling edge endpoints, duplicate or reserved ids, broken parent references,
|
|
6
|
+
and (as warnings) off-grid geometry and overlapping sibling nodes. Runs without
|
|
7
|
+
launching draw.io, so it is a fast pre-check before the visual review step.
|
|
8
|
+
|
|
9
|
+
python3 validate.py diagram.drawio
|
|
10
|
+
|
|
11
|
+
Exit status is non-zero when any error (or, with --strict, any warning) is
|
|
12
|
+
found, so it can gate a workflow. Compressed (non-XML) diagram pages are
|
|
13
|
+
skipped with a warning — this skill always writes uncompressed XML.
|
|
14
|
+
|
|
15
|
+
Usage: python3 validate.py <file.drawio> [--strict]
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
|
|
21
|
+
RESERVED = {"0", "1"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rect(cell):
|
|
25
|
+
"""Return (x, y, w, h) floats for a cell's geometry, or None if absent/bad.
|
|
26
|
+
|
|
27
|
+
x/y default to 0 when omitted: draw.io treats a missing position as the
|
|
28
|
+
origin, and container-managed children (table rows, swimlane/UML-class
|
|
29
|
+
lines under tableLayout) legitimately omit x/y while keeping width/height.
|
|
30
|
+
Only width/height are required to be present and numeric.
|
|
31
|
+
"""
|
|
32
|
+
g = cell.find("mxGeometry")
|
|
33
|
+
if g is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
return (float(g.get("x", "0")), float(g.get("y", "0")),
|
|
37
|
+
float(g.get("width", "nan")), float(g.get("height", "nan")))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_edge_label(cell):
|
|
43
|
+
"""True for a draw.io edge label / relative-positioned child vertex.
|
|
44
|
+
|
|
45
|
+
These legitimately omit width/height: their position is given relative to a
|
|
46
|
+
parent edge (style ``edgeLabel``) or via ``relative="1"`` geometry. Treating
|
|
47
|
+
them as normal vertices wrongly flags them as missing/invalid geometry.
|
|
48
|
+
"""
|
|
49
|
+
if "edgeLabel" in (cell.get("style") or ""):
|
|
50
|
+
return True
|
|
51
|
+
g = cell.find("mxGeometry")
|
|
52
|
+
return g is not None and g.get("relative") == "1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def overlap(a, b):
|
|
56
|
+
ax, ay, aw, ah = a
|
|
57
|
+
bx, by, bw, bh = b
|
|
58
|
+
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_page(diagram):
|
|
62
|
+
"""Return (errors, warnings) for one <diagram> page."""
|
|
63
|
+
name = diagram.get("name", "?")
|
|
64
|
+
model = diagram.find("mxGraphModel")
|
|
65
|
+
if model is None:
|
|
66
|
+
if (diagram.text or "").strip():
|
|
67
|
+
return [], [f"page {name!r}: compressed, skipped (cannot lint)"]
|
|
68
|
+
return [f"page {name!r}: no <mxGraphModel>"], []
|
|
69
|
+
root = model.find("root")
|
|
70
|
+
cells = root.findall("mxCell") if root is not None else []
|
|
71
|
+
errors, warns = [], []
|
|
72
|
+
ids = {}
|
|
73
|
+
for c in cells:
|
|
74
|
+
cid = c.get("id")
|
|
75
|
+
if cid in ids:
|
|
76
|
+
errors.append(f"duplicate id {cid!r}")
|
|
77
|
+
ids[cid] = c
|
|
78
|
+
parents = {c.get("parent") for c in cells} # ids that have children
|
|
79
|
+
for c in cells:
|
|
80
|
+
cid, parent = c.get("id"), c.get("parent")
|
|
81
|
+
is_v, is_e = c.get("vertex") == "1", c.get("edge") == "1"
|
|
82
|
+
if parent is not None and parent not in ids:
|
|
83
|
+
errors.append(f"cell {cid!r} parent {parent!r} does not exist")
|
|
84
|
+
for end in ("source", "target"):
|
|
85
|
+
ref = c.get(end)
|
|
86
|
+
if ref and ref not in ids:
|
|
87
|
+
errors.append(f"edge {cid!r} {end} {ref!r} does not exist")
|
|
88
|
+
if (is_v or is_e) and cid in RESERVED:
|
|
89
|
+
errors.append(f"cell {cid!r} reuses reserved id 0/1")
|
|
90
|
+
if is_v and not is_edge_label(c):
|
|
91
|
+
r = rect(c)
|
|
92
|
+
if r is None or any(v != v for v in r): # None or NaN
|
|
93
|
+
errors.append(f"vertex {cid!r} has missing/invalid geometry")
|
|
94
|
+
else:
|
|
95
|
+
x, y, w, h = r
|
|
96
|
+
if w <= 0 or h <= 0:
|
|
97
|
+
warns.append(f"vertex {cid!r} non-positive size {w:g}x{h:g}")
|
|
98
|
+
if x < 0 or y < 0:
|
|
99
|
+
warns.append(f"vertex {cid!r} negative position ({x:g},{y:g})")
|
|
100
|
+
# Sibling overlap: only leaf vertices (containers legitimately wrap children).
|
|
101
|
+
boxes = [(c.get("id"), c.get("parent"), rect(c)) for c in cells
|
|
102
|
+
if c.get("vertex") == "1" and c.get("id") not in parents and rect(c)
|
|
103
|
+
and not any(v != v for v in rect(c))]
|
|
104
|
+
for i in range(len(boxes)):
|
|
105
|
+
for j in range(i + 1, len(boxes)):
|
|
106
|
+
(ia, pa, ra), (ib, pb, rb) = boxes[i], boxes[j]
|
|
107
|
+
if pa == pb and overlap(ra, rb):
|
|
108
|
+
warns.append(f"vertices {ia!r} and {ib!r} overlap")
|
|
109
|
+
return errors, warns
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
ap = argparse.ArgumentParser(description="Lint a .drawio file for structural errors.")
|
|
114
|
+
ap.add_argument("file")
|
|
115
|
+
ap.add_argument("--strict", action="store_true", help="treat warnings as failure too")
|
|
116
|
+
args = ap.parse_args()
|
|
117
|
+
try:
|
|
118
|
+
tree = ET.parse(args.file)
|
|
119
|
+
except (ET.ParseError, OSError) as exc:
|
|
120
|
+
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
|
121
|
+
pages = tree.getroot().findall("diagram") or [tree.getroot()]
|
|
122
|
+
errors, warns = [], []
|
|
123
|
+
for page in pages:
|
|
124
|
+
e, w = check_page(page)
|
|
125
|
+
errors += e
|
|
126
|
+
warns += w
|
|
127
|
+
for w in warns:
|
|
128
|
+
print(f"warning: {w}")
|
|
129
|
+
for e in errors:
|
|
130
|
+
print(f"error: {e}")
|
|
131
|
+
print(f"{len(errors)} error(s), {len(warns)} warning(s)")
|
|
132
|
+
if errors or (args.strict and warns):
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schema.json",
|
|
3
|
+
"name": "corporate",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"default": false,
|
|
6
|
+
"source": { "type": "built-in" },
|
|
7
|
+
"confidence": "high",
|
|
8
|
+
"palette": {
|
|
9
|
+
"primary": { "fillColor": "#e3f2fd", "strokeColor": "#1565c0" },
|
|
10
|
+
"success": { "fillColor": "#e8f5e9", "strokeColor": "#2e7d32" },
|
|
11
|
+
"warning": { "fillColor": "#fff9c4", "strokeColor": "#f57c00" },
|
|
12
|
+
"accent": { "fillColor": "#fff3e0", "strokeColor": "#e65100" },
|
|
13
|
+
"danger": { "fillColor": "#ffebee", "strokeColor": "#c62828" },
|
|
14
|
+
"neutral": { "fillColor": "#eceff1", "strokeColor": "#455a64" },
|
|
15
|
+
"secondary": { "fillColor": "#f3e5f5", "strokeColor": "#6a1b9a" }
|
|
16
|
+
},
|
|
17
|
+
"roles": {
|
|
18
|
+
"service": "primary",
|
|
19
|
+
"database": "success",
|
|
20
|
+
"queue": "warning",
|
|
21
|
+
"gateway": "accent",
|
|
22
|
+
"error": "danger",
|
|
23
|
+
"external": "neutral",
|
|
24
|
+
"security": "secondary"
|
|
25
|
+
},
|
|
26
|
+
"shapes": {
|
|
27
|
+
"service": "rounded=0",
|
|
28
|
+
"database": "shape=cylinder3",
|
|
29
|
+
"queue": "rounded=0",
|
|
30
|
+
"decision": "rhombus",
|
|
31
|
+
"external": "rounded=0;dashed=1",
|
|
32
|
+
"container": "swimlane;startSize=30"
|
|
33
|
+
},
|
|
34
|
+
"font": {
|
|
35
|
+
"fontFamily": "Arial",
|
|
36
|
+
"fontSize": 11,
|
|
37
|
+
"titleFontSize": 13,
|
|
38
|
+
"titleBold": true
|
|
39
|
+
},
|
|
40
|
+
"edges": {
|
|
41
|
+
"style": "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1",
|
|
42
|
+
"arrow": "endArrow=classic;endFill=1",
|
|
43
|
+
"dashedFor": ["optional", "async"]
|
|
44
|
+
},
|
|
45
|
+
"extras": {
|
|
46
|
+
"sketch": false,
|
|
47
|
+
"globalStrokeWidth": 1
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schema.json",
|
|
3
|
+
"name": "default",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"default": false,
|
|
6
|
+
"source": { "type": "built-in" },
|
|
7
|
+
"confidence": "high",
|
|
8
|
+
"palette": {
|
|
9
|
+
"primary": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf" },
|
|
10
|
+
"success": { "fillColor": "#d5e8d4", "strokeColor": "#82b366" },
|
|
11
|
+
"warning": { "fillColor": "#fff2cc", "strokeColor": "#d6b656" },
|
|
12
|
+
"accent": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00" },
|
|
13
|
+
"danger": { "fillColor": "#f8cecc", "strokeColor": "#b85450" },
|
|
14
|
+
"neutral": { "fillColor": "#f5f5f5", "strokeColor": "#666666" },
|
|
15
|
+
"secondary": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6" }
|
|
16
|
+
},
|
|
17
|
+
"roles": {
|
|
18
|
+
"service": "primary",
|
|
19
|
+
"database": "success",
|
|
20
|
+
"queue": "warning",
|
|
21
|
+
"gateway": "accent",
|
|
22
|
+
"error": "danger",
|
|
23
|
+
"external": "neutral",
|
|
24
|
+
"security": "secondary"
|
|
25
|
+
},
|
|
26
|
+
"shapes": {
|
|
27
|
+
"service": "rounded=1",
|
|
28
|
+
"database": "shape=cylinder3",
|
|
29
|
+
"queue": "rounded=1",
|
|
30
|
+
"decision": "rhombus",
|
|
31
|
+
"external": "rounded=1;dashed=1",
|
|
32
|
+
"container": "swimlane;startSize=30"
|
|
33
|
+
},
|
|
34
|
+
"font": {
|
|
35
|
+
"fontFamily": "Helvetica",
|
|
36
|
+
"fontSize": 12,
|
|
37
|
+
"titleFontSize": 14,
|
|
38
|
+
"titleBold": true
|
|
39
|
+
},
|
|
40
|
+
"edges": {
|
|
41
|
+
"style": "edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
|
|
42
|
+
"arrow": "endArrow=classic;endFill=1",
|
|
43
|
+
"dashedFor": []
|
|
44
|
+
},
|
|
45
|
+
"extras": {
|
|
46
|
+
"sketch": false,
|
|
47
|
+
"globalStrokeWidth": 1
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schema.json",
|
|
3
|
+
"name": "handdrawn",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"default": false,
|
|
6
|
+
"source": { "type": "built-in" },
|
|
7
|
+
"confidence": "high",
|
|
8
|
+
"palette": {
|
|
9
|
+
"primary": { "fillColor": "#ffe4b5", "strokeColor": "#b8651e" },
|
|
10
|
+
"success": { "fillColor": "#def0dc", "strokeColor": "#5c8a49" },
|
|
11
|
+
"warning": { "fillColor": "#fff4cc", "strokeColor": "#b8901a" },
|
|
12
|
+
"accent": { "fillColor": "#ffd9b3", "strokeColor": "#c25100" },
|
|
13
|
+
"danger": { "fillColor": "#ffcdbf", "strokeColor": "#a53d3d" },
|
|
14
|
+
"neutral": { "fillColor": "#f5e6d3", "strokeColor": "#8b7355" },
|
|
15
|
+
"secondary": { "fillColor": "#e6d7e8", "strokeColor": "#7b4397" }
|
|
16
|
+
},
|
|
17
|
+
"roles": {
|
|
18
|
+
"service": "primary",
|
|
19
|
+
"database": "success",
|
|
20
|
+
"queue": "warning",
|
|
21
|
+
"gateway": "accent",
|
|
22
|
+
"error": "danger",
|
|
23
|
+
"external": "neutral",
|
|
24
|
+
"security": "secondary"
|
|
25
|
+
},
|
|
26
|
+
"shapes": {
|
|
27
|
+
"service": "rounded=1",
|
|
28
|
+
"database": "shape=cylinder3",
|
|
29
|
+
"queue": "rounded=1",
|
|
30
|
+
"decision": "rhombus",
|
|
31
|
+
"external": "rounded=1;dashed=1",
|
|
32
|
+
"container": "swimlane;startSize=30"
|
|
33
|
+
},
|
|
34
|
+
"font": {
|
|
35
|
+
"fontFamily": "Helvetica",
|
|
36
|
+
"fontSize": 12,
|
|
37
|
+
"titleFontSize": 14,
|
|
38
|
+
"titleBold": true
|
|
39
|
+
},
|
|
40
|
+
"edges": {
|
|
41
|
+
"style": "edgeStyle=orthogonalEdgeStyle;curved=1;rounded=1;orthogonalLoop=1;jettySize=auto;html=1",
|
|
42
|
+
"arrow": "endArrow=classic;endFill=1",
|
|
43
|
+
"dashedFor": ["optional"]
|
|
44
|
+
},
|
|
45
|
+
"extras": {
|
|
46
|
+
"sketch": true,
|
|
47
|
+
"globalStrokeWidth": 2
|
|
48
|
+
}
|
|
49
|
+
}
|