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.
- drawio/VERSION +1 -0
- drawio/__init__.py +5 -0
- drawio/__main__.py +4 -4
- drawio/cli.py +56 -24
- drawio/document.py +556 -0
- drawio/entities/__init__.py +4 -0
- drawio/entities/base_entity.py +9 -0
- drawio/entities/config.py +1 -2
- drawio/entities/drawio.py +4 -2
- drawio/entities/drawio_app.py +3 -9
- drawio/entities/drawio_browser.py +174 -28
- drawio/entities/drawio_config/abstract_config.py +2 -2
- drawio/entities/drawio_config/drawio_config.py +13 -1
- drawio/entities/drawio_config/label_config.py +5 -0
- drawio/entities/erd/column.py +2 -1
- drawio/entities/erd/erd.py +45 -0
- drawio/entities/erd/table.py +3 -3
- drawio/entities/graph/__init__.py +4 -0
- drawio/entities/graph/edge.py +18 -15
- drawio/entities/graph/graph.py +130 -117
- drawio/entities/graph/node.py +63 -62
- drawio/entities/logger.py +8 -0
- drawio/entities/renderers/__init__.py +7 -0
- drawio/entities/renderers/base_render.py +24 -0
- drawio/entities/renderers/drawio_render.py +16 -0
- drawio/entities/renderers/pdf_render.py +59 -0
- drawio/itergrations/__init__.py +0 -0
- drawio/itergrations/mermaid/__init__.py +0 -0
- drawio/itergrations/mermaid/mermaid_controller.py +108 -0
- drawio_python-0.1.3.dist-info/METADATA +199 -0
- drawio_python-0.1.3.dist-info/RECORD +40 -0
- {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info}/WHEEL +1 -1
- drawio_python-0.1.0.dist-info/METADATA +0 -33
- drawio_python-0.1.0.dist-info/RECORD +0 -28
- {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info}/entry_points.txt +0 -0
- {drawio_python-0.1.0.dist-info → drawio_python-0.1.3.dist-info/licenses}/LICENSE +0 -0
- {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
drawio/__main__.py
CHANGED
drawio/cli.py
CHANGED
|
@@ -1,28 +1,60 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Offline utilities for editable draw.io files."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
4
7
|
|
|
5
|
-
|
|
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():
|
|
13
|
-
""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
""
|
|
28
|
-
|
|
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
|
drawio/entities/__init__.py
CHANGED
drawio/entities/config.py
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
GLOBAL_GRAPH = None
|
|
1
|
+
GLOBAL_GRAPH = None
|