@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,141 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a Go module's package-import graph as autolayout graph JSON.
3
+
4
+ The Go counterpart to pyimports.py / jsimports.py. Reads the module path from
5
+ go.mod, walks the module, treats each directory of .go files as one package
6
+ (node = its import path), and records the intra-module package imports. Stdlib
7
+ and third-party imports are ignored. Transitive reduction is on by default so
8
+ the diagram stays readable.
9
+
10
+ python3 goimports.py ./mymodule -o graph.json
11
+ python3 autolayout.py graph.json -o diagram.drawio
12
+
13
+ Parsing is regex-based over `import` statements (single and block form),
14
+ which is enough for a structural package graph. *_test.go files and vendor/
15
+ are skipped.
16
+
17
+ Usage: python3 goimports.py <module_dir> [-o graph.json] [--direction TB|LR]
18
+ [--group] [--no-reduce]
19
+ """
20
+ import argparse
21
+ import json
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+
27
+ MODULE = re.compile(r"^module\s+(\S+)", re.MULTILINE)
28
+ BLOCK = re.compile(r"import\s*\((.*?)\)", re.DOTALL)
29
+ SINGLE = re.compile(r'import\s+(?:[\w.]+\s+|_\s+)?"([^"]+)"')
30
+ QUOTED = re.compile(r'"([^"]+)"')
31
+
32
+
33
+ def module_path(root):
34
+ """Read the `module` path from go.mod at the module root, or None."""
35
+ gomod = os.path.join(root, "go.mod")
36
+ if not os.path.exists(gomod):
37
+ return None
38
+ m = MODULE.search(open(gomod, encoding="utf-8", errors="ignore").read())
39
+ return m.group(1) if m else None
40
+
41
+
42
+ def discover(root, modpath):
43
+ """Map package import path -> list of .go files (one entry per directory)."""
44
+ root = os.path.abspath(root)
45
+ pkgs = {}
46
+ for dirpath, dirs, files in os.walk(root):
47
+ dirs[:] = [d for d in dirs
48
+ if d not in ("vendor", "testdata") and not d.startswith(".")]
49
+ gofiles = [os.path.join(dirpath, f) for f in files
50
+ if f.endswith(".go") and not f.endswith("_test.go")]
51
+ if not gofiles:
52
+ continue
53
+ rel = os.path.relpath(dirpath, root).replace(os.sep, "/")
54
+ ip = modpath if rel == "." else f"{modpath}/{rel}"
55
+ pkgs[ip] = gofiles
56
+ return pkgs
57
+
58
+
59
+ def imports_of(files, modpath, pkgs):
60
+ """Intra-module package import paths referenced by a package's files."""
61
+ found = set()
62
+ for path in files:
63
+ try:
64
+ src = open(path, encoding="utf-8", errors="ignore").read()
65
+ except OSError:
66
+ continue
67
+ specs = []
68
+ for block in BLOCK.findall(src):
69
+ specs += QUOTED.findall(block)
70
+ specs += SINGLE.findall(src)
71
+ for spec in specs:
72
+ if (spec == modpath or spec.startswith(modpath + "/")) and spec in pkgs:
73
+ found.add(spec)
74
+ return found
75
+
76
+
77
+ def transitive_reduce(nodes, edges):
78
+ """Drop edges implied by a longer path, via Graphviz `tred`."""
79
+ idx = {n: i for i, n in enumerate(nodes)}
80
+ dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
81
+ try:
82
+ out = subprocess.run(["tred"], input=dot, capture_output=True,
83
+ text=True, check=True).stdout
84
+ except (FileNotFoundError, subprocess.CalledProcessError) as exc:
85
+ sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
86
+ return edges
87
+ rev = {i: n for n, i in idx.items()}
88
+ return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser(description="Go import graph -> autolayout graph JSON.")
93
+ ap.add_argument("module", help="module directory (contains go.mod)")
94
+ ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
95
+ ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
96
+ ap.add_argument("--group", action="store_true",
97
+ help="group nodes into containers by top-level package dir")
98
+ ap.add_argument("--no-reduce", action="store_true",
99
+ help="keep every edge (skip transitive reduction)")
100
+ args = ap.parse_args()
101
+
102
+ modpath = module_path(args.module)
103
+ if not modpath:
104
+ sys.exit(f"error: no go.mod with a module path found in {args.module}")
105
+ pkgs = discover(args.module, modpath)
106
+ if not pkgs:
107
+ sys.exit(f"error: no Go packages found under {args.module}")
108
+ edges = sorted({(ip, t) for ip, files in pkgs.items()
109
+ for t in imports_of(files, modpath, pkgs) if t != ip})
110
+ raw = len(edges)
111
+ if not args.no_reduce:
112
+ edges = transitive_reduce(list(pkgs), edges)
113
+ # Drop the module prefix from labels for readability; ids stay full.
114
+ strip = modpath + "/"
115
+ label = lambda ip: ip[len(strip):] if ip.startswith(strip) else os.path.basename(ip)
116
+
117
+ def node(ip):
118
+ d = {"id": ip, "label": label(ip)}
119
+ if args.group:
120
+ rest = label(ip).split("/")
121
+ if len(rest) > 1: # nested under a sub-package
122
+ d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
123
+ return d
124
+
125
+ graph = {
126
+ "direction": args.direction,
127
+ "nodes": [node(ip) for ip in pkgs],
128
+ "edges": [{"source": s, "target": t} for s, t in edges],
129
+ }
130
+ text = json.dumps(graph, indent=2)
131
+ if args.output:
132
+ open(args.output, "w", encoding="utf-8").write(text)
133
+ sys.stderr.write(f"wrote {args.output}\n")
134
+ else:
135
+ sys.stdout.write(text)
136
+ note = "" if args.no_reduce else f" (reduced from {raw})"
137
+ sys.stderr.write(f"{len(pkgs)} packages, {len(edges)} edges{note}\n")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a JS/TS project's module-import graph as autolayout graph JSON.
3
+
4
+ The JavaScript/TypeScript counterpart to pyimports.py: walks a source
5
+ directory, scans each module for static and dynamic import specifiers
6
+ (`import`/`export ... from`, `require()`, `import()`), keeps only the
7
+ intra-project edges (relative specifiers that resolve to a file under the
8
+ root), and applies transitive reduction so the diagram stays readable.
9
+
10
+ python3 jsimports.py src -o graph.json
11
+ python3 autolayout.py graph.json -o diagram.drawio
12
+
13
+ Resolution is path-based: relative specifiers are resolved against the
14
+ importing file's directory, trying the .ts/.tsx/.js/.jsx/.mjs/.cjs extensions
15
+ and directory index files. Bare specifiers (node_modules packages such as
16
+ "react") are ignored. Scanning is regex-based rather than a full parser, so a
17
+ specifier inside a comment or string literal is counted in rare cases.
18
+
19
+ Usage: python3 jsimports.py <src_dir> [-o graph.json] [--direction TB|LR]
20
+ [--group] [--no-reduce]
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+
29
+ EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
30
+ SPEC = re.compile(
31
+ r"(?:import|export)\b[^'\";]*?\bfrom\s*['\"]([^'\"]+)['\"]" # import/export ... from "x"
32
+ r"|import\s*['\"]([^'\"]+)['\"]" # import "x" (side effect)
33
+ r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # require("x")
34
+ r"|import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # import("x") dynamic
35
+ )
36
+
37
+
38
+ def modid(path, root):
39
+ """Module id = path relative to root, extension stripped, posix separators."""
40
+ rel = os.path.relpath(path, root)
41
+ for ext in EXTS:
42
+ if rel.endswith(ext):
43
+ rel = rel[: -len(ext)]
44
+ break
45
+ return rel.replace(os.sep, "/")
46
+
47
+
48
+ def discover(root):
49
+ """Map module id -> absolute file path for every source file under root."""
50
+ root = os.path.abspath(root)
51
+ modules = {}
52
+ for dirpath, dirs, files in os.walk(root):
53
+ dirs[:] = [d for d in dirs if d != "node_modules" and not d.startswith(".")]
54
+ for fn in files:
55
+ if fn.endswith(EXTS) and not fn.endswith(".d.ts"):
56
+ full = os.path.join(dirpath, fn)
57
+ modules[modid(full, root)] = full
58
+ return modules, root
59
+
60
+
61
+ def resolve(spec, importer, root, modules):
62
+ """Resolve a relative specifier to a known module id, or None (external)."""
63
+ if not spec.startswith("."):
64
+ return None
65
+ base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
66
+ candidates = ([base + e for e in EXTS]
67
+ + [os.path.join(base, "index" + e) for e in EXTS]
68
+ + [base])
69
+ for cand in candidates:
70
+ mid = modid(cand, root)
71
+ if mid in modules and modules[mid] != importer:
72
+ return mid
73
+ return None
74
+
75
+
76
+ def edges_of(mid, path, root, modules):
77
+ """Intra-project modules imported by module `mid`."""
78
+ found = set()
79
+ try:
80
+ src = open(path, encoding="utf-8", errors="ignore").read()
81
+ except OSError:
82
+ return found
83
+ for m in SPEC.finditer(src):
84
+ spec = m.group(1) or m.group(2) or m.group(3) or m.group(4)
85
+ target = resolve(spec, path, root, modules)
86
+ if target and target != mid:
87
+ found.add(target)
88
+ return found
89
+
90
+
91
+ def transitive_reduce(nodes, edges):
92
+ """Drop edges implied by a longer path, via Graphviz `tred`."""
93
+ idx = {n: i for i, n in enumerate(nodes)}
94
+ dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
95
+ try:
96
+ out = subprocess.run(["tred"], input=dot, capture_output=True,
97
+ text=True, check=True).stdout
98
+ except (FileNotFoundError, subprocess.CalledProcessError) as exc:
99
+ sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
100
+ return edges
101
+ rev = {i: n for n, i in idx.items()}
102
+ return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
103
+
104
+
105
+ def common_dir(ids):
106
+ """Longest shared leading path segment across module ids (e.g. 'src/')."""
107
+ common = []
108
+ for parts in zip(*[m.split("/") for m in ids]):
109
+ if len(set(parts)) == 1:
110
+ common.append(parts[0])
111
+ else:
112
+ break
113
+ return "/".join(common) + "/" if common else ""
114
+
115
+
116
+ def main():
117
+ ap = argparse.ArgumentParser(description="JS/TS import graph -> autolayout graph JSON.")
118
+ ap.add_argument("src", help="source directory")
119
+ ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
120
+ ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
121
+ ap.add_argument("--group", action="store_true",
122
+ help="group nodes into containers by top-level directory")
123
+ ap.add_argument("--no-reduce", action="store_true",
124
+ help="keep every edge (skip transitive reduction)")
125
+ args = ap.parse_args()
126
+
127
+ modules, root = discover(args.src)
128
+ if not modules:
129
+ sys.exit(f"error: no JS/TS modules found under {args.src}")
130
+ edges = sorted({(m, t) for m, p in modules.items()
131
+ for t in edges_of(m, p, root, modules)})
132
+ raw = len(edges)
133
+ if not args.no_reduce:
134
+ edges = transitive_reduce(list(modules), edges)
135
+ strip = common_dir(list(modules))
136
+ label = lambda m: (m[len(strip):] if strip and m.startswith(strip) else m) or m
137
+
138
+ def node(m):
139
+ d = {"id": m, "label": label(m)}
140
+ if args.group:
141
+ rest = label(m).split("/")
142
+ if len(rest) > 1: # has a sub-directory
143
+ d["group"] = "/".join(rest[:-1]) # full directory path -> nested boxes
144
+ return d
145
+
146
+ graph = {
147
+ "direction": args.direction,
148
+ "nodes": [node(m) for m in modules],
149
+ "edges": [{"source": s, "target": t} for s, t in edges],
150
+ }
151
+ text = json.dumps(graph, indent=2)
152
+ if args.output:
153
+ open(args.output, "w", encoding="utf-8").write(text)
154
+ sys.stderr.write(f"wrote {args.output}\n")
155
+ else:
156
+ sys.stdout.write(text)
157
+ note = "" if args.no_reduce else f" (reduced from {raw})"
158
+ sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a Python project's class-inheritance graph as autolayout graph JSON.
3
+
4
+ A finer-grained companion to pyimports.py: instead of module->module imports,
5
+ it emits one node per class and an edge from each subclass to the project base
6
+ classes it extends. With --group, classes are boxed by their module (nested by
7
+ sub-package), so the result reads as an auto-generated class hierarchy.
8
+
9
+ python3 pyclasses.py myproject --group -o graph.json
10
+ python3 autolayout.py graph.json -o diagram.drawio
11
+
12
+ Only inheritance is resolved (statically reliable); base classes are matched
13
+ by name, preferring a class in the same module. External bases (object,
14
+ Exception, third-party) are ignored. This is a *class structure* view, not a
15
+ function-level call graph — static call resolution in Python is unreliable, so
16
+ that is deliberately out of scope.
17
+
18
+ Usage: python3 pyclasses.py <project_dir> [-o graph.json] [--direction TB|LR]
19
+ [--group] [--no-reduce]
20
+ """
21
+ import argparse
22
+ import ast
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+
29
+
30
+ def discover(root):
31
+ """Map dotted module name -> file path; qualify with the package name when
32
+ root is itself a package (mirrors pyimports.py)."""
33
+ root = os.path.abspath(root)
34
+ base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
35
+ modules = {}
36
+ for dirpath, _, files in os.walk(root):
37
+ for fn in files:
38
+ if not fn.endswith(".py"):
39
+ continue
40
+ rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3]
41
+ parts = rel.split(os.sep)
42
+ if parts[-1] == "__init__":
43
+ parts = parts[:-1]
44
+ parts = ([base] + parts) if base else parts
45
+ if parts:
46
+ modules[".".join(parts)] = os.path.join(dirpath, fn)
47
+ return modules, base
48
+
49
+
50
+ def base_name(node):
51
+ """Simple name of a base-class expression (`Foo` or `pkg.Foo` -> 'Foo')."""
52
+ if isinstance(node, ast.Name):
53
+ return node.id
54
+ if isinstance(node, ast.Attribute):
55
+ return node.attr
56
+ return None
57
+
58
+
59
+ def classes_in(module, path):
60
+ """Top-level classes of a module: list of (qualified_id, simple_name, [base names])."""
61
+ try:
62
+ tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
63
+ except SyntaxError:
64
+ return []
65
+ out = []
66
+ for node in tree.body:
67
+ if isinstance(node, ast.ClassDef):
68
+ bases = [b for b in (base_name(b) for b in node.bases) if b]
69
+ out.append((f"{module}.{node.name}", node.name, bases))
70
+ return out
71
+
72
+
73
+ def transitive_reduce(nodes, edges):
74
+ """Drop edges implied by a longer path, via Graphviz `tred`."""
75
+ idx = {n: i for i, n in enumerate(nodes)}
76
+ dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
77
+ try:
78
+ out = subprocess.run(["tred"], input=dot, capture_output=True,
79
+ text=True, check=True).stdout
80
+ except (FileNotFoundError, subprocess.CalledProcessError) as exc:
81
+ sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
82
+ return edges
83
+ rev = {i: n for n, i in idx.items()}
84
+ return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
85
+
86
+
87
+ def main():
88
+ ap = argparse.ArgumentParser(description="Python class-inheritance graph -> autolayout graph JSON.")
89
+ ap.add_argument("project", help="package or project directory")
90
+ ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
91
+ ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
92
+ ap.add_argument("--group", action="store_true",
93
+ help="box classes by their module (nested by sub-package)")
94
+ ap.add_argument("--no-reduce", action="store_true",
95
+ help="keep every edge (skip transitive reduction)")
96
+ args = ap.parse_args()
97
+
98
+ modules, base = discover(args.project)
99
+ classes = {} # qualified id -> (module, bases)
100
+ by_name = {} # simple name -> [qualified ids]
101
+ for mod, path in modules.items():
102
+ for cid, name, bases in classes_in(mod, path):
103
+ classes[cid] = (mod, bases)
104
+ by_name.setdefault(name, []).append(cid)
105
+ if not classes:
106
+ sys.exit(f"error: no classes found under {args.project}")
107
+
108
+ def resolve(name, module):
109
+ cands = by_name.get(name, [])
110
+ same = [c for c in cands if classes[c][0] == module]
111
+ if same:
112
+ return same[0] # prefer a class in the same module
113
+ return cands[0] if len(cands) == 1 else None # else only if unambiguous
114
+
115
+ edges = set()
116
+ for cid, (mod, bases) in classes.items():
117
+ for b in bases:
118
+ target = resolve(b, mod)
119
+ if target and target != cid:
120
+ edges.add((cid, target))
121
+ edges = sorted(edges)
122
+ raw = len(edges)
123
+ if not args.no_reduce:
124
+ edges = transitive_reduce(list(classes), edges)
125
+
126
+ strip = base + "." if base else ""
127
+ short = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
128
+
129
+ def node(cid):
130
+ # No hard-coded colour: autolayout tints nodes by their group (module),
131
+ # so a grouped class hierarchy reads as coloured-by-module.
132
+ d = {"id": cid, "label": cid.rsplit(".", 1)[1]}
133
+ if args.group:
134
+ mod = classes[cid][0]
135
+ path = short(mod).replace(".", "/") # module path -> nested boxes
136
+ if path:
137
+ d["group"] = path
138
+ return d
139
+
140
+ graph = {
141
+ "direction": args.direction,
142
+ "nodes": [node(cid) for cid in classes],
143
+ "edges": [{"source": s, "target": t} for s, t in edges],
144
+ }
145
+ text = json.dumps(graph, indent=2)
146
+ if args.output:
147
+ open(args.output, "w", encoding="utf-8").write(text)
148
+ sys.stderr.write(f"wrote {args.output}\n")
149
+ else:
150
+ sys.stdout.write(text)
151
+ note = "" if args.no_reduce else f" (reduced from {raw})"
152
+ sys.stderr.write(f"{len(classes)} classes, {len(edges)} inheritance edges{note}\n")
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a Python project's module-import graph as autolayout graph JSON.
3
+
4
+ Walks a package/project directory, parses each module with `ast`, builds the
5
+ intra-project import edges, and (by default) applies transitive reduction so
6
+ the diagram stays readable instead of becoming a hairball. The output feeds
7
+ autolayout.py:
8
+
9
+ python3 pyimports.py myproject -o graph.json
10
+ python3 autolayout.py graph.json -o diagram.drawio
11
+
12
+ Transitive reduction uses Graphviz `tred` (drops edges implied by a longer
13
+ path); pass --no-reduce to keep every import edge. Only intra-project imports
14
+ are kept — third-party and stdlib imports are ignored.
15
+
16
+ Usage: python3 pyimports.py <project_dir> [-o graph.json] [--direction TB|LR] [--no-reduce]
17
+ """
18
+ import argparse
19
+ import ast
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+
26
+
27
+ def discover(root):
28
+ """Map dotted module name -> file path for every .py under root, plus the
29
+ package prefix. If root is itself a package (has __init__.py), module names
30
+ are qualified with its name so the project's own absolute imports resolve."""
31
+ root = os.path.abspath(root)
32
+ base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
33
+ modules = {}
34
+ for dirpath, _, files in os.walk(root):
35
+ for fn in files:
36
+ if not fn.endswith(".py"):
37
+ continue
38
+ rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3] # strip .py
39
+ parts = rel.split(os.sep)
40
+ if parts[-1] == "__init__":
41
+ parts = parts[:-1] # package = its dir
42
+ parts = ([base] + parts) if base else parts
43
+ if parts:
44
+ modules[".".join(parts)] = os.path.join(dirpath, fn)
45
+ return modules, base
46
+
47
+
48
+ def resolve(name, current, modules):
49
+ """Resolve a dotted name to the longest known module prefix (or None)."""
50
+ parts = name.split(".") if name else []
51
+ while parts:
52
+ cand = ".".join(parts)
53
+ if cand in modules and cand != current:
54
+ return cand
55
+ parts = parts[:-1]
56
+ return None
57
+
58
+
59
+ def edges_of(name, path, modules):
60
+ """Intra-project modules imported by `name`."""
61
+ pkg = name if path.endswith("__init__.py") else name.rsplit(".", 1)[0] if "." in name else ""
62
+ found = set()
63
+ try:
64
+ tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
65
+ except SyntaxError:
66
+ return found
67
+ for node in ast.walk(tree):
68
+ if isinstance(node, ast.Import): # import a.b.c
69
+ for alias in node.names:
70
+ target = resolve(alias.name, name, modules)
71
+ if target:
72
+ found.add(target)
73
+ elif isinstance(node, ast.ImportFrom): # from a.b import c
74
+ if node.level: # relative: climb level-1 packages
75
+ base = pkg.split(".") if pkg else []
76
+ base = base[: len(base) - (node.level - 1)]
77
+ prefix = ".".join(base)
78
+ mod = f"{prefix}.{node.module}" if prefix and node.module else (node.module or prefix)
79
+ else:
80
+ mod = node.module or ""
81
+ target = resolve(mod, name, modules)
82
+ if target:
83
+ found.add(target)
84
+ for alias in node.names: # `from pkg import submodule`
85
+ sub = f"{mod}.{alias.name}" if mod else alias.name
86
+ target = resolve(sub, name, modules)
87
+ if target:
88
+ found.add(target)
89
+ return found
90
+
91
+
92
+ def transitive_reduce(nodes, edges):
93
+ """Drop edges implied by a longer path, via Graphviz `tred`."""
94
+ idx = {n: i for i, n in enumerate(nodes)}
95
+ dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
96
+ try:
97
+ out = subprocess.run(["tred"], input=dot, capture_output=True,
98
+ text=True, check=True).stdout
99
+ except (FileNotFoundError, subprocess.CalledProcessError) as exc:
100
+ sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
101
+ return edges
102
+ rev = {i: n for n, i in idx.items()}
103
+ return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
104
+
105
+
106
+ def main():
107
+ ap = argparse.ArgumentParser(description="Python import graph -> autolayout graph JSON.")
108
+ ap.add_argument("project", help="package or project directory")
109
+ ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
110
+ ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
111
+ ap.add_argument("--group", action="store_true",
112
+ help="group nodes into containers by sub-package")
113
+ ap.add_argument("--no-reduce", action="store_true",
114
+ help="keep every edge (skip transitive reduction)")
115
+ args = ap.parse_args()
116
+
117
+ modules, base = discover(args.project)
118
+ if not modules:
119
+ sys.exit(f"error: no .py modules found under {args.project}")
120
+ edges = sorted({(name, t) for name, path in modules.items()
121
+ for t in edges_of(name, path, modules)})
122
+ raw = len(edges)
123
+ if not args.no_reduce:
124
+ edges = transitive_reduce(list(modules), edges)
125
+ # Drop the shared package prefix from labels for readability; ids stay full.
126
+ strip = base + "." if base else ""
127
+ label = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
128
+
129
+ def node(m):
130
+ d = {"id": m, "label": label(m)}
131
+ if args.group:
132
+ rest = label(m).split(".")
133
+ if len(rest) > 1: # nested under a sub-package
134
+ d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
135
+ return d
136
+
137
+ graph = {
138
+ "direction": args.direction,
139
+ "nodes": [node(m) for m in modules],
140
+ "edges": [{"source": s, "target": t} for s, t in edges],
141
+ }
142
+ text = json.dumps(graph, indent=2)
143
+ if args.output:
144
+ open(args.output, "w", encoding="utf-8").write(text)
145
+ sys.stderr.write(f"wrote {args.output}\n")
146
+ else:
147
+ sys.stdout.write(text)
148
+ note = "" if args.no_reduce else f" (reduced from {raw})"
149
+ sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """Repair truncated IEND chunk in draw.io -e PNG exports (issue #8).
3
+
4
+ draw.io's CLI emits -e PNGs with the 4-byte IEND length field but missing
5
+ the 8 bytes of "IEND" type + CRC. Strict PNG decoders and vision APIs
6
+ (Anthropic included) reject the file with 400 "Could not process image".
7
+ SVG/PDF are unaffected.
8
+
9
+ Usage: python3 repair_png.py <path/to/diagram.drawio.png>
10
+
11
+ Idempotent: the endswith(IEND) guard makes this a no-op once draw.io
12
+ fixes the bug upstream, so it's safe to run unconditionally after every
13
+ -e PNG export.
14
+ """
15
+ import sys
16
+
17
+ IEND = b"\x00\x00\x00\x00IEND\xaeB`\x82"
18
+
19
+
20
+ def repair(path: str) -> bool:
21
+ with open(path, "rb") as f:
22
+ data = f.read()
23
+ if data.endswith(IEND):
24
+ return False
25
+ if data.endswith(b"\x00\x00\x00\x00"):
26
+ data = data[:-4]
27
+ with open(path, "wb") as f:
28
+ f.write(data + IEND)
29
+ return True
30
+
31
+
32
+ if __name__ == "__main__":
33
+ if len(sys.argv) != 2:
34
+ print("usage: repair_png.py <path>", file=sys.stderr)
35
+ sys.exit(2)
36
+ if repair(sys.argv[1]):
37
+ print(f"repaired {sys.argv[1]}")