drawio-python 0.1.0__py3-none-any.whl → 0.1.3__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.
Files changed (37) hide show
  1. drawio/VERSION +1 -0
  2. drawio/__init__.py +5 -0
  3. drawio/__main__.py +4 -4
  4. drawio/cli.py +56 -24
  5. drawio/document.py +556 -0
  6. drawio/entities/__init__.py +4 -0
  7. drawio/entities/base_entity.py +9 -0
  8. drawio/entities/config.py +1 -2
  9. drawio/entities/drawio.py +4 -2
  10. drawio/entities/drawio_app.py +3 -9
  11. drawio/entities/drawio_browser.py +174 -28
  12. drawio/entities/drawio_config/abstract_config.py +2 -2
  13. drawio/entities/drawio_config/drawio_config.py +13 -1
  14. drawio/entities/drawio_config/label_config.py +5 -0
  15. drawio/entities/erd/column.py +2 -1
  16. drawio/entities/erd/erd.py +45 -0
  17. drawio/entities/erd/table.py +3 -3
  18. drawio/entities/graph/__init__.py +4 -0
  19. drawio/entities/graph/edge.py +18 -15
  20. drawio/entities/graph/graph.py +130 -117
  21. drawio/entities/graph/node.py +63 -62
  22. drawio/entities/logger.py +8 -0
  23. drawio/entities/renderers/__init__.py +7 -0
  24. drawio/entities/renderers/base_render.py +24 -0
  25. drawio/entities/renderers/drawio_render.py +16 -0
  26. drawio/entities/renderers/pdf_render.py +59 -0
  27. drawio/itergrations/__init__.py +0 -0
  28. drawio/itergrations/mermaid/__init__.py +0 -0
  29. drawio/itergrations/mermaid/mermaid_controller.py +108 -0
  30. drawio_python-0.1.3.dist-info/METADATA +199 -0
  31. drawio_python-0.1.3.dist-info/RECORD +40 -0
  32. {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info}/WHEEL +1 -1
  33. drawio_python-0.1.0.dist-info/METADATA +0 -33
  34. drawio_python-0.1.0.dist-info/RECORD +0 -28
  35. {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info}/entry_points.txt +0 -0
  36. {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info/licenses}/LICENSE +0 -0
  37. {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info}/top_level.txt +0 -0
drawio/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.3
drawio/__init__.py CHANGED
@@ -0,0 +1,5 @@
1
+ from .document import Cell, Document, Page, Theme
2
+ from .entities.graph.graph import Graph
3
+ from .entities.graph.node import Node
4
+
5
+ __all__ = ["Cell", "Document", "Graph", "Node", "Page", "Theme"]
drawio/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
- """Entry point for drawio."""
1
+ """Entry point for python -m drawio."""
2
2
 
3
- from .cli import main # pragma: no cover
3
+ from .cli import main
4
4
 
5
- if __name__ == "__main__": # pragma: no cover
6
- main()
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
drawio/cli.py CHANGED
@@ -1,28 +1,60 @@
1
- """CLI interface for drawio project.
1
+ """Offline utilities for editable draw.io files."""
2
2
 
3
- Be creative! do whatever you want!
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
4
7
 
5
- - Install click or typer and create a CLI app
6
- - Use builtin argparse
7
- - Start a web application
8
- - Import things from your .base module
9
- """
8
+ from drawio.document import Document
10
9
 
11
10
 
12
- def main(): # pragma: no cover
13
- """
14
- The main function executes on commands:
15
- `python -m drawio` and `$ drawio `.
16
-
17
- This is your program's entry point.
18
-
19
- You can change this function to do whatever you want.
20
- Examples:
21
- * Run a test suite
22
- * Run a server
23
- * Do some other stuff
24
- * Run a command line application (Click, Typer, ArgParse)
25
- * List all available tasks
26
- * Run an application (Flask, FastAPI, Django, etc.)
27
- """
28
- print("This will do something")
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)
15
+ commands = parser.add_subparsers(dest="command", required=True)
16
+ validate = commands.add_parser("validate", help="Check IDs, references and parent hierarchy")
17
+ validate.add_argument("input", type=Path)
18
+ validate.add_argument(
19
+ "--check-bounds", action="store_true", help="Also reject children outside containers"
20
+ )
21
+ info = commands.add_parser("info", help="Print page IDs, names and cell counts as JSON")
22
+ info.add_argument("input", type=Path)
23
+ url = commands.add_parser("url", help="Encode file contents in an editor URL (no upload)")
24
+ 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)
30
+ 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:
59
+ print(f"drawio: {exc}", file=sys.stderr)
60
+ return 1
drawio/document.py ADDED
@@ -0,0 +1,556 @@
1
+ """Editable draw.io XML, with no browser or third-party runtime dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import math
7
+ import os
8
+ import re
9
+ import tempfile
10
+ import xml.etree.ElementTree as ET
11
+ import zlib
12
+ from copy import deepcopy
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Mapping, Optional, Union
16
+ from urllib.parse import quote, unquote
17
+
18
+ MAX_XML_BYTES = 10 * 1024 * 1024
19
+ _INVALID_XML = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
20
+
21
+
22
+ def _text(value: str) -> str:
23
+ if not isinstance(value, str):
24
+ raise TypeError("Labels, names and IDs must be strings")
25
+ if _INVALID_XML.search(value):
26
+ raise ValueError("Text contains a character forbidden by XML 1.0")
27
+ return value
28
+
29
+
30
+ def _identifier(value: str) -> str:
31
+ if not _text(value).strip():
32
+ raise ValueError("IDs and page names cannot be empty")
33
+ return value
34
+
35
+
36
+ def _number(value: float, *, positive: bool = False) -> str:
37
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
38
+ raise TypeError("Coordinates and dimensions must be numbers")
39
+ if not math.isfinite(value) or (positive and value <= 0):
40
+ raise ValueError("Coordinates must be finite; dimensions must be positive")
41
+ return str(value)
42
+
43
+
44
+ def _style(defaults: dict, overrides: Optional[Mapping] = None) -> str:
45
+ merged = {**defaults, **(overrides or {})}
46
+ parts = []
47
+ for key, value in merged.items():
48
+ _identifier(key)
49
+ value = str(int(value)) if isinstance(value, bool) else str(value)
50
+ _text(value)
51
+ if any(c in key for c in ";=") or ";" in value:
52
+ raise ValueError("Style keys/values cannot contain style delimiters")
53
+ parts.append(f"{key}={value};")
54
+ return "".join(parts)
55
+
56
+
57
+ def _parse(xml: str) -> ET.Element:
58
+ if re.search(r"<!\s*(?:DOCTYPE|ENTITY)\b", xml, re.IGNORECASE):
59
+ raise ValueError("DTD and entity declarations are not supported")
60
+ try:
61
+ return ET.fromstring(xml)
62
+ except ET.ParseError as exc:
63
+ raise ValueError(f"Invalid draw.io XML: {exc}") from exc
64
+
65
+
66
+ def _inflate(text: str, limit: int) -> str:
67
+ try:
68
+ raw = base64.b64decode("".join(text.split()), validate=True)
69
+ decoder = zlib.decompressobj(wbits=-15)
70
+ data = decoder.decompress(raw, limit + 1)
71
+ if len(data) > limit or decoder.unconsumed_tail:
72
+ raise ValueError("Expanded diagram exceeds the XML size limit")
73
+ if not decoder.eof or decoder.unused_data:
74
+ raise ValueError("Incomplete or trailing compressed diagram data")
75
+ return unquote(data.decode("utf-8"), errors="strict")
76
+ except (zlib.error, UnicodeError, base64.binascii.Error) as exc:
77
+ raise ValueError("Invalid compressed draw.io diagram") from exc
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Theme:
82
+ """Default styles for newly created shapes and connectors."""
83
+
84
+ font_family: str = "Helvetica"
85
+ font_size: int = 18
86
+ fill_color: str = "#ffffff"
87
+ stroke_color: str = "#8091a5"
88
+ text_color: str = "#182b40"
89
+ line_color: str = "#6b7d91"
90
+
91
+
92
+ @dataclass(eq=False, frozen=True)
93
+ class Cell:
94
+ """A shape or connector belonging to one page. Coordinates stay in XML."""
95
+
96
+ id: str
97
+ page: Page
98
+ element: ET.Element
99
+
100
+
101
+ class Page:
102
+ """One draw.io tab; child geometry is relative to its parent container."""
103
+
104
+ def __init__(self, diagram: ET.Element, theme: Theme):
105
+ self.element = diagram
106
+ self.theme = theme
107
+ model = diagram.find("mxGraphModel")
108
+ if model is None or model.find("root") is None:
109
+ raise ValueError("A page must contain mxGraphModel/root")
110
+ self.model = model
111
+ self.root = model.find("root")
112
+
113
+ @property
114
+ def id(self) -> str:
115
+ return self.element.get("id", "")
116
+
117
+ @property
118
+ def name(self) -> str:
119
+ return self.element.get("name", "")
120
+
121
+ @name.setter
122
+ def name(self, value: str) -> None:
123
+ self.element.set("name", _identifier(value))
124
+
125
+ def _index(self) -> dict[str, ET.Element]:
126
+ result = {}
127
+ for entry in self.root:
128
+ # draw.io may wrap mxCell in an object/UserObject with metadata.
129
+ cell = entry if entry.tag == "mxCell" else entry.find("mxCell")
130
+ if cell is None:
131
+ continue
132
+ id = entry.get("id") or cell.get("id")
133
+ if id is None:
134
+ raise ValueError("A cell is missing its ID")
135
+ _identifier(id)
136
+ if id in result:
137
+ raise ValueError(f"Duplicate cell ID: {id}")
138
+ result[id] = cell
139
+ return result
140
+
141
+ @property
142
+ def cells(self) -> tuple[Cell, ...]:
143
+ return tuple(Cell(id, self, el) for id, el in self._index().items())
144
+
145
+ def get(self, id: str) -> Cell:
146
+ try:
147
+ return Cell(id, self, self._index()[id])
148
+ except KeyError as exc:
149
+ raise ValueError(f"Unknown cell ID: {id}") from exc
150
+
151
+ def _resolve(self, ref: Union[Cell, str], *, vertex: bool = True) -> str:
152
+ id = ref.id if isinstance(ref, Cell) else _identifier(ref)
153
+ cell = self.get(id)
154
+ if isinstance(ref, Cell) and (ref.page is not self or ref.element is not cell.element):
155
+ raise ValueError("A cell reference belongs to another page")
156
+ if vertex and cell.element.get("vertex") != "1":
157
+ raise ValueError(f"Expected a shape, not a layer or connector: {id}")
158
+ return id
159
+
160
+ def _new_id(self, id: Optional[str], prefix: str) -> str:
161
+ existing = self._index()
162
+ if id is not None:
163
+ id = _identifier(id)
164
+ if id in existing:
165
+ raise ValueError(f"Duplicate cell ID: {id}")
166
+ return id
167
+ i = 1
168
+ while f"{prefix}{i}" in existing:
169
+ i += 1
170
+ return f"{prefix}{i}"
171
+
172
+ def box(
173
+ self,
174
+ label: str,
175
+ x: float,
176
+ y: float,
177
+ width: float = 200,
178
+ height: float = 90,
179
+ *,
180
+ id: Optional[str] = None,
181
+ parent: Optional[Union[Cell, str]] = None,
182
+ style: Optional[Mapping] = None,
183
+ html: bool = False,
184
+ ) -> Cell:
185
+ """Add a shape. Labels are plain text unless html=True is explicit."""
186
+ id = self._new_id(id, "n")
187
+ parent_id = "1" if parent is None else self._resolve(parent)
188
+ geometry = dict(
189
+ x=_number(x),
190
+ y=_number(y),
191
+ width=_number(width, positive=True),
192
+ height=_number(height, positive=True),
193
+ )
194
+ defaults = {
195
+ "rounded": 0,
196
+ "whiteSpace": "wrap",
197
+ "fontFamily": self.theme.font_family,
198
+ "fontSize": self.theme.font_size,
199
+ "fontColor": self.theme.text_color,
200
+ "fillColor": self.theme.fill_color,
201
+ "strokeColor": self.theme.stroke_color,
202
+ "strokeWidth": 1.5,
203
+ "spacing": 10,
204
+ }
205
+ # Do not let a style mapping silently turn plain text into trusted HTML.
206
+ styles = _style(defaults, {**(style or {}), "html": html})
207
+ element = ET.SubElement(
208
+ self.root,
209
+ "mxCell",
210
+ id=id,
211
+ value=_text(label),
212
+ style=styles,
213
+ vertex="1",
214
+ parent=parent_id,
215
+ )
216
+ ET.SubElement(element, "mxGeometry", **geometry, **{"as": "geometry"})
217
+ return Cell(id, self, element)
218
+
219
+ def container(
220
+ self,
221
+ label: str,
222
+ x: float,
223
+ y: float,
224
+ width: float,
225
+ height: float,
226
+ *,
227
+ style: Optional[Mapping] = None,
228
+ **kwargs,
229
+ ) -> Cell:
230
+ """Add a parent block; its children remain independently editable."""
231
+ styles = {
232
+ "verticalAlign": "top",
233
+ "spacingTop": 16,
234
+ "container": 1,
235
+ "collapsible": 0,
236
+ "recursiveResize": 0,
237
+ "fontStyle": 1,
238
+ **(style or {}),
239
+ }
240
+ return self.box(label, x, y, width, height, style=styles, **kwargs)
241
+
242
+ def text(
243
+ self,
244
+ label: str,
245
+ x: float,
246
+ y: float,
247
+ width: float = 400,
248
+ height: float = 40,
249
+ *,
250
+ style: Optional[Mapping] = None,
251
+ **kwargs,
252
+ ) -> Cell:
253
+ return self.box(
254
+ label,
255
+ x,
256
+ y,
257
+ width,
258
+ height,
259
+ style={
260
+ "fillColor": "none",
261
+ "strokeColor": "none",
262
+ "align": "left",
263
+ "spacing": 0,
264
+ **(style or {}),
265
+ },
266
+ **kwargs,
267
+ )
268
+
269
+ def connect(
270
+ self,
271
+ source: Union[Cell, str],
272
+ target: Union[Cell, str],
273
+ label: str = "",
274
+ *,
275
+ id: Optional[str] = None,
276
+ source_port=None,
277
+ target_port=None,
278
+ waypoints=(),
279
+ dashed: bool = False,
280
+ style: Optional[Mapping] = None,
281
+ ) -> Cell:
282
+ """Add an orthogonal connector; waypoints use page coordinates."""
283
+ source_id, target_id = self._resolve(source), self._resolve(target)
284
+ id = self._new_id(id, "e")
285
+ defaults = {
286
+ "edgeStyle": "orthogonalEdgeStyle",
287
+ "rounded": 0,
288
+ "html": 0,
289
+ "endArrow": "block",
290
+ "endFill": 1,
291
+ "strokeWidth": 2,
292
+ "fontFamily": self.theme.font_family,
293
+ "fontSize": self.theme.font_size,
294
+ "fontColor": self.theme.text_color,
295
+ "strokeColor": self.theme.line_color,
296
+ "labelBackgroundColor": "#ffffff",
297
+ "dashed": dashed,
298
+ }
299
+ for prefix, port in (("exit", source_port), ("entry", target_port)):
300
+ if port is not None:
301
+ if len(port) != 2:
302
+ raise ValueError("A port is an (x, y) pair in [0, 1]")
303
+ for axis, value in zip(("X", "Y"), port):
304
+ _number(value)
305
+ if not 0 <= value <= 1:
306
+ raise ValueError("Port coordinates must lie in [0, 1]")
307
+ defaults[prefix + axis] = value
308
+ points = []
309
+ for point in waypoints:
310
+ if len(point) != 2:
311
+ raise ValueError("A waypoint is an (x, y) pair")
312
+ points.append((_number(point[0]), _number(point[1])))
313
+ styles = _style(defaults, {**(style or {}), "html": 0})
314
+ element = ET.SubElement(
315
+ self.root,
316
+ "mxCell",
317
+ id=id,
318
+ value=_text(label),
319
+ style=styles,
320
+ edge="1",
321
+ parent="1",
322
+ source=source_id,
323
+ target=target_id,
324
+ )
325
+ geometry = ET.SubElement(element, "mxGeometry", relative="1", **{"as": "geometry"})
326
+ if points:
327
+ array = ET.SubElement(geometry, "Array", **{"as": "points"})
328
+ for x, y in points:
329
+ ET.SubElement(array, "mxPoint", x=x, y=y)
330
+ return Cell(id, self, element)
331
+
332
+ def validate(self, *, check_bounds: bool = False) -> None:
333
+ """Check IDs, references and parent cycles; optionally check child bounds."""
334
+ _identifier(self.id)
335
+ _identifier(self.name)
336
+ index = self._index()
337
+ if "0" not in index or "1" not in index:
338
+ raise ValueError("A page requires root cell 0 and default layer 1")
339
+ if index["0"].get("parent") is not None or index["1"].get("parent") != "0":
340
+ raise ValueError("Invalid root/default layer hierarchy")
341
+ for id, cell in index.items():
342
+ parent = cell.get("parent")
343
+ if id != "0" and parent is None:
344
+ raise ValueError(f"Cell {id} is missing a parent")
345
+ for attr in ("parent", "source", "target"):
346
+ ref = cell.get(attr)
347
+ if ref is not None and ref not in index:
348
+ raise ValueError(f"Cell {id} has an unknown {attr}: {ref}")
349
+ if attr != "parent" and ref is not None and index[ref].get("vertex") != "1":
350
+ raise ValueError(f"Connector {id} references a non-shape: {ref}")
351
+ seen = {id}
352
+ while parent is not None:
353
+ if parent not in index:
354
+ raise ValueError(f"Cell {id} has an unknown ancestor: {parent}")
355
+ if parent in seen:
356
+ raise ValueError(f"Parent cycle at cell {id}")
357
+ seen.add(parent)
358
+ parent = index[parent].get("parent")
359
+ geometry = cell.find("mxGeometry")
360
+ if cell.get("edge") == "1":
361
+ for endpoint in ("source", "target"):
362
+ point = (
363
+ None
364
+ if geometry is None
365
+ else geometry.find(f"mxPoint[@as='{endpoint}Point']")
366
+ )
367
+ if cell.get(endpoint) is None and point is None:
368
+ raise ValueError(f"Connector {id} is missing its {endpoint} endpoint")
369
+ if geometry is not None:
370
+ for attr in ("x", "y", "width", "height"):
371
+ if attr in geometry.attrib:
372
+ try:
373
+ number = float(geometry.get(attr))
374
+ except ValueError as exc:
375
+ raise ValueError(f"Invalid geometry on {id}") from exc
376
+ if not math.isfinite(number) or (
377
+ attr in ("width", "height") and number < 0
378
+ ):
379
+ raise ValueError(f"Invalid geometry on {id}")
380
+ if check_bounds and cell.get("vertex") == "1" and cell.get("parent") not in ("0", "1"):
381
+ parent_geometry = index[cell.get("parent")].find("mxGeometry")
382
+ if geometry is None or parent_geometry is None or geometry.get("relative") == "1":
383
+ continue
384
+ for axis, dimension in (("x", "width"), ("y", "height")):
385
+ start = float(geometry.get(axis, "0"))
386
+ end = start + float(geometry.get(dimension, "0"))
387
+ if start < 0 or end > float(parent_geometry.get(dimension, "0")):
388
+ raise ValueError(f"Child {id} extends outside its parent")
389
+
390
+ def to_xml(self) -> str:
391
+ """Return mxGraphModel XML for Extras > Edit Diagram in draw.io."""
392
+ self.validate()
393
+ return ET.tostring(self.model, encoding="unicode")
394
+
395
+
396
+ class Document:
397
+ """A multi-page .drawio file. Existing page XML and metadata are retained."""
398
+
399
+ def __init__(self, *, theme: Optional[Theme] = None):
400
+ self.theme = theme or Theme()
401
+ self.element = ET.Element("mxfile", host="app.diagrams.net", type="device")
402
+ self._pages: list[Page] = []
403
+
404
+ @property
405
+ def pages(self) -> tuple[Page, ...]:
406
+ return tuple(self._pages)
407
+
408
+ def add_page(
409
+ self,
410
+ name: str,
411
+ *,
412
+ id: Optional[str] = None,
413
+ width: float = 1600,
414
+ height: float = 1000,
415
+ background: str = "#ffffff",
416
+ ) -> Page:
417
+ _identifier(name)
418
+ ids = {p.id for p in self.pages}
419
+ if id is None:
420
+ i = 1
421
+ while f"page-{i}" in ids:
422
+ i += 1
423
+ id = f"page-{i}"
424
+ _identifier(id)
425
+ if id in ids:
426
+ raise ValueError(f"Duplicate page ID: {id}")
427
+ width, height = _number(width, positive=True), _number(height, positive=True)
428
+ background = _text(background)
429
+ diagram = ET.SubElement(self.element, "diagram", id=id, name=name)
430
+ model = ET.SubElement(
431
+ diagram,
432
+ "mxGraphModel",
433
+ dx=width,
434
+ dy=height,
435
+ grid="0",
436
+ gridSize="10",
437
+ guides="1",
438
+ tooltips="1",
439
+ connect="1",
440
+ arrows="1",
441
+ fold="1",
442
+ page="0",
443
+ pageScale="1",
444
+ pageWidth=width,
445
+ pageHeight=height,
446
+ math="0",
447
+ shadow="0",
448
+ background=background,
449
+ )
450
+ root = ET.SubElement(model, "root")
451
+ ET.SubElement(root, "mxCell", id="0")
452
+ ET.SubElement(root, "mxCell", id="1", parent="0")
453
+ page = Page(diagram, self.theme)
454
+ self._pages.append(page)
455
+ return page
456
+
457
+ def get_page(self, id: str) -> Page:
458
+ for page in self.pages:
459
+ if page.id == id:
460
+ return page
461
+ raise ValueError(f"Unknown page ID: {id}")
462
+
463
+ def validate(self, *, check_bounds: bool = False) -> None:
464
+ if not self.pages:
465
+ raise ValueError("A document must contain at least one page")
466
+ if len({p.id for p in self.pages}) != len(self.pages):
467
+ raise ValueError("Duplicate page ID")
468
+ for page in self.pages:
469
+ page.validate(check_bounds=check_bounds)
470
+
471
+ def to_xml(self, *, pretty: bool = True) -> str:
472
+ self.validate()
473
+ element = deepcopy(self.element)
474
+ if pretty:
475
+ ET.indent(element, space=" ")
476
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(element, encoding="unicode")
477
+
478
+ def save(self, path, *, overwrite: bool = False) -> Path:
479
+ """Atomically save; refuse to replace a file unless overwrite=True."""
480
+ path = Path(path).expanduser()
481
+ xml = self.to_xml()
482
+ temporary = None
483
+ try:
484
+ with tempfile.NamedTemporaryFile(
485
+ mode="w", encoding="utf-8", dir=path.parent, delete=False
486
+ ) as stream:
487
+ temporary = Path(stream.name)
488
+ stream.write(xml)
489
+ if overwrite:
490
+ os.replace(temporary, path)
491
+ else:
492
+ os.link(temporary, path) # Exclusive creation; no exists/check race.
493
+ finally:
494
+ if temporary is not None:
495
+ temporary.unlink(missing_ok=True)
496
+ return path
497
+
498
+ def to_url(self) -> str:
499
+ """Encode the complete document in a draw.io editor URL; no network I/O."""
500
+ data = quote(self.to_xml(pretty=False), safe="~()*!.'-").encode("utf-8")
501
+ compressor = zlib.compressobj(wbits=-15)
502
+ compressed = compressor.compress(data) + compressor.flush()
503
+ return "https://app.diagrams.net/?splash=0#R" + base64.b64encode(compressed).decode("ascii")
504
+
505
+ @classmethod
506
+ def load(cls, path, *, max_bytes: int = MAX_XML_BYTES) -> Document:
507
+ if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
508
+ raise ValueError("max_bytes must be a positive integer")
509
+ with Path(path).expanduser().open("rb") as stream:
510
+ data = stream.read(max_bytes + 1)
511
+ return cls.from_xml(data, max_bytes=max_bytes)
512
+
513
+ @classmethod
514
+ def from_xml(cls, xml: Union[str, bytes], *, max_bytes: int = MAX_XML_BYTES) -> Document:
515
+ """Read UTF-8 mxfile, diagram or mxGraphModel XML, including compressed pages."""
516
+ if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
517
+ raise ValueError("max_bytes must be a positive integer")
518
+ if isinstance(xml, bytes):
519
+ if len(xml) > max_bytes:
520
+ raise ValueError("XML exceeds the size limit")
521
+ xml = xml.decode("utf-8-sig")
522
+ if len(xml.encode("utf-8")) > max_bytes:
523
+ raise ValueError("XML exceeds the size limit")
524
+ element = _parse(xml)
525
+ document = cls()
526
+ if element.tag == "mxGraphModel":
527
+ page = document.add_page("Page 1")
528
+ page.element.remove(page.model)
529
+ page.element.append(element)
530
+ document._pages = [Page(page.element, document.theme)]
531
+ else:
532
+ if element.tag == "diagram":
533
+ document.element.append(element)
534
+ elif element.tag == "mxfile":
535
+ document.element = element
536
+ else:
537
+ raise ValueError("Expected mxfile, diagram or mxGraphModel")
538
+ remaining = max_bytes
539
+ for diagram in document.element.findall("diagram"):
540
+ model = diagram.find("mxGraphModel")
541
+ if model is None:
542
+ encoded = (diagram.text or "").strip()
543
+ if not encoded:
544
+ raise ValueError("Empty diagram without mxGraphModel")
545
+ decoded = encoded if encoded.startswith("<") else _inflate(encoded, remaining)
546
+ model = _parse(decoded)
547
+ if model.tag != "mxGraphModel":
548
+ raise ValueError("A diagram must contain mxGraphModel")
549
+ diagram.text = None
550
+ diagram.append(model)
551
+ remaining -= len(ET.tostring(model, encoding="utf-8"))
552
+ if remaining < 0:
553
+ raise ValueError("Expanded diagrams exceed the XML size limit")
554
+ document._pages.append(Page(diagram, document.theme))
555
+ document.validate()
556
+ return document
@@ -0,0 +1,4 @@
1
+ from .graph.graph import Graph
2
+ from .graph.node import Node
3
+
4
+ __all__ = ["Graph", "Node"]
@@ -0,0 +1,9 @@
1
+ from drawio.entities.logger import DrawioLogger
2
+
3
+
4
+ class BaseEntity:
5
+ def __init__(self):
6
+ self.logger = DrawioLogger("drawio")
7
+
8
+ def log(self, message):
9
+ self.logger.debug(message)
drawio/entities/config.py CHANGED
@@ -1,2 +1 @@
1
-
2
- GLOBAL_GRAPH = None
1
+ GLOBAL_GRAPH = None