@taloswork/ose-shared 0.1.8 → 0.1.9

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 (155) hide show
  1. package/package.json +3 -3
  2. package/skills/cozy-static-svg/SKILL.md +76 -0
  3. package/skills/cozy-static-svg/references/approved-preview.png +0 -0
  4. package/skills/cozy-static-svg/references/cross-platform.md +73 -0
  5. package/skills/cozy-static-svg/references/moon_keeps_watch.svg +229 -0
  6. package/skills/cozy-static-svg/references/style-anchor.md +271 -0
  7. package/skills/cozy-static-svg/scripts/artkit.py +321 -0
  8. package/skills/cozy-static-svg/scripts/to_png.py +333 -0
  9. package/skills/diagram-design/SKILL.md +568 -0
  10. package/skills/diagram-design/assets/example-architecture-dark.html +182 -0
  11. package/skills/diagram-design/assets/example-architecture-full.html +194 -0
  12. package/skills/diagram-design/assets/example-architecture.html +183 -0
  13. package/skills/diagram-design/assets/example-bar-dark.html +129 -0
  14. package/skills/diagram-design/assets/example-bar-full.html +105 -0
  15. package/skills/diagram-design/assets/example-bar.html +129 -0
  16. package/skills/diagram-design/assets/example-data-flow-dark.html +143 -0
  17. package/skills/diagram-design/assets/example-data-flow-full.html +185 -0
  18. package/skills/diagram-design/assets/example-data-flow.html +143 -0
  19. package/skills/diagram-design/assets/example-datalake-dark.html +243 -0
  20. package/skills/diagram-design/assets/example-datalake-full.html +248 -0
  21. package/skills/diagram-design/assets/example-datalake.html +269 -0
  22. package/skills/diagram-design/assets/example-dp-integration-dark.html +80 -0
  23. package/skills/diagram-design/assets/example-dp-integration-full.html +90 -0
  24. package/skills/diagram-design/assets/example-dp-integration.html +80 -0
  25. package/skills/diagram-design/assets/example-dp-security-matrix-dark.html +31 -0
  26. package/skills/diagram-design/assets/example-dp-security-matrix-full.html +32 -0
  27. package/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
  28. package/skills/diagram-design/assets/example-er-dark.html +202 -0
  29. package/skills/diagram-design/assets/example-er-full.html +205 -0
  30. package/skills/diagram-design/assets/example-er.html +202 -0
  31. package/skills/diagram-design/assets/example-flowchart-dark.html +156 -0
  32. package/skills/diagram-design/assets/example-flowchart-full.html +159 -0
  33. package/skills/diagram-design/assets/example-flowchart.html +156 -0
  34. package/skills/diagram-design/assets/example-gantt-dark.html +137 -0
  35. package/skills/diagram-design/assets/example-gantt-full.html +127 -0
  36. package/skills/diagram-design/assets/example-gantt.html +137 -0
  37. package/skills/diagram-design/assets/example-high-level-dark.html +251 -0
  38. package/skills/diagram-design/assets/example-high-level-full.html +247 -0
  39. package/skills/diagram-design/assets/example-high-level-vertical-dark.html +274 -0
  40. package/skills/diagram-design/assets/example-high-level-vertical-full.html +252 -0
  41. package/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
  42. package/skills/diagram-design/assets/example-high-level.html +301 -0
  43. package/skills/diagram-design/assets/example-import-drawio.html +185 -0
  44. package/skills/diagram-design/assets/example-import-mermaid.html +70 -0
  45. package/skills/diagram-design/assets/example-it-state-dark.html +90 -0
  46. package/skills/diagram-design/assets/example-it-state-full.html +69 -0
  47. package/skills/diagram-design/assets/example-it-state.html +128 -0
  48. package/skills/diagram-design/assets/example-layers-dark.html +123 -0
  49. package/skills/diagram-design/assets/example-layers-full.html +126 -0
  50. package/skills/diagram-design/assets/example-layers.html +123 -0
  51. package/skills/diagram-design/assets/example-line-dark.html +111 -0
  52. package/skills/diagram-design/assets/example-line-full.html +105 -0
  53. package/skills/diagram-design/assets/example-line.html +111 -0
  54. package/skills/diagram-design/assets/example-loop-dark.html +100 -0
  55. package/skills/diagram-design/assets/example-loop-full.html +137 -0
  56. package/skills/diagram-design/assets/example-loop-terminal.html +347 -0
  57. package/skills/diagram-design/assets/example-loop.html +100 -0
  58. package/skills/diagram-design/assets/example-medallion-dark.html +159 -0
  59. package/skills/diagram-design/assets/example-medallion-full.html +201 -0
  60. package/skills/diagram-design/assets/example-medallion.html +159 -0
  61. package/skills/diagram-design/assets/example-nested-dark.html +132 -0
  62. package/skills/diagram-design/assets/example-nested-full.html +135 -0
  63. package/skills/diagram-design/assets/example-nested.html +136 -0
  64. package/skills/diagram-design/assets/example-org-chart-dark.html +45 -0
  65. package/skills/diagram-design/assets/example-org-chart-full.html +45 -0
  66. package/skills/diagram-design/assets/example-org-chart.html +45 -0
  67. package/skills/diagram-design/assets/example-paved-road-animated.html +546 -0
  68. package/skills/diagram-design/assets/example-policy-trace-animated.html +490 -0
  69. package/skills/diagram-design/assets/example-process-dark.html +64 -0
  70. package/skills/diagram-design/assets/example-process-full.html +74 -0
  71. package/skills/diagram-design/assets/example-process.html +64 -0
  72. package/skills/diagram-design/assets/example-pyramid-dark.html +119 -0
  73. package/skills/diagram-design/assets/example-pyramid-full.html +122 -0
  74. package/skills/diagram-design/assets/example-pyramid.html +119 -0
  75. package/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
  76. package/skills/diagram-design/assets/example-quadrant-dark.html +135 -0
  77. package/skills/diagram-design/assets/example-quadrant-full.html +138 -0
  78. package/skills/diagram-design/assets/example-quadrant.html +135 -0
  79. package/skills/diagram-design/assets/example-queue-animated.html +577 -0
  80. package/skills/diagram-design/assets/example-radar-dark.html +139 -0
  81. package/skills/diagram-design/assets/example-radar-full.html +135 -0
  82. package/skills/diagram-design/assets/example-radar.html +139 -0
  83. package/skills/diagram-design/assets/example-scatter-dark.html +141 -0
  84. package/skills/diagram-design/assets/example-scatter-full.html +116 -0
  85. package/skills/diagram-design/assets/example-scatter.html +141 -0
  86. package/skills/diagram-design/assets/example-sequence-dark.html +222 -0
  87. package/skills/diagram-design/assets/example-sequence-full.html +388 -0
  88. package/skills/diagram-design/assets/example-sequence-oauth-dark.html +188 -0
  89. package/skills/diagram-design/assets/example-sequence-oauth-full.html +350 -0
  90. package/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
  91. package/skills/diagram-design/assets/example-sequence.html +222 -0
  92. package/skills/diagram-design/assets/example-state-dark.html +147 -0
  93. package/skills/diagram-design/assets/example-state-full.html +150 -0
  94. package/skills/diagram-design/assets/example-state.html +147 -0
  95. package/skills/diagram-design/assets/example-swimlane-dark.html +172 -0
  96. package/skills/diagram-design/assets/example-swimlane-full.html +175 -0
  97. package/skills/diagram-design/assets/example-swimlane.html +172 -0
  98. package/skills/diagram-design/assets/example-timeline-dark.html +135 -0
  99. package/skills/diagram-design/assets/example-timeline-full.html +138 -0
  100. package/skills/diagram-design/assets/example-timeline.html +135 -0
  101. package/skills/diagram-design/assets/example-tree-dark.html +173 -0
  102. package/skills/diagram-design/assets/example-tree-full.html +176 -0
  103. package/skills/diagram-design/assets/example-tree.html +173 -0
  104. package/skills/diagram-design/assets/example-venn-dark.html +132 -0
  105. package/skills/diagram-design/assets/example-venn-full.html +135 -0
  106. package/skills/diagram-design/assets/example-venn.html +110 -0
  107. package/skills/diagram-design/assets/icons.html +230 -0
  108. package/skills/diagram-design/assets/index.html +369 -0
  109. package/skills/diagram-design/assets/template-dark.html +85 -0
  110. package/skills/diagram-design/assets/template-full.html +413 -0
  111. package/skills/diagram-design/assets/template-motion.html +434 -0
  112. package/skills/diagram-design/assets/template-terminal.html +173 -0
  113. package/skills/diagram-design/assets/template.html +86 -0
  114. package/skills/diagram-design/references/animation.md +141 -0
  115. package/skills/diagram-design/references/export.md +136 -0
  116. package/skills/diagram-design/references/import-drawio.md +171 -0
  117. package/skills/diagram-design/references/import-mermaid.md +126 -0
  118. package/skills/diagram-design/references/onboarding.md +297 -0
  119. package/skills/diagram-design/references/output-spec.md +181 -0
  120. package/skills/diagram-design/references/primitive-annotation.md +36 -0
  121. package/skills/diagram-design/references/primitive-icons.md +827 -0
  122. package/skills/diagram-design/references/primitive-sketchy.md +43 -0
  123. package/skills/diagram-design/references/primitive-terminal.md +76 -0
  124. package/skills/diagram-design/references/semantic-patterns.md +122 -0
  125. package/skills/diagram-design/references/style-guide.md +139 -0
  126. package/skills/diagram-design/references/type-architecture.md +78 -0
  127. package/skills/diagram-design/references/type-bar.md +48 -0
  128. package/skills/diagram-design/references/type-data-flow.md +374 -0
  129. package/skills/diagram-design/references/type-dp-integration.md +410 -0
  130. package/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
  131. package/skills/diagram-design/references/type-er.md +23 -0
  132. package/skills/diagram-design/references/type-flowchart.md +23 -0
  133. package/skills/diagram-design/references/type-gantt.md +45 -0
  134. package/skills/diagram-design/references/type-high-level.md +458 -0
  135. package/skills/diagram-design/references/type-it-state.md +470 -0
  136. package/skills/diagram-design/references/type-layers.md +26 -0
  137. package/skills/diagram-design/references/type-line.md +44 -0
  138. package/skills/diagram-design/references/type-loop.md +223 -0
  139. package/skills/diagram-design/references/type-medallion.md +356 -0
  140. package/skills/diagram-design/references/type-nested.md +22 -0
  141. package/skills/diagram-design/references/type-org-chart.md +44 -0
  142. package/skills/diagram-design/references/type-process.md +495 -0
  143. package/skills/diagram-design/references/type-pyramid.md +33 -0
  144. package/skills/diagram-design/references/type-quadrant.md +81 -0
  145. package/skills/diagram-design/references/type-radar.md +80 -0
  146. package/skills/diagram-design/references/type-scatter.md +39 -0
  147. package/skills/diagram-design/references/type-sequence.md +130 -0
  148. package/skills/diagram-design/references/type-state.md +21 -0
  149. package/skills/diagram-design/references/type-swimlane.md +20 -0
  150. package/skills/diagram-design/references/type-timeline.md +20 -0
  151. package/skills/diagram-design/references/type-tree.md +24 -0
  152. package/skills/diagram-design/references/type-venn.md +26 -0
  153. package/skills/diagram-design/scripts/drawio_extract.py +856 -0
  154. package/skills/diagram-design/scripts/mermaid_extract.py +1285 -0
  155. package/skills/diagram-design/scripts/self_check.py +389 -0
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env python3
2
+ """Extract a normalized intermediate representation (IR) from a draw.io file.
3
+
4
+ The deterministic half of the draw.io import flow: this script never makes a
5
+ design decision. It decodes whatever draw.io wrote (raw XML, deflate+base64
6
+ payloads, PNG/SVG files with an embedded ``mxfile``), flattens the mxGraphModel
7
+ into absolute-positioned nodes and edges, and reports structural signals — hubs,
8
+ containers, depth, cycles, leaf clusters — that the skill uses to pick a diagram
9
+ type and a level of detail.
10
+
11
+ Usage:
12
+ python3 drawio_extract.py <file.drawio> [--page N|NAME] [--json]
13
+ [--max-rows N] [--out PATH]
14
+
15
+ Default output is a compact Markdown digest meant to be read into context.
16
+ ``--json`` emits the full IR instead (every node, every edge, every style).
17
+
18
+ Exit codes: 0 ok, 2 unreadable / unsupported input.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import base64
25
+ import html
26
+ import json
27
+ import re
28
+ import struct
29
+ import sys
30
+ import zlib
31
+ from dataclasses import dataclass, field, asdict
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from urllib.parse import unquote
35
+ from xml.etree import ElementTree as ET
36
+
37
+ # --------------------------------------------------------------------------
38
+ # container / payload decoding
39
+ # --------------------------------------------------------------------------
40
+
41
+ PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
42
+ MAX_INPUT_BYTES = 32 * 1024 * 1024
43
+ MAX_XML_BYTES = 64 * 1024 * 1024
44
+
45
+
46
+ class PayloadTooLarge(ValueError):
47
+ """Raised when compressed metadata expands beyond the supported limit."""
48
+
49
+
50
+ def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
51
+ print(f"drawio_extract: {msg}", file=sys.stderr)
52
+ raise SystemExit(2)
53
+
54
+
55
+ def _reject_unsafe_xml(xml: str, source: str) -> None:
56
+ """Reject declarations that can make XML parsing expand external data."""
57
+ upper = xml.upper()
58
+ if "<!DOCTYPE" in upper or "<!ENTITY" in upper:
59
+ _fail(f"{source}: DTD and entity declarations are not supported")
60
+
61
+
62
+ def _decompress_limited(data: bytes, wbits: int, limit: int = MAX_XML_BYTES) -> bytes:
63
+ """Decompress without allowing a small payload to expand without bound."""
64
+ decompressor = zlib.decompressobj(wbits)
65
+ output = bytearray()
66
+ chunk = data
67
+ while chunk:
68
+ remaining = limit + 1 - len(output)
69
+ if remaining <= 0:
70
+ raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
71
+ output.extend(decompressor.decompress(chunk, remaining))
72
+ if len(output) > limit:
73
+ raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
74
+ chunk = decompressor.unconsumed_tail
75
+ if not decompressor.eof:
76
+ raise zlib.error("incomplete compressed payload")
77
+ remaining = limit + 1 - len(output)
78
+ if remaining <= 0:
79
+ raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
80
+ output.extend(decompressor.flush(remaining))
81
+ if len(output) > limit:
82
+ raise PayloadTooLarge(f"decoded payload exceeds {limit} bytes")
83
+ return bytes(output)
84
+
85
+
86
+ def _inflate(payload: str) -> str | None:
87
+ """Undo draw.io's base64 + raw-deflate + URL-encoding pipeline."""
88
+ try:
89
+ raw = base64.b64decode(payload, validate=False)
90
+ except Exception:
91
+ return None
92
+ for wbits in (-15, 15, 47):
93
+ try:
94
+ text = _decompress_limited(raw, wbits).decode("utf-8", "replace")
95
+ except PayloadTooLarge:
96
+ _fail(
97
+ f"decoded diagram exceeds the {MAX_XML_BYTES // (1024 * 1024)} MiB limit"
98
+ )
99
+ except Exception:
100
+ continue
101
+ # draw.io URL-encodes before deflating; unquote is a no-op if it didn't.
102
+ return unquote(text)
103
+ return None
104
+
105
+
106
+ def _png_embedded_xml(data: bytes) -> str | None:
107
+ """Pull the ``mxfile`` tEXt/zTXt chunk out of a draw.io-exported PNG."""
108
+ pos = len(PNG_MAGIC)
109
+ while pos + 8 <= len(data):
110
+ (length,) = struct.unpack(">I", data[pos : pos + 4])
111
+ ctype = data[pos + 4 : pos + 8]
112
+ body_end = pos + 8 + length
113
+ chunk_end = body_end + 4
114
+ if chunk_end > len(data):
115
+ _fail("PNG has a truncated metadata chunk")
116
+ body = data[pos + 8 : body_end]
117
+ pos = chunk_end
118
+ if ctype not in (b"tEXt", b"zTXt", b"iTXt"):
119
+ if ctype == b"IEND":
120
+ break
121
+ continue
122
+ key, _, rest = body.partition(b"\x00")
123
+ if key.lower() != b"mxfile":
124
+ continue
125
+ try:
126
+ if ctype == b"tEXt":
127
+ value = rest
128
+ elif ctype == b"zTXt":
129
+ value = _decompress_limited(rest[1:], 15)
130
+ else: # iTXt: compression flag, method, lang, translated key, text
131
+ flag = rest[0:1]
132
+ tail = rest[2:].split(b"\x00", 2)[-1]
133
+ value = _decompress_limited(tail, 15) if flag == b"\x01" else tail
134
+ except PayloadTooLarge:
135
+ _fail(
136
+ f"embedded PNG diagram exceeds the "
137
+ f"{MAX_XML_BYTES // (1024 * 1024)} MiB limit"
138
+ )
139
+ except (IndexError, ValueError, zlib.error):
140
+ _fail("PNG has invalid compressed draw.io metadata")
141
+ return unquote(value.decode("utf-8", "replace"))
142
+ return None
143
+
144
+
145
+ def _svg_embedded_xml(text: str) -> str | None:
146
+ for match in re.finditer(
147
+ r"\bcontent\s*=\s*([\"'])(.*?)\1", text, flags=re.IGNORECASE | re.DOTALL
148
+ ):
149
+ candidate = html.unescape(match.group(2))
150
+ if "<mxfile" in candidate or "<mxGraphModel" in candidate:
151
+ return candidate
152
+ return None
153
+
154
+
155
+ def load_mxfile(path: Path) -> str:
156
+ """Return the ``<mxfile>`` (or bare ``<mxGraphModel>``) XML for any input."""
157
+ size = path.stat().st_size
158
+ if size > MAX_INPUT_BYTES:
159
+ _fail(
160
+ f"{path.name}: input is {size} bytes; maximum is "
161
+ f"{MAX_INPUT_BYTES // (1024 * 1024)} MiB"
162
+ )
163
+ data = path.read_bytes()
164
+ if data.startswith(PNG_MAGIC):
165
+ xml = _png_embedded_xml(data)
166
+ if not xml:
167
+ _fail(f"{path.name}: PNG has no embedded draw.io diagram")
168
+ return xml
169
+ text = data.decode("utf-8", "replace").lstrip("").strip()
170
+ if "<mxfile" in text or "<mxGraphModel" in text:
171
+ return text
172
+ if "<svg" in text[:2000]:
173
+ xml = _svg_embedded_xml(text)
174
+ if not xml:
175
+ _fail(f"{path.name}: SVG has no embedded draw.io diagram")
176
+ return xml
177
+ inflated = _inflate(text)
178
+ if inflated and "<mxGraphModel" in inflated:
179
+ return inflated
180
+ _fail(f"{path.name}: not a draw.io file (no mxfile, mxGraphModel, or payload)")
181
+
182
+
183
+ # --------------------------------------------------------------------------
184
+ # style + label helpers
185
+ # --------------------------------------------------------------------------
186
+
187
+ BR_RE = re.compile(r"<br\s*/?>|</p\s*>|</div\s*>", re.IGNORECASE)
188
+ TAG_RE = re.compile(r"<[^>]+>")
189
+
190
+
191
+ def parse_style(style: str | None) -> dict[str, str]:
192
+ out: dict[str, str] = {}
193
+ if not style:
194
+ return out
195
+ for part in style.split(";"):
196
+ part = part.strip()
197
+ if not part:
198
+ continue
199
+ key, sep, value = part.partition("=")
200
+ out[key.strip()] = value.strip() if sep else "1"
201
+ return out
202
+
203
+
204
+ def clean_label(value: str | None) -> str:
205
+ """draw.io labels are often HTML fragments; flatten to plain text lines."""
206
+ if not value:
207
+ return ""
208
+ text = BR_RE.sub("\n", value)
209
+ text = TAG_RE.sub("", text)
210
+ text = html.unescape(text)
211
+ text = text.replace("\xa0", " ")
212
+ lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in text.split("\n")]
213
+ return "\n".join(ln for ln in lines if ln).strip()
214
+
215
+
216
+ SHAPE_FAMILIES = (
217
+ ("mxgraph.aws", "aws"),
218
+ ("mxgraph.azure", "azure"),
219
+ ("mxgraph.gcp", "gcp"),
220
+ ("mxgraph.kubernetes", "kubernetes"),
221
+ ("mxgraph.cisco", "network"),
222
+ ("mxgraph.veeam", "infra"),
223
+ ("mxgraph.flowchart", "flowchart"),
224
+ ("mxgraph.bpmn", "bpmn"),
225
+ ("mxgraph.er", "er"),
226
+ ("mxgraph.sysml", "uml"),
227
+ ("mxgraph.archimate", "archimate"),
228
+ )
229
+
230
+ # style key -> canonical shape name, checked in order
231
+ SHAPE_KEYS = (
232
+ ("swimlane", "swimlane"),
233
+ ("ellipse", "ellipse"),
234
+ ("rhombus", "rhombus"),
235
+ ("triangle", "triangle"),
236
+ ("cylinder", "cylinder"),
237
+ ("cylinder3", "cylinder"),
238
+ ("hexagon", "hexagon"),
239
+ ("cloud", "cloud"),
240
+ ("actor", "actor"),
241
+ ("umlActor", "actor"),
242
+ ("note", "note"),
243
+ ("card", "card"),
244
+ ("step", "step"),
245
+ ("process", "process"),
246
+ ("parallelogram", "parallelogram"),
247
+ ("document", "document"),
248
+ ("datastore", "cylinder"),
249
+ ("umlLifeline", "lifeline"),
250
+ ("umlFrame", "frame"),
251
+ ("table", "table"),
252
+ ("tableRow", "table-row"),
253
+ ("partialRectangle", "table-row"),
254
+ ("image", "image"),
255
+ ("text", "text"),
256
+ ("group", "group"),
257
+ )
258
+
259
+
260
+ def classify_shape(style: dict[str, str]) -> str:
261
+ raw = style.get("shape", "")
262
+ if raw:
263
+ for key, name in SHAPE_KEYS:
264
+ if raw == key or raw.startswith(key):
265
+ return name
266
+ for prefix, family in SHAPE_FAMILIES:
267
+ if raw.startswith(prefix):
268
+ return f"icon:{family}"
269
+ return f"shape:{raw}"
270
+ for key, name in SHAPE_KEYS:
271
+ if key in style:
272
+ return name
273
+ if style.get("ellipse") == "1":
274
+ return "ellipse"
275
+ return "rect"
276
+
277
+
278
+ def shape_family(shape: str) -> str:
279
+ if shape.startswith("icon:"):
280
+ return shape.split(":", 1)[1]
281
+ if shape.startswith("shape:"):
282
+ return "custom"
283
+ return shape
284
+
285
+
286
+ # --------------------------------------------------------------------------
287
+ # IR model
288
+ # --------------------------------------------------------------------------
289
+
290
+
291
+ @dataclass
292
+ class Node:
293
+ id: str
294
+ label: str = ""
295
+ shape: str = "rect"
296
+ parent: str | None = None
297
+ depth: int = 0
298
+ x: float = 0.0
299
+ y: float = 0.0
300
+ w: float = 0.0
301
+ h: float = 0.0
302
+ fill: str = ""
303
+ stroke: str = ""
304
+ font_color: str = ""
305
+ dashed: bool = False
306
+ rounded: bool = False
307
+ container: bool = False
308
+ children: list[str] = field(default_factory=list)
309
+ link: str = ""
310
+ attrs: dict[str, str] = field(default_factory=dict)
311
+ in_degree: int = 0
312
+ out_degree: int = 0
313
+
314
+
315
+ @dataclass
316
+ class Edge:
317
+ id: str
318
+ source: str | None
319
+ target: str | None
320
+ label: str = ""
321
+ dashed: bool = False
322
+ bidirectional: bool = False
323
+ undirected: bool = False
324
+ style_name: str = ""
325
+ waypoints: int = 0
326
+ stroke: str = ""
327
+
328
+
329
+ @dataclass
330
+ class Page:
331
+ id: str
332
+ name: str
333
+ index: int
334
+ nodes: list[Node] = field(default_factory=list)
335
+ edges: list[Edge] = field(default_factory=list)
336
+
337
+ @property
338
+ def node_map(self) -> dict[str, Node]:
339
+ return {n.id: n for n in self.nodes}
340
+
341
+
342
+ def _num(geom: ET.Element | None, key: str) -> float:
343
+ if geom is None:
344
+ return 0.0
345
+ try:
346
+ return float(geom.get(key, "0") or 0)
347
+ except ValueError:
348
+ return 0.0
349
+
350
+
351
+ def parse_page(diagram: ET.Element, index: int) -> Page:
352
+ name = diagram.get("name") or f"Page-{index + 1}"
353
+ page = Page(id=diagram.get("id") or f"page-{index}", name=name, index=index)
354
+
355
+ model = diagram.find(".//mxGraphModel")
356
+ if model is None:
357
+ text = (diagram.text or "").strip()
358
+ inflated = _inflate(text) if text else None
359
+ if not inflated:
360
+ return page
361
+ _reject_unsafe_xml(inflated, f"page {index}")
362
+ model = ET.fromstring(inflated)
363
+ if model.tag != "mxGraphModel":
364
+ found = model.find(".//mxGraphModel")
365
+ if found is None:
366
+ return page
367
+ model = found
368
+
369
+ root = model.find("root")
370
+ if root is None:
371
+ return page
372
+
373
+ # Pass 1: collect raw cells, unwrapping <object>/<UserObject> containers.
374
+ raw: dict[str, dict[str, Any]] = {}
375
+ order: list[str] = []
376
+ for element in root:
377
+ if element.tag in ("object", "UserObject"):
378
+ cell = element.find("mxCell")
379
+ if cell is None:
380
+ continue
381
+ attrs = {
382
+ k: v
383
+ for k, v in element.attrib.items()
384
+ if k not in ("id", "label", "placeholders")
385
+ }
386
+ cid = element.get("id") or cell.get("id") or ""
387
+ value = element.get("label", "")
388
+ elif element.tag == "mxCell":
389
+ cell = element
390
+ attrs = {}
391
+ cid = cell.get("id") or ""
392
+ value = cell.get("value", "")
393
+ else:
394
+ continue
395
+ if not cid:
396
+ continue
397
+ raw[cid] = {"cell": cell, "attrs": attrs, "value": value}
398
+ order.append(cid)
399
+
400
+ # Pass 2: vertices (absolute geometry resolved after the pass).
401
+ edge_label_parts: dict[str, list[str]] = {}
402
+ for cid in order:
403
+ entry = raw[cid]
404
+ cell = entry["cell"]
405
+ style = parse_style(cell.get("style"))
406
+ parent = cell.get("parent")
407
+ if cell.get("edge") == "1":
408
+ continue
409
+ if cell.get("vertex") != "1":
410
+ continue
411
+ # An edge label is a vertex parented to an edge; fold it into the edge.
412
+ parent_entry = raw.get(parent or "")
413
+ parent_is_edge = bool(
414
+ parent_entry and parent_entry["cell"].get("edge") == "1"
415
+ )
416
+ if parent_is_edge or "edgeLabel" in style:
417
+ if parent:
418
+ text = clean_label(entry["value"])
419
+ if text:
420
+ edge_label_parts.setdefault(parent, []).append(text)
421
+ continue
422
+
423
+ geom = cell.find("mxGeometry")
424
+ node = Node(
425
+ id=cid,
426
+ label=clean_label(entry["value"]),
427
+ shape=classify_shape(style),
428
+ parent=parent,
429
+ x=_num(geom, "x"),
430
+ y=_num(geom, "y"),
431
+ w=_num(geom, "width"),
432
+ h=_num(geom, "height"),
433
+ fill=style.get("fillColor", ""),
434
+ stroke=style.get("strokeColor", ""),
435
+ font_color=style.get("fontColor", ""),
436
+ dashed=style.get("dashed") == "1",
437
+ rounded=style.get("rounded") == "1",
438
+ container=style.get("container") == "1" or "swimlane" in style,
439
+ link=entry["attrs"].get("link", ""),
440
+ attrs={
441
+ k: v
442
+ for k, v in entry["attrs"].items()
443
+ if k not in ("link", "tooltip")
444
+ },
445
+ )
446
+ page.nodes.append(node)
447
+
448
+ node_map = page.node_map
449
+
450
+ # Resolve absolute geometry + depth by walking the parent chain.
451
+ def resolve(node: Node, seen: set[str]) -> tuple[float, float, int]:
452
+ if node.id in seen:
453
+ return node.x, node.y, 0
454
+ seen.add(node.id)
455
+ parent = node_map.get(node.parent or "")
456
+ if parent is None:
457
+ return node.x, node.y, 0
458
+ px, py, pdepth = resolve(parent, seen)
459
+ return node.x + px, node.y + py, pdepth + 1
460
+
461
+ for node in page.nodes:
462
+ ax, ay, depth = resolve(node, set())
463
+ node.x, node.y, node.depth = ax, ay, depth
464
+ parent = node_map.get(node.parent or "")
465
+ if parent is not None:
466
+ parent.children.append(node.id)
467
+ parent.container = True
468
+
469
+ # Pass 3: edges.
470
+ for cid in order:
471
+ entry = raw[cid]
472
+ cell = entry["cell"]
473
+ if cell.get("edge") != "1":
474
+ continue
475
+ style = parse_style(cell.get("style"))
476
+ geom = cell.find("mxGeometry")
477
+ waypoints = 0
478
+ if geom is not None:
479
+ waypoints = len(
480
+ [p for p in geom.findall(".//mxPoint") if p.get("as") is None]
481
+ )
482
+ label = clean_label(entry["value"])
483
+ extra = edge_label_parts.get(cid, [])
484
+ if extra:
485
+ label = " / ".join([p for p in ([label] + extra) if p])
486
+ source = cell.get("source")
487
+ target = cell.get("target")
488
+ page.edges.append(
489
+ Edge(
490
+ id=cid,
491
+ source=source if source in node_map else None,
492
+ target=target if target in node_map else None,
493
+ label=label,
494
+ dashed=style.get("dashed") == "1",
495
+ bidirectional=style.get("startArrow", "none")
496
+ not in ("none", "0", "")
497
+ and style.get("endArrow", "classic") not in ("none", "0"),
498
+ undirected=style.get("endArrow") in ("none", "0")
499
+ and style.get("startArrow", "none") in ("none", "0", ""),
500
+ style_name=style.get("shape", "")
501
+ or ("orthogonal" if style.get("edgeStyle") else ""),
502
+ waypoints=waypoints,
503
+ stroke=style.get("strokeColor", ""),
504
+ )
505
+ )
506
+
507
+ for edge in page.edges:
508
+ if edge.source and edge.source in node_map:
509
+ node_map[edge.source].out_degree += 1
510
+ if edge.target and edge.target in node_map:
511
+ node_map[edge.target].in_degree += 1
512
+
513
+ return page
514
+
515
+
516
+ def parse_file(path: Path) -> list[Page]:
517
+ xml = load_mxfile(path)
518
+ _reject_unsafe_xml(xml, path.name)
519
+ try:
520
+ root = ET.fromstring(xml)
521
+ except ET.ParseError as exc:
522
+ _fail(f"{path.name}: malformed XML ({exc})")
523
+ if root.tag == "mxGraphModel":
524
+ wrapper = ET.Element("diagram", {"name": path.stem, "id": "single"})
525
+ wrapper.append(root)
526
+ return [parse_page(wrapper, 0)]
527
+ diagrams = root.findall(".//diagram")
528
+ if not diagrams:
529
+ _fail(f"{path.name}: mxfile contains no <diagram> pages")
530
+ return [parse_page(d, i) for i, d in enumerate(diagrams)]
531
+
532
+
533
+ # --------------------------------------------------------------------------
534
+ # structural analysis — signals, not decisions
535
+ # --------------------------------------------------------------------------
536
+
537
+
538
+ def _has_cycle(nodes: list[Node], edges: list[Edge]) -> bool:
539
+ adjacency: dict[str, list[str]] = {n.id: [] for n in nodes}
540
+ for edge in edges:
541
+ if edge.source and edge.target and edge.source in adjacency:
542
+ adjacency[edge.source].append(edge.target)
543
+ WHITE, GREY, BLACK = 0, 1, 2
544
+ color = {n.id: WHITE for n in nodes}
545
+
546
+ def visit(start: str) -> bool:
547
+ stack = [(start, iter(adjacency.get(start, [])))]
548
+ color[start] = GREY
549
+ while stack:
550
+ nid, it = stack[-1]
551
+ advanced = False
552
+ for nxt in it:
553
+ state = color.get(nxt, BLACK)
554
+ if state == GREY:
555
+ return True
556
+ if state == WHITE:
557
+ color[nxt] = GREY
558
+ stack.append((nxt, iter(adjacency.get(nxt, []))))
559
+ advanced = True
560
+ break
561
+ if not advanced:
562
+ color[nid] = BLACK
563
+ stack.pop()
564
+ return False
565
+
566
+ return any(color[n.id] == WHITE and visit(n.id) for n in nodes)
567
+
568
+
569
+ def _aligned(boxes: list[Node], tolerance: float = 8.0) -> bool:
570
+ """True when the boxes stack as lanes — shared left edge or shared top edge."""
571
+ if len(boxes) < 2:
572
+ return False
573
+ same_x = max(n.x for n in boxes) - min(n.x for n in boxes) <= tolerance
574
+ same_w = max(n.w for n in boxes) - min(n.w for n in boxes) <= tolerance
575
+ same_y = max(n.y for n in boxes) - min(n.y for n in boxes) <= tolerance
576
+ same_h = max(n.h for n in boxes) - min(n.h for n in boxes) <= tolerance
577
+ return (same_x and same_w) or (same_y and same_h)
578
+
579
+
580
+ def analyze(page: Page) -> dict[str, Any]:
581
+ nodes = page.nodes
582
+ edges = page.edges
583
+ drawable = [n for n in nodes if n.shape not in ("text",) and (n.label or n.children)]
584
+ containers = [n for n in nodes if n.children]
585
+ leaves = [n for n in nodes if not n.children]
586
+ shapes: dict[str, int] = {}
587
+ for node in nodes:
588
+ shapes[shape_family(node.shape)] = shapes.get(shape_family(node.shape), 0) + 1
589
+
590
+ def name_of(node: Node) -> str:
591
+ return (node.label.replace("\n", " · ") or node.id)
592
+
593
+ ranked = sorted(
594
+ leaves, key=lambda n: (n.in_degree + n.out_degree), reverse=True
595
+ )
596
+ hubs = [
597
+ {"id": n.id, "label": name_of(n), "degree": n.in_degree + n.out_degree}
598
+ for n in ranked[:5]
599
+ if (n.in_degree + n.out_degree) > 0
600
+ ]
601
+ sources = [name_of(n) for n in leaves if n.out_degree and not n.in_degree]
602
+ sinks = [name_of(n) for n in leaves if n.in_degree and not n.out_degree]
603
+ orphans = [name_of(n) for n in leaves if not n.in_degree and not n.out_degree]
604
+
605
+ # Type candidates, strongest signal first. Advisory only.
606
+ candidates: list[str] = []
607
+ if shapes.get("lifeline"):
608
+ candidates.append("sequence")
609
+ if shapes.get("table") or shapes.get("er"):
610
+ candidates.append("er")
611
+ lanes = [n for n in nodes if n.shape == "swimlane" and n.children]
612
+ if len(lanes) >= 2 and _aligned(lanes):
613
+ candidates.append("swimlane")
614
+ if shapes.get("rhombus"):
615
+ candidates.append("flowchart")
616
+ if shapes.get("ellipse", 0) >= max(2, len(leaves) // 3) and edges:
617
+ candidates.append("state")
618
+ if any(f in shapes for f in ("aws", "azure", "gcp", "kubernetes", "network")):
619
+ candidates.append("architecture")
620
+ if containers and not shapes.get("swimlane"):
621
+ candidates.append("nested")
622
+ if edges and not _has_cycle(nodes, edges) and len(sources) == 1:
623
+ candidates.append("tree")
624
+ if edges:
625
+ candidates.append("architecture")
626
+ if not candidates:
627
+ candidates.append("architecture")
628
+
629
+ seen: set[str] = set()
630
+ candidates = [c for c in candidates if not (c in seen or seen.add(c))]
631
+
632
+ # Collapse candidates: containers whose children are all leaves, and
633
+ # fan-out clusters — the first things to merge when simplifying.
634
+ collapsible = [
635
+ {
636
+ "id": c.id,
637
+ "label": name_of(c),
638
+ "children": len(c.children),
639
+ "child_labels": [
640
+ name_of(page.node_map[cid])
641
+ for cid in c.children
642
+ if page.node_map.get(cid) and page.node_map[cid].label
643
+ ][:8],
644
+ }
645
+ for c in containers
646
+ if c.children and all(not page.node_map[cid].children for cid in c.children)
647
+ ]
648
+ collapsible.sort(key=lambda c: c["children"], reverse=True)
649
+
650
+ return {
651
+ "nodes_total": len(nodes),
652
+ "nodes_drawable": len(drawable),
653
+ "containers": len(containers),
654
+ "leaves": len(leaves),
655
+ "edges_total": len(edges),
656
+ "edges_labeled": sum(1 for e in edges if e.label),
657
+ "edges_dangling": sum(1 for e in edges if not (e.source and e.target)),
658
+ "max_depth": max((n.depth for n in nodes), default=0),
659
+ "shapes": dict(sorted(shapes.items(), key=lambda kv: -kv[1])),
660
+ "has_cycle": _has_cycle(nodes, edges),
661
+ "hubs": hubs,
662
+ "entry_points": sources[:6],
663
+ "terminals": sinks[:6],
664
+ "orphans": orphans[:6],
665
+ "type_candidates": candidates[:3],
666
+ "collapsible_groups": collapsible[:8],
667
+ "over_node_budget": len(drawable) > 9,
668
+ "over_edge_budget": len(edges) > 12,
669
+ }
670
+
671
+
672
+ # --------------------------------------------------------------------------
673
+ # rendering the digest
674
+ # --------------------------------------------------------------------------
675
+
676
+
677
+ def page_bounds(page: Page) -> tuple[float, float, float, float]:
678
+ boxes = [(n.x, n.y, n.x + n.w, n.y + n.h) for n in page.nodes if n.w and n.h]
679
+ if not boxes:
680
+ return (0.0, 0.0, 0.0, 0.0)
681
+ return (
682
+ min(b[0] for b in boxes),
683
+ min(b[1] for b in boxes),
684
+ max(b[2] for b in boxes),
685
+ max(b[3] for b in boxes),
686
+ )
687
+
688
+
689
+ def digest(path: Path, pages: list[Page], selected: list[Page], max_rows: int) -> str:
690
+ out: list[str] = []
691
+ out.append(f"# draw.io IR — {path.name}")
692
+ out.append("")
693
+ out.append(
694
+ f"{len(pages)} page(s): "
695
+ + ", ".join(f"[{p.index}] {p.name} ({len(p.nodes)}n/{len(p.edges)}e)" for p in pages)
696
+ )
697
+ for page in selected:
698
+ info = analyze(page)
699
+ x0, y0, x1, y1 = page_bounds(page)
700
+ out.append("")
701
+ out.append(f"## Page {page.index} — {page.name}")
702
+ out.append("")
703
+ out.append(
704
+ f"- source canvas: {int(x1 - x0)}×{int(y1 - y0)} px "
705
+ f"(aspect {((x1 - x0) / (y1 - y0)):.2f})"
706
+ if y1 > y0
707
+ else "- source canvas: empty"
708
+ )
709
+ out.append(
710
+ f"- nodes: {info['nodes_total']} total / {info['nodes_drawable']} drawable "
711
+ f"/ {info['containers']} containers, depth {info['max_depth']}"
712
+ )
713
+ out.append(
714
+ f"- edges: {info['edges_total']} ({info['edges_labeled']} labeled, "
715
+ f"{info['edges_dangling']} dangling), cycle: {info['has_cycle']}"
716
+ )
717
+ out.append(f"- shapes: {info['shapes']}")
718
+ out.append(f"- type candidates: {', '.join(info['type_candidates'])}")
719
+ out.append(
720
+ f"- budget: nodes {'OVER' if info['over_node_budget'] else 'ok'} (max 9), "
721
+ f"edges {'OVER' if info['over_edge_budget'] else 'ok'} (max 12)"
722
+ )
723
+ if info["hubs"]:
724
+ hubs = ", ".join(f"{h['label'] or h['id']}({h['degree']})" for h in info["hubs"])
725
+ out.append(f"- hubs (focal candidates): {hubs}")
726
+ if info["entry_points"]:
727
+ out.append(f"- entry points: {', '.join(info['entry_points'])}")
728
+ if info["terminals"]:
729
+ out.append(f"- terminals: {', '.join(info['terminals'])}")
730
+ if info["orphans"]:
731
+ out.append(f"- unconnected: {', '.join(info['orphans'])}")
732
+ if info["collapsible_groups"]:
733
+ out.append("- collapsible groups (simplify here first):")
734
+ for group in info["collapsible_groups"]:
735
+ kids = ", ".join(group["child_labels"])
736
+ out.append(f" - {group['label']} — {group['children']} children: {kids}")
737
+
738
+ out.append("")
739
+ out.append("### Nodes")
740
+ out.append("")
741
+ out.append("| id | label | shape | depth | parent | deg | box |")
742
+ out.append("|---|---|---|---|---|---|---|")
743
+ listed = [n for n in page.nodes if n.label or n.children]
744
+ for node in listed[:max_rows]:
745
+ label = node.label.replace("\n", " ⏎ ").replace("|", "\\|")
746
+ out.append(
747
+ f"| {node.id} | {label} | {node.shape} | {node.depth} | "
748
+ f"{node.parent or '-'} | {node.in_degree}/{node.out_degree} | "
749
+ f"{int(node.x)},{int(node.y)} {int(node.w)}×{int(node.h)} |"
750
+ )
751
+ if len(listed) > max_rows:
752
+ out.append(f"| … | +{len(listed) - max_rows} more (use --json) | | | | | |")
753
+
754
+ out.append("")
755
+ out.append("### Edges")
756
+ out.append("")
757
+ out.append("| source | target | label | style |")
758
+ out.append("|---|---|---|---|")
759
+ names = {n.id: (n.label.split("\n")[0] or n.id) for n in page.nodes}
760
+ for edge in page.edges[:max_rows]:
761
+ marks = []
762
+ if edge.dashed:
763
+ marks.append("dashed")
764
+ if edge.bidirectional:
765
+ marks.append("bidir")
766
+ if edge.undirected:
767
+ marks.append("undirected")
768
+ out.append(
769
+ f"| {names.get(edge.source or '', '?')} | {names.get(edge.target or '', '?')} "
770
+ f"| {edge.label.replace('|', chr(92) + '|') or '-'} | {' '.join(marks) or '-'} |"
771
+ )
772
+ if len(page.edges) > max_rows:
773
+ out.append(f"| … | +{len(page.edges) - max_rows} more (use --json) | | |")
774
+ out.append("")
775
+ return "\n".join(out)
776
+
777
+
778
+ def to_json(path: Path, pages: list[Page], selected: list[Page]) -> str:
779
+ payload = {
780
+ "source": str(path),
781
+ "pages_total": len(pages),
782
+ "pages": [
783
+ {
784
+ "id": p.id,
785
+ "name": p.name,
786
+ "index": p.index,
787
+ "bounds": dict(zip(("x0", "y0", "x1", "y1"), page_bounds(p))),
788
+ "analysis": analyze(p),
789
+ "nodes": [asdict(n) for n in p.nodes],
790
+ "edges": [asdict(e) for e in p.edges],
791
+ }
792
+ for p in selected
793
+ ],
794
+ }
795
+ return json.dumps(payload, indent=2, ensure_ascii=False)
796
+
797
+
798
+ def select_pages(pages: list[Page], selector: str | None) -> list[Page]:
799
+ if selector is None:
800
+ return pages if len(pages) == 1 else pages[:1]
801
+ if selector == "all":
802
+ return pages
803
+ if selector.isdigit():
804
+ index = int(selector)
805
+ match = [p for p in pages if p.index == index]
806
+ if not match:
807
+ _fail(f"no page with index {index} (have 0..{len(pages) - 1})")
808
+ return match
809
+ match = [p for p in pages if p.name.lower() == selector.lower()]
810
+ if not match:
811
+ names = ", ".join(p.name for p in pages)
812
+ _fail(f"no page named {selector!r} (have: {names})")
813
+ return match
814
+
815
+
816
+ def main(argv: list[str] | None = None) -> int:
817
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
818
+ parser.add_argument("file", help=".drawio / .xml / .drawio.png / .drawio.svg")
819
+ parser.add_argument(
820
+ "--page",
821
+ help="page index, page name, or 'all' (default: first page)",
822
+ )
823
+ parser.add_argument("--json", action="store_true", help="emit the full IR as JSON")
824
+ parser.add_argument(
825
+ "--max-rows",
826
+ type=int,
827
+ default=40,
828
+ help="rows per table in the Markdown digest (default 40)",
829
+ )
830
+ parser.add_argument("--out", help="write to this path instead of stdout")
831
+ args = parser.parse_args(argv)
832
+
833
+ if args.max_rows < 1:
834
+ parser.error("--max-rows must be at least 1")
835
+
836
+ path = Path(args.file)
837
+ if not path.is_file():
838
+ _fail(f"{path}: no such file")
839
+
840
+ pages = parse_file(path)
841
+ selected = select_pages(pages, args.page)
842
+ text = (
843
+ to_json(path, pages, selected)
844
+ if args.json
845
+ else digest(path, pages, selected, args.max_rows)
846
+ )
847
+ if args.out:
848
+ Path(args.out).write_text(text, encoding="utf-8")
849
+ print(f"wrote {args.out} ({len(text)} bytes)")
850
+ else:
851
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
852
+ return 0
853
+
854
+
855
+ if __name__ == "__main__":
856
+ raise SystemExit(main())