drawio-python 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl

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.
drawio/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.3
1
+ 0.1.4
drawio/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
- from .document import Cell, Document, Page, Theme
1
+ from .document import Cell, Document, Page, Theme, parse_style
2
2
  from .entities.graph.graph import Graph
3
3
  from .entities.graph.node import Node
4
4
 
5
- __all__ = ["Cell", "Document", "Graph", "Node", "Page", "Theme"]
5
+ __all__ = ["Cell", "Document", "Graph", "Node", "Page", "Theme", "parse_style"]
drawio/cli.py CHANGED
@@ -1,4 +1,8 @@
1
- """Offline utilities for editable draw.io files."""
1
+ """Offline utilities for editable draw.io files.
2
+
3
+ Every command that reports something accepts --json, so an agent can read the result
4
+ without parsing prose. Exit status: 0 success, 1 failure or findings, 2 bad usage.
5
+ """
2
6
 
3
7
  import argparse
4
8
  import json
@@ -7,54 +11,389 @@ from pathlib import Path
7
11
 
8
12
  from drawio.document import Document
9
13
 
14
+ _VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip()
10
15
 
11
- def main(argv=None):
12
- parser = argparse.ArgumentParser(description="Generate and inspect native draw.io XML")
13
- version = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip()
14
- parser.add_argument("--version", action="version", version=version)
16
+
17
+ def _emit(data, as_json: bool, text: str) -> None:
18
+ print(json.dumps(data, indent=2, ensure_ascii=False) if as_json else text)
19
+
20
+
21
+ def _pages_info(document):
22
+ return [
23
+ {
24
+ "id": page.id,
25
+ "name": page.name,
26
+ "shapes": sum(c.element.get("vertex") == "1" for c in page.cells),
27
+ "connectors": sum(c.element.get("edge") == "1" for c in page.cells),
28
+ }
29
+ for page in document.pages
30
+ ]
31
+
32
+
33
+ def _cmd_new(args):
34
+ document = Document()
35
+ document.add_page(args.name)
36
+ print(document.save(args.output, overwrite=args.overwrite))
37
+ return 0
38
+
39
+
40
+ def _cmd_validate(args):
41
+ document = Document.load(args.input)
42
+ document.validate(check_bounds=args.check_bounds)
43
+ _emit(
44
+ {"valid": True, "pages": len(document.pages)},
45
+ args.json,
46
+ f"Valid: {len(document.pages)} page(s)",
47
+ )
48
+ return 0
49
+
50
+
51
+ def _cmd_info(args):
52
+ print(json.dumps(_pages_info(Document.load(args.input)), indent=2, ensure_ascii=False))
53
+ return 0
54
+
55
+
56
+ def _cmd_url(args):
57
+ print(Document.load(args.input).to_url())
58
+ return 0
59
+
60
+
61
+ def _cmd_render(args):
62
+ from drawio.render import render, render_all
63
+
64
+ source = Path(args.input)
65
+ options = dict(
66
+ scale=args.scale,
67
+ border=args.border,
68
+ backend=args.backend,
69
+ aspect=args.aspect,
70
+ transparent=args.transparent,
71
+ download=not args.offline,
72
+ )
73
+ if args.all_pages:
74
+ fmt = (args.format or "png").lower()
75
+ folder = Path(args.output) if args.output else source.with_suffix("")
76
+ results = render_all(source, folder, fmt=fmt, **options)
77
+ else:
78
+ output = args.output or str(source.with_suffix("." + (args.format or "png").lower()))
79
+ results = [render(source, output, page=args.page, fmt=args.format, **options)]
80
+ data = [
81
+ {"output": str(r.path), "backend": r.backend, "page": r.page, "notes": r.notes}
82
+ for r in results
83
+ ]
84
+ lines = []
85
+ for r in results:
86
+ lines.append(f"{r.path} (page {r.page}, {r.backend} backend)")
87
+ lines += [f" note: {n}" for n in r.notes]
88
+ _emit(data, args.json, "\n".join(lines))
89
+ return 0
90
+
91
+
92
+ def _cmd_lint(args):
93
+ from drawio.lint import SEVERITIES, check, parse_fit
94
+
95
+ document = Document.load(args.input)
96
+ document.validate()
97
+ fit = parse_fit(args.fit, args.min_pt) if args.fit else None
98
+ issues = check(document, fit=fit, pages=args.page or None)
99
+ threshold = SEVERITIES.index(args.fail_on)
100
+ failing = [i for i in issues if SEVERITIES.index(i.severity) <= threshold]
101
+ counts = {s: sum(i.severity == s for i in issues) for s in SEVERITIES}
102
+ if args.json:
103
+ print(
104
+ json.dumps(
105
+ {"file": args.input, "summary": counts, "issues": [i.to_dict() for i in issues]},
106
+ indent=2,
107
+ ensure_ascii=False,
108
+ )
109
+ )
110
+ else:
111
+ for issue in issues:
112
+ print(f"{issue.severity}: [{issue.code}] page {issue.page}: {issue.message}")
113
+ if issue.hint:
114
+ print(f" fix: {issue.hint}")
115
+ summary = ", ".join(f"{n} {s}(s)" for s, n in counts.items() if n)
116
+ print(f"{args.input}: {summary or 'no layout problems found'}")
117
+ return 1 if failing else 0
118
+
119
+
120
+ def _cmd_inspect(args):
121
+ from drawio.scene import build
122
+
123
+ document = Document.load(args.input)
124
+ pages = []
125
+ for page in document.pages:
126
+ if args.page and args.page not in (page.id, page.name):
127
+ continue
128
+ scene = build(page)
129
+ shapes = [
130
+ {
131
+ "id": s.id,
132
+ "label": s.label.text if s.label else "",
133
+ "kind": s.kind,
134
+ "box": [round(v, 1) for v in s.box],
135
+ "parent": s.parent if s.parent not in ("0", "1") else None,
136
+ "container": s.container,
137
+ }
138
+ for s in scene.shapes
139
+ ]
140
+ connectors = [
141
+ {
142
+ "id": e.id,
143
+ "source": e.source,
144
+ "target": e.target,
145
+ "label": " / ".join(label.text for label in e.labels),
146
+ "route": [[round(x, 1), round(y, 1)] for x, y in e.points],
147
+ }
148
+ for e in scene.connectors
149
+ ]
150
+ bounds = [round(v, 1) for v in scene.bounds()]
151
+ pages.append(
152
+ {
153
+ "id": page.id,
154
+ "name": page.name,
155
+ "bounds": bounds,
156
+ "shapes": shapes,
157
+ "connectors": connectors,
158
+ }
159
+ )
160
+ if args.json:
161
+ print(json.dumps(pages, indent=2, ensure_ascii=False))
162
+ return 0
163
+ for page in pages:
164
+ print(f"page {page['id']} {page['name']!r} bounds x,y,w,h={page['bounds']}")
165
+ for s in page["shapes"]:
166
+ parent = f" in {s['parent']}" if s["parent"] else ""
167
+ label = " ".join(s["label"].split())
168
+ print(f" shape {s['id']:<12} {s['kind']:<10} {s['box']}{parent} {label[:60]!r}")
169
+ for e in page["connectors"]:
170
+ label = f" {e['label']!r}" if e["label"] else ""
171
+ print(f" edge {e['id']:<12} {e['source']} -> {e['target']}{label}")
172
+ return 0
173
+
174
+
175
+ def _write(document, args) -> Path:
176
+ target = args.output or args.input
177
+ return document.save(target, overwrite=args.output is None or args.overwrite)
178
+
179
+
180
+ def _cmd_fit(args):
181
+ document = Document.load(args.input)
182
+ resized = []
183
+ for page in document.pages:
184
+ if args.autosize:
185
+ from drawio.lint import check_scene
186
+ from drawio.scene import build
187
+
188
+ for issue in check_scene(build(page)):
189
+ if issue.code == "text-overflow":
190
+ page.autosize(issue.cells[0], max_width=args.max_width)
191
+ resized.append(issue.cells[0])
192
+ page.fit(padding=args.padding)
193
+ path = _write(document, args)
194
+ _emit({"output": str(path), "autosized": resized}, args.json, f"{path}")
195
+ return 0
196
+
197
+
198
+ def _cmd_layout(args):
199
+ from drawio.layout import layered
200
+
201
+ document = Document.load(args.input)
202
+ pages = [p for p in document.pages if not args.page or args.page in (p.id, p.name)]
203
+ if not pages:
204
+ raise ValueError(f"No page with ID or name {args.page!r}")
205
+ for page in pages:
206
+ layered(page, direction=args.direction, rank_gap=args.rank_gap, gap=args.gap)
207
+ page.fit()
208
+ path = _write(document, args)
209
+ _emit({"output": str(path), "pages": [p.id for p in pages]}, args.json, f"{path}")
210
+ return 0
211
+
212
+
213
+ def _cmd_merge(args):
214
+ merged = Document()
215
+ for name in args.inputs:
216
+ for page in Document.load(name).pages:
217
+ merged.import_page(page)
218
+ path = merged.save(args.output, overwrite=args.overwrite)
219
+ _emit({"output": str(path), "pages": _pages_info(merged)}, args.json, f"{path}")
220
+ return 0
221
+
222
+
223
+ def _cmd_shapes(args):
224
+ from drawio.shapes import ARROWS, PALETTE, SHAPES
225
+
226
+ data = {"shapes": SHAPES, "colors": PALETTE, "arrows": list(ARROWS)}
227
+ text = [
228
+ "shapes (page.box(..., shape=NAME)): " + ", ".join(SHAPES),
229
+ "colors (style=drawio.shapes.color(NAME)): " + ", ".join(PALETTE),
230
+ "arrows (page.connect(..., arrow=NAME)): " + ", ".join(ARROWS),
231
+ ]
232
+ _emit(data, args.json, "\n".join(text))
233
+ return 0
234
+
235
+
236
+ def _cmd_doctor(args):
237
+ from drawio.doctor import diagnose
238
+
239
+ report = diagnose()
240
+ if args.json:
241
+ print(json.dumps(report, indent=2, ensure_ascii=False))
242
+ return 0
243
+ for item in report["checks"]:
244
+ mark = "ok " if item["ok"] else "-- "
245
+ print(f"{mark} {item['name']}: {item['detail']}")
246
+ if not item["ok"] and item.get("fix"):
247
+ print(f" fix: {item['fix']}")
248
+ print("render backends available: " + ", ".join(report["backends"]))
249
+ return 0
250
+
251
+
252
+ def _cmd_skill(args):
253
+ from drawio.skill import install, skill_dir
254
+
255
+ if args.action == "path":
256
+ print(skill_dir())
257
+ return 0
258
+ if args.action == "show":
259
+ print((skill_dir() / "SKILL.md").read_text(encoding="utf-8"))
260
+ return 0
261
+ written = install(args.agent, dest=args.dest, force=args.force)
262
+ _emit([str(p) for p in written], args.json, "\n".join(f"installed {p}" for p in written))
263
+ return 0
264
+
265
+
266
+ def _parser():
267
+ parser = argparse.ArgumentParser(
268
+ prog="drawio",
269
+ description="Build, check and export editable draw.io diagrams. "
270
+ "Run `drawio skill show` for the agent workflow.",
271
+ )
272
+ parser.add_argument("--version", action="version", version=_VERSION)
15
273
  commands = parser.add_subparsers(dest="command", required=True)
16
- validate = commands.add_parser("validate", help="Check IDs, references and parent hierarchy")
274
+
275
+ def add(name, func, help):
276
+ sub = commands.add_parser(name, help=help, description=help)
277
+ sub.set_defaults(func=func)
278
+ return sub
279
+
280
+ def json_flag(sub):
281
+ sub.add_argument("--json", action="store_true", help="Print machine-readable JSON")
282
+
283
+ def output_flags(sub):
284
+ sub.add_argument("-o", "--output", help="Write here instead of updating the input")
285
+ sub.add_argument("--overwrite", action="store_true", help="Replace an existing output")
286
+
287
+ new = add("new", _cmd_new, "Create an empty one-page document")
288
+ new.add_argument("output", type=Path)
289
+ new.add_argument("--name", default="Overview")
290
+ new.add_argument("--overwrite", action="store_true")
291
+
292
+ validate = add("validate", _cmd_validate, "Check IDs, references and parent hierarchy")
17
293
  validate.add_argument("input", type=Path)
18
294
  validate.add_argument(
19
295
  "--check-bounds", action="store_true", help="Also reject children outside containers"
20
296
  )
21
- info = commands.add_parser("info", help="Print page IDs, names and cell counts as JSON")
297
+ json_flag(validate)
298
+
299
+ info = add("info", _cmd_info, "Print page IDs, names and cell counts as JSON")
22
300
  info.add_argument("input", type=Path)
23
- url = commands.add_parser("url", help="Encode file contents in an editor URL (no upload)")
301
+
302
+ url = add("url", _cmd_url, "Encode file contents in an editor URL (no upload)")
24
303
  url.add_argument("input", type=Path)
25
- new = commands.add_parser("new", help="Create an empty one-page document")
26
- new.add_argument("output", type=Path)
27
- new.add_argument("--name", default="Overview")
28
- new.add_argument("--overwrite", action="store_true")
29
- args = parser.parse_args(argv)
304
+
305
+ render = add("render", _cmd_render, "Export a page to PNG, SVG, PDF or JPEG")
306
+ render.add_argument("input")
307
+ render.add_argument("-o", "--output", help="Output file (or folder with --all-pages)")
308
+ render.add_argument("-f", "--format", choices=["png", "svg", "pdf", "jpg", "jpeg"])
309
+ render.add_argument("-p", "--page", help="Page index (0-based), ID or name; default first")
310
+ render.add_argument("--all-pages", action="store_true", help="One file per page")
311
+ render.add_argument("-s", "--scale", type=float, default=1)
312
+ render.add_argument("--border", type=int, default=10)
313
+ render.add_argument(
314
+ "--backend",
315
+ choices=["auto", "native", "desktop", "browser"],
316
+ default="auto",
317
+ help="auto: draw.io desktop if installed, else native (default)",
318
+ )
319
+ render.add_argument("--aspect", help="Pad the canvas to a ratio such as 16:9 (native)")
320
+ render.add_argument("--transparent", action="store_true", help="No background (PNG)")
321
+ render.add_argument(
322
+ "--offline", action="store_true", help="Never download the diagrams.net viewer"
323
+ )
324
+ json_flag(render)
325
+
326
+ lint = add("lint", _cmd_lint, "Find overflowing text, overlaps and crossing connectors")
327
+ lint.add_argument("input")
328
+ lint.add_argument("-p", "--page", action="append", help="Only these page IDs or names")
329
+ lint.add_argument(
330
+ "--fit",
331
+ help="Size the picture will be shown at, e.g. 8.15x4.85in, 16cm, 1200px; reports text"
332
+ " smaller than --min-pt",
333
+ )
334
+ lint.add_argument("--min-pt", type=float, default=12)
335
+ lint.add_argument(
336
+ "--fail-on",
337
+ choices=["error", "warning", "info"],
338
+ default="warning",
339
+ help="Lowest severity that makes the exit status 1 (default warning)",
340
+ )
341
+ json_flag(lint)
342
+
343
+ inspect = add("inspect", _cmd_inspect, "List shapes and connectors with page coordinates")
344
+ inspect.add_argument("input")
345
+ inspect.add_argument("-p", "--page", help="Only this page ID or name")
346
+ json_flag(inspect)
347
+
348
+ fit = add("fit", _cmd_fit, "Grow containers and the page to fit their contents")
349
+ fit.add_argument("input")
350
+ output_flags(fit)
351
+ fit.add_argument("--padding", type=float, default=20)
352
+ fit.add_argument(
353
+ "--autosize", action="store_true", help="Also grow shapes whose text overflows"
354
+ )
355
+ fit.add_argument("--max-width", type=float, help="Widest a shape may grow with --autosize")
356
+ json_flag(fit)
357
+
358
+ layout = add("layout", _cmd_layout, "Arrange top-level shapes in ranks along connectors")
359
+ layout.add_argument("input")
360
+ output_flags(layout)
361
+ layout.add_argument("-p", "--page", help="Only this page ID or name")
362
+ layout.add_argument("--direction", choices=["down", "up", "right", "left"], default="down")
363
+ layout.add_argument("--rank-gap", type=float, default=80)
364
+ layout.add_argument("--gap", type=float, default=40)
365
+ json_flag(layout)
366
+
367
+ merge = add("merge", _cmd_merge, "Combine the pages of several files into one")
368
+ merge.add_argument("output")
369
+ merge.add_argument("inputs", nargs="+")
370
+ merge.add_argument("--overwrite", action="store_true")
371
+ json_flag(merge)
372
+
373
+ shapes = add("shapes", _cmd_shapes, "List shape names, colour presets and arrow ends")
374
+ json_flag(shapes)
375
+
376
+ doctor = add("doctor", _cmd_doctor, "Report which render backends and fonts are available")
377
+ json_flag(doctor)
378
+
379
+ skill = add("skill", _cmd_skill, "Install or print the agent skill shipped with drawio")
380
+ skill.add_argument("action", choices=["install", "show", "path"])
381
+ skill.add_argument(
382
+ "--agent",
383
+ choices=["claude", "codex", "agents", "all"],
384
+ default="all",
385
+ help="Where to install: ~/.claude/skills, ~/.codex/skills, ~/.agents/skills",
386
+ )
387
+ skill.add_argument("--dest", help="Install into this skills folder instead")
388
+ skill.add_argument("--force", action="store_true", help="Replace an installed copy")
389
+ json_flag(skill)
390
+ return parser
391
+
392
+
393
+ def main(argv=None):
394
+ args = _parser().parse_args(argv)
30
395
  try:
31
- if args.command == "new":
32
- document = Document()
33
- document.add_page(args.name)
34
- print(document.save(args.output, overwrite=args.overwrite))
35
- return 0
36
- document = Document.load(args.input)
37
- if args.command == "validate":
38
- document.validate(check_bounds=args.check_bounds)
39
- print(f"Valid: {len(document.pages)} page(s)")
40
- elif args.command == "info":
41
- print(
42
- json.dumps(
43
- [
44
- {
45
- "id": page.id,
46
- "name": page.name,
47
- "shapes": sum(c.element.get("vertex") == "1" for c in page.cells),
48
- "connectors": sum(c.element.get("edge") == "1" for c in page.cells),
49
- }
50
- for page in document.pages
51
- ],
52
- indent=2,
53
- )
54
- )
55
- elif args.command == "url":
56
- print(document.to_url())
57
- return 0
58
- except (OSError, ValueError, TypeError) as exc:
396
+ return args.func(args)
397
+ except (OSError, ValueError, TypeError, RuntimeError) as exc:
59
398
  print(f"drawio: {exc}", file=sys.stderr)
60
399
  return 1
drawio/doctor.py ADDED
@@ -0,0 +1,144 @@
1
+ """What this machine can do with drawio-python, and how to enable what is missing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _CHROME = {
13
+ "darwin": [
14
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
15
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
16
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
17
+ ],
18
+ "win32": [
19
+ os.path.join(
20
+ os.environ.get("ProgramFiles", r"C:\Program Files"),
21
+ r"Google\Chrome\Application\chrome.exe",
22
+ ),
23
+ os.path.join(
24
+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
25
+ r"Microsoft\Edge\Application\msedge.exe",
26
+ ),
27
+ ],
28
+ }
29
+
30
+
31
+ def _has(module: str) -> bool:
32
+ return importlib.util.find_spec(module) is not None
33
+
34
+
35
+ def _system_browser():
36
+ env = os.environ.get("DRAWIO_CHROME")
37
+ if env:
38
+ return env
39
+ key = "win32" if sys.platform.startswith("win") else sys.platform
40
+ for path in _CHROME.get(key, []):
41
+ if Path(path).is_file():
42
+ return path
43
+ for name in (
44
+ "google-chrome",
45
+ "google-chrome-stable",
46
+ "chromium",
47
+ "chromium-browser",
48
+ "microsoft-edge",
49
+ "msedge",
50
+ "chrome",
51
+ ):
52
+ found = shutil.which(name)
53
+ if found:
54
+ return found
55
+ return None
56
+
57
+
58
+ def _playwright_chromium():
59
+ if not _has("playwright"):
60
+ return None
61
+ try:
62
+ from playwright.sync_api import sync_playwright
63
+
64
+ with sync_playwright() as p:
65
+ path = p.chromium.executable_path
66
+ return path if path and Path(path).exists() else None
67
+ except Exception:
68
+ return None
69
+
70
+
71
+ def diagnose() -> dict:
72
+ from . import __file__ as package_file
73
+ from .render import browser, desktop
74
+ from .render.fonts import font_path
75
+
76
+ version = Path(package_file).with_name("VERSION").read_text(encoding="utf-8").strip()
77
+ pillow = _has("PIL")
78
+ vector_pdf = _has("cairosvg") or bool(shutil.which("rsvg-convert"))
79
+ desktop_path = desktop.find()
80
+ has_playwright = _has("playwright")
81
+ chromium = _playwright_chromium() if has_playwright else None
82
+ system_browser = _system_browser()
83
+ viewer = browser.viewer_script(download=False)
84
+ font = font_path("Helvetica") if pillow else None
85
+ checks = [
86
+ {
87
+ "name": "drawio-python",
88
+ "ok": True,
89
+ "detail": f"{version} at {Path(package_file).parent} (Python {platform.python_version()})",
90
+ },
91
+ {"name": "native SVG", "ok": True, "detail": "always available, no dependencies"},
92
+ {
93
+ "name": "Pillow (PNG, JPEG, PDF)",
94
+ "ok": pillow,
95
+ "detail": "installed" if pillow else "missing",
96
+ "fix": "pip install 'drawio-python[render]'",
97
+ },
98
+ {
99
+ "name": "vector PDF converter",
100
+ "ok": vector_pdf,
101
+ "detail": "cairosvg or rsvg-convert found"
102
+ if vector_pdf
103
+ else "none; native PDF output is a raster image",
104
+ "fix": "pip install cairosvg, or install librsvg (rsvg-convert)",
105
+ },
106
+ {
107
+ "name": "raster font",
108
+ "ok": bool(font),
109
+ "detail": font or "Pillow's built-in font",
110
+ "fix": "install Arial, Liberation Sans, DejaVu Sans or Noto Sans, or set DRAWIO_FONT",
111
+ },
112
+ {
113
+ "name": "draw.io desktop",
114
+ "ok": bool(desktop_path),
115
+ "detail": desktop_path or "not found",
116
+ "fix": "https://github.com/jgraph/drawio-desktop/releases (or set DRAWIO_DESKTOP)",
117
+ },
118
+ {
119
+ "name": "Playwright",
120
+ "ok": has_playwright,
121
+ "detail": "installed" if has_playwright else "missing",
122
+ "fix": "pip install 'drawio-python[browser-render]'",
123
+ },
124
+ {
125
+ "name": "browser for --backend browser",
126
+ "ok": bool(chromium or system_browser),
127
+ "detail": chromium or system_browser or "none",
128
+ "fix": "playwright install chromium, or install Chrome/Edge, or set DRAWIO_CHROME",
129
+ },
130
+ {
131
+ "name": "diagrams.net viewer script",
132
+ "ok": bool(viewer),
133
+ "detail": str(viewer) if viewer else "not cached; downloaded on first browser render",
134
+ "fix": f"allow one download from {browser.VIEWER_URL} or set DRAWIO_VIEWER_JS",
135
+ },
136
+ ]
137
+ backends = ["native (svg)"]
138
+ if pillow:
139
+ backends.append("native (png, jpg, pdf)")
140
+ if desktop_path:
141
+ backends.append("desktop")
142
+ if has_playwright and (chromium or system_browser):
143
+ backends.append("browser")
144
+ return {"version": version, "checks": checks, "backends": backends}