@formicoidea/labre-framework-uml 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +384 -0
  2. package/dist/actions.d.ts +430 -0
  3. package/dist/actions.js +724 -0
  4. package/dist/background.d.ts +153 -0
  5. package/dist/background.js +557 -0
  6. package/dist/board-hit.d.ts +137 -0
  7. package/dist/board-hit.js +191 -0
  8. package/dist/commands-manifest.d.ts +19 -0
  9. package/dist/commands-manifest.js +571 -0
  10. package/dist/commands.d.ts +4 -0
  11. package/dist/commands.js +983 -0
  12. package/dist/component.d.ts +521 -0
  13. package/dist/component.js +708 -0
  14. package/dist/consts.d.ts +365 -0
  15. package/dist/consts.js +463 -0
  16. package/dist/descriptor.d.ts +12 -0
  17. package/dist/descriptor.js +10 -0
  18. package/dist/drawio-decode.d.ts +32 -0
  19. package/dist/drawio-decode.js +96 -0
  20. package/dist/drawio-import.d.ts +133 -0
  21. package/dist/drawio-import.js +885 -0
  22. package/dist/edge-morph.d.ts +154 -0
  23. package/dist/edge-morph.js +153 -0
  24. package/dist/edge-styles.d.ts +78 -0
  25. package/dist/edge-styles.js +210 -0
  26. package/dist/effects.d.ts +9 -0
  27. package/dist/effects.js +6 -0
  28. package/dist/element-renderer.d.ts +64 -0
  29. package/dist/element-renderer.js +114 -0
  30. package/dist/element-view.d.ts +234 -0
  31. package/dist/element-view.js +430 -0
  32. package/dist/export.d.ts +61 -0
  33. package/dist/export.js +41 -0
  34. package/dist/filename.d.ts +17 -0
  35. package/dist/filename.js +26 -0
  36. package/dist/frame-tag.d.ts +70 -0
  37. package/dist/frame-tag.js +78 -0
  38. package/dist/grammar.d.ts +695 -0
  39. package/dist/grammar.js +1436 -0
  40. package/dist/import.d.ts +306 -0
  41. package/dist/import.js +1270 -0
  42. package/dist/index.d.ts +47 -0
  43. package/dist/index.js +67 -0
  44. package/dist/interchange.d.ts +125 -0
  45. package/dist/interchange.js +372 -0
  46. package/dist/keywords.d.ts +204 -0
  47. package/dist/keywords.js +361 -0
  48. package/dist/kinds.d.ts +73 -0
  49. package/dist/kinds.js +91 -0
  50. package/dist/legend.d.ts +30 -0
  51. package/dist/legend.js +255 -0
  52. package/dist/model.d.ts +862 -0
  53. package/dist/model.js +1316 -0
  54. package/dist/morph.d.ts +178 -0
  55. package/dist/morph.js +496 -0
  56. package/dist/node/act-glyphs.d.ts +30 -0
  57. package/dist/node/act-glyphs.js +142 -0
  58. package/dist/node/compartment-watcher.d.ts +107 -0
  59. package/dist/node/compartment-watcher.js +163 -0
  60. package/dist/node/fit.d.ts +85 -0
  61. package/dist/node/fit.js +129 -0
  62. package/dist/node/node-renderer.d.ts +49 -0
  63. package/dist/node/node-renderer.js +611 -0
  64. package/dist/node/node-view.d.ts +51 -0
  65. package/dist/node/node-view.js +89 -0
  66. package/dist/node/paint.d.ts +84 -0
  67. package/dist/node/paint.js +143 -0
  68. package/dist/node/sd-glyphs.d.ts +36 -0
  69. package/dist/node/sd-glyphs.js +79 -0
  70. package/dist/node/stm-glyphs.d.ts +22 -0
  71. package/dist/node/stm-glyphs.js +111 -0
  72. package/dist/node/tier-metrics.d.ts +38 -0
  73. package/dist/node/tier-metrics.js +45 -0
  74. package/dist/operators.d.ts +45 -0
  75. package/dist/operators.js +75 -0
  76. package/dist/plantuml-import.d.ts +113 -0
  77. package/dist/plantuml-import.js +1723 -0
  78. package/dist/plantuml.d.ts +94 -0
  79. package/dist/plantuml.js +985 -0
  80. package/dist/presets.d.ts +149 -0
  81. package/dist/presets.js +302 -0
  82. package/dist/profiles.d.ts +2 -0
  83. package/dist/profiles.js +358 -0
  84. package/dist/reading.d.ts +160 -0
  85. package/dist/reading.js +648 -0
  86. package/dist/roles.d.ts +257 -0
  87. package/dist/roles.js +997 -0
  88. package/dist/rules.d.ts +267 -0
  89. package/dist/rules.js +3090 -0
  90. package/dist/templates.d.ts +2 -0
  91. package/dist/templates.js +197 -0
  92. package/dist/toolbar/config.d.ts +228 -0
  93. package/dist/toolbar/config.js +833 -0
  94. package/dist/toolbar/icons.d.ts +440 -0
  95. package/dist/toolbar/icons.js +711 -0
  96. package/dist/toolbar/senior-tool.d.ts +11 -0
  97. package/dist/toolbar/senior-tool.js +21 -0
  98. package/dist/toolbar/uml-menu.d.ts +17 -0
  99. package/dist/toolbar/uml-menu.js +20 -0
  100. package/dist/toolbar/uml-senior-button.d.ts +18 -0
  101. package/dist/toolbar/uml-senior-button.js +22 -0
  102. package/dist/translations.d.ts +27 -0
  103. package/dist/translations.js +117 -0
  104. package/dist/view.d.ts +33 -0
  105. package/dist/view.js +236 -0
  106. package/dist/xmi-import.d.ts +174 -0
  107. package/dist/xmi-import.js +2268 -0
  108. package/dist/xmi.d.ts +61 -0
  109. package/dist/xmi.js +1860 -0
  110. package/dist/xml-reader.d.ts +157 -0
  111. package/dist/xml-reader.js +430 -0
  112. package/dist/xml.d.ts +72 -0
  113. package/dist/xml.js +96 -0
  114. package/package.json +38 -0
@@ -0,0 +1,885 @@
1
+ import { UML_DIAGRAM_KIND_TAG } from '@formicoidea/labre-core/model';
2
+ import { UML_NODE_BOX } from './consts.js';
3
+ import { parseEndLabel, parseLifelineIdent, parseOperation, parseProperty, } from './grammar.js';
4
+ import { umlSequenceSlot } from './import.js';
5
+ import { stereotypesOf } from './keywords.js';
6
+ import { ADORNED_RELATION_KINDS, umlLifelineAt } from './model.js';
7
+ import { decodeXmlText, readXml, xmlDescendants, } from './xml-reader.js';
8
+ /** The format id foreign draw.io matter rides under (`docs/adr/0012` D2). */
9
+ export const UML_DRAWIO_FORMAT_ID = 'drawio';
10
+ /* ── The compressed half a pure function cannot read ──────────────────── */
11
+ /**
12
+ * Whether this text is an `<mxfile>` whose payload still needs inflating.
13
+ *
14
+ * The test is the honest one — "there is a `<diagram>` and no `<mxGraphModel>`
15
+ * anywhere in it" — rather than a guess at what base64 looks like: draw.io
16
+ * writes the uncompressed form under the very same element (File › Properties ›
17
+ * Compressed: off), and a reader that refused those would refuse half the files
18
+ * in the wild for the sake of a heuristic.
19
+ */
20
+ export function isCompressedDrawio(text) {
21
+ return !text.includes('<mxGraphModel') && text.includes('<diagram');
22
+ }
23
+ /**
24
+ * A draw.io style string as a lookup.
25
+ *
26
+ * `shadow=0;fillColor=#FFF2CC;html=1;` is a map; `swimlane;childLayout=…` opens
27
+ * with a bare shape NAME, which is stored as a key with an empty value so that
28
+ * `'swimlane' in style` and `style.shape === 'umlactor'` are the same kind of
29
+ * question. Keys AND values are lower-cased: draw.io is inconsistent about case
30
+ * in hand-edited styles, every token this reader tests for is an identifier
31
+ * rather than a label, and nobody should have to know which spelling a
32
+ * particular shape library used.
33
+ */
34
+ function parseStyle(raw) {
35
+ const style = {};
36
+ for (const part of (raw ?? '').split(';')) {
37
+ const token = part.trim();
38
+ if (!token)
39
+ continue;
40
+ const eq = token.indexOf('=');
41
+ if (eq < 0)
42
+ style[token.toLowerCase()] = '';
43
+ else {
44
+ style[token.slice(0, eq).trim().toLowerCase()] = token
45
+ .slice(eq + 1)
46
+ .trim()
47
+ .toLowerCase();
48
+ }
49
+ }
50
+ return style;
51
+ }
52
+ /* ── The label HTML ───────────────────────────────────────────────────── */
53
+ const HR = /<hr\s*\/?>/i;
54
+ const LINE_BREAK = /<br\s*\/?>|<\/p>|<\/div>|<\/li>|<\/tr>/gi;
55
+ const TAGS = /<[^>]*>/g;
56
+ /**
57
+ * Annex C's ASCII guillemets, rescued before the markup strip runs.
58
+ *
59
+ * `<<use>>` survives the XML attribute as those six characters, and the next
60
+ * line would read `<use>` as a tag and throw the keyword away — which is how a
61
+ * `«use»` dependency silently became an association. Rewriting the pair into
62
+ * the real guillemets first is the fix and also the right normalisation:
63
+ * `stereotypesOf` reads both spellings, and this leaves exactly one of them in
64
+ * the string the tag strip then walks.
65
+ */
66
+ const ASCII_GUILLEMETS = /<<([^<>]{0,64})>>/g;
67
+ /** The one entity `decodeXmlText` leaves standing that a LABEL routinely has. */
68
+ const NBSP = /&nbsp;/gi;
69
+ /**
70
+ * A cell's `value` as COMPARTMENTS of lines — draw.io's UML class template,
71
+ * read.
72
+ *
73
+ * The template is `<p>Name</p><hr />attributes<hr />operations`, each member
74
+ * separated by `<br />`, and the whole of it HTML-escaped inside the `value`
75
+ * attribute. So: split on the rules, turn every block boundary into a newline,
76
+ * drop what is left of the markup, and decode what the markup was hiding.
77
+ *
78
+ * The second decode pass, after the tags come off, is deliberate rather than
79
+ * belt-and-braces: draw.io escapes the HTML into an XML attribute, so a `<` the
80
+ * author typed in a label is `&amp;lt;` in the file and one pass would leave
81
+ * them looking at `&lt;`.
82
+ *
83
+ * Blank lines and §9.2.4's elision marker are dropped, exactly as
84
+ * `parseCompartment` drops them for a compartment the author typed on the
85
+ * canvas: a file that wrote `...` back as a property named `...` would turn
86
+ * "there are more features, not shown" into "one of them is called dot dot
87
+ * dot".
88
+ */
89
+ export function drawioCompartments(value) {
90
+ const raw = value ?? '';
91
+ if (!raw.trim())
92
+ return [];
93
+ return raw
94
+ .split(HR)
95
+ .map(part => decodeXmlText(part
96
+ .replaceAll(LINE_BREAK, '\n')
97
+ .replaceAll(ASCII_GUILLEMETS, '«$1»')
98
+ .replaceAll(TAGS, '')
99
+ .replaceAll(NBSP, ' '))
100
+ .split('\n')
101
+ .map(line => line.trim())
102
+ .filter(line => line.length > 0 && line !== '...' && line !== '…'))
103
+ .filter(lines => lines.length > 0);
104
+ }
105
+ /**
106
+ * §17.6.4's operators, as draw.io writes them in a frame's label — `<<alt>>`,
107
+ * `alt`, `alt [x > 0]`.
108
+ *
109
+ * What separates a combined fragment from a plain `shape=umlFrame`, which
110
+ * draw.io also uses for a package and for a diagram frame: the first word of the
111
+ * label is one of the operators, and nothing else in the style says so.
112
+ */
113
+ const DRAWIO_FRAGMENT_OPERATORS = new Set([
114
+ 'alt',
115
+ 'opt',
116
+ 'loop',
117
+ 'par',
118
+ 'break',
119
+ 'critical',
120
+ 'seq',
121
+ 'strict',
122
+ 'neg',
123
+ 'assert',
124
+ 'ignore',
125
+ 'consider',
126
+ 'ref',
127
+ ]);
128
+ /** The operator a frame's label opens with, and the guard after it. */
129
+ function drawioFragmentLabel(value) {
130
+ const text = drawioCompartments(value).flat().join(' ').trim();
131
+ const opened = /^(?:«|<<)?\s*([A-Za-z]+)\s*(?:»|>>)?\s*(.*)$/.exec(text);
132
+ if (!opened)
133
+ return undefined;
134
+ const word = opened[1].toLowerCase();
135
+ if (!DRAWIO_FRAGMENT_OPERATORS.has(word))
136
+ return undefined;
137
+ const tail = opened[2].trim();
138
+ const bracketed = /^\[(.*)\]$/.exec(tail);
139
+ return {
140
+ operator: word,
141
+ guard: (bracketed ? bracketed[1] : tail).trim(),
142
+ };
143
+ }
144
+ /**
145
+ * The shape vocabulary, read off the style — the whole of this reader's
146
+ * heuristics, in one function, so ADR 0019's "what it guesses and how"
147
+ * paragraph has exactly one place to be true about.
148
+ *
149
+ * The order is load-bearing. The explicit UML shapes win over the geometric
150
+ * ones (`shape=umlActor` is drawn inside a rectangle; a note may also be an
151
+ * ellipse in somebody's stencil), and the CLASSIFIER test is last because it is
152
+ * the weakest: a box with compartments, a stack-laid swimlane with member rows,
153
+ * or a box whose first line carries an Annex C keyword.
154
+ */
155
+ function vertexKindOf(style, compartments, hasChildren, site = {
156
+ value: '',
157
+ onLifeline: false,
158
+ width: 0,
159
+ }) {
160
+ const shape = style.shape ?? '';
161
+ // §17's stencil first, and `umlLifeline` before everything: draw.io draws a
162
+ // lifeline as a container, so a reader that tested for a swimlane or for
163
+ // compartments first would call the participant a class.
164
+ if (shape === 'umllifeline' || 'umllifeline' in style)
165
+ return 'lifeline';
166
+ if (shape === 'umldestroy' || 'umldestroy' in style)
167
+ return 'destruction';
168
+ if (shape === 'umlactor' || 'umlactor' in style)
169
+ return 'actor';
170
+ if (shape === 'note' || 'note' in style)
171
+ return 'note';
172
+ if (shape === 'folder' || shape === 'package' || shape === 'umlframe') {
173
+ // §17.6.4's rectangle and §12.2.4's folder are the same `umlFrame` in this
174
+ // stencil; the operator written in the corner is the only thing that tells
175
+ // them apart, which is exactly what §17.6.4 says the pentagon is for.
176
+ return shape === 'umlframe' && drawioFragmentLabel(site.value)
177
+ ? 'fragment'
178
+ : 'package';
179
+ }
180
+ // §17.2.4's bar: a narrow rectangle drawn ON a lifeline. draw.io gives it no
181
+ // shape of its own — it is a plain box parented to the participant — so the
182
+ // reading is the one the notation gives: where it is, and how wide.
183
+ if (site.onLifeline && site.width > 0 && site.width <= 24)
184
+ return 'execution';
185
+ if ('ellipse' in style || shape === 'ellipse')
186
+ return 'use-case';
187
+ if ('swimlane' in style &&
188
+ style.childlayout === 'stacklayout' &&
189
+ hasChildren) {
190
+ return 'classifier';
191
+ }
192
+ if (compartments.length > 1)
193
+ return 'classifier';
194
+ if (compartments.length === 1) {
195
+ const { keywords } = stereotypesOf(compartments[0].join('\n'));
196
+ if (keywords.length > 0)
197
+ return 'classifier';
198
+ }
199
+ return 'unknown';
200
+ }
201
+ /** `«interface»` and `«enumeration»`, however the author spelled them. */
202
+ function classifierKindOf(keywords) {
203
+ const labels = keywords.map(keyword => keyword.trim().toLowerCase());
204
+ if (labels.includes('interface'))
205
+ return 'interface';
206
+ if (labels.includes('enumeration') || labels.includes('enum')) {
207
+ return 'enumeration';
208
+ }
209
+ return 'class';
210
+ }
211
+ /* ── What an edge IS ──────────────────────────────────────────────────── */
212
+ /**
213
+ * The relationship an edge's arrow ends state.
214
+ *
215
+ * Read in the order a UML reader's eye reads them, and the order matters twice:
216
+ *
217
+ * - the DIAMOND first, because draw.io's own aggregation style carries
218
+ * `dashed=1` as well (the corpus's `request` edge does) and a dependency
219
+ * test that ran first would call it a dependency;
220
+ * - then the block arrowhead, whatever its fill.
221
+ *
222
+ * ## `block` is the UML stencil's head; the fill is a drawing mistake
223
+ *
224
+ * The two ends read their fill differently, and the asymmetry is a fact about
225
+ * draw.io rather than an inconsistency (lead's ruling of 15/09/2026, recorded
226
+ * for the PO):
227
+ *
228
+ * - a DIAMOND means one of two UML relationships and the fill says which, so
229
+ * it is READ — `startFill=0` is §11.5.4's shared aggregation, and anything
230
+ * else, an absent fill included, is the composite one, because mxGraph
231
+ * resolves `startFill` / `endFill` with a default of 1 and paints it solid;
232
+ * - a BLOCK head means one UML relationship whatever its fill. Ordinary
233
+ * draw.io arrows are `classic` or `open`; `block` is what the UML stencil
234
+ * writes, and it writes `endFill=0` with it. A file that drew `block` and
235
+ * left the fill alone — draw.io's own UML class template does exactly that —
236
+ * is a generalization somebody drew slightly wrong, not an association.
237
+ * Reading the fill here would demote the one arrow the author most clearly
238
+ * meant.
239
+ *
240
+ * So a solid line with a block head is §9.2.4's generalization and a dashed one
241
+ * is §10.4.4's realization, and when the head is not hollow
242
+ * {@link isAmbiguousBlockHead} lets the caller say so in a remark — the kind is
243
+ * right, the DRAWING is not, and the author is the one who can fix it.
244
+ *
245
+ * Everything else is an association unless it is dashed, which is §7.8.4's
246
+ * dependency; the `«use»`, `«include»` and `«extend»` labels then choose
247
+ * between the three kinds that share the dashed-open notation. `«use»` is the
248
+ * plain dependency and needs no branch — it is what falls through.
249
+ */
250
+ function relationKindOf(style, keywords) {
251
+ const labels = keywords.map(keyword => keyword.trim().toLowerCase());
252
+ const dashed = style.dashed === '1';
253
+ const startArrow = style.startarrow ?? '';
254
+ const endArrow = style.endarrow ?? '';
255
+ if (startArrow === 'diamond' || startArrow === 'diamondthin') {
256
+ // Hollow is the SHARED aggregation; filled — and unspecified, which
257
+ // mxGraph draws filled — is the composite one.
258
+ return style.startfill === '0' ? 'aggregation' : 'composition';
259
+ }
260
+ if (endArrow === 'diamond' || endArrow === 'diamondthin') {
261
+ // The whole is at the TARGET end; {@link importDrawio} flips the ends, so
262
+ // the relation still records the whole as its source.
263
+ return style.endfill === '0' ? 'aggregation' : 'composition';
264
+ }
265
+ if (endArrow === 'block' || endArrow === 'blockthin') {
266
+ // Whatever the fill: `block` is the UML stencil's head, and a filled one is
267
+ // a triangle drawn wrong rather than a different relationship. See the
268
+ // header, and {@link isAmbiguousBlockHead} for what is said about it.
269
+ return dashed ? 'realization' : 'generalization';
270
+ }
271
+ if (labels.includes('include'))
272
+ return 'include';
273
+ if (labels.includes('extend'))
274
+ return 'extend';
275
+ if (dashed)
276
+ return 'dependency';
277
+ return 'association';
278
+ }
279
+ /**
280
+ * A block arrowhead drawn SOLID — read as the generalization it is, and
281
+ * remarked on because the file does not draw one.
282
+ *
283
+ * `endArrow=block` with no `endFill=0` renders as a filled triangle, and UML
284
+ * draws the generalization triangle hollow (§9.2.4). {@link relationKindOf}
285
+ * takes the head at its word regardless — `block` is the UML stencil's, and the
286
+ * alternative is demoting the one arrow whose meaning is least in doubt — so
287
+ * nothing about the BOARD is wrong here. What is wrong is the drawing the file
288
+ * came from, which is a thing only the author can fix and only if somebody
289
+ * tells them. Hence a remark rather than a different reading.
290
+ *
291
+ * The same question at the source end has the opposite answer, and the header
292
+ * says why: a diamond's fill picks between two UML relationships, so it is read.
293
+ */
294
+ function isAmbiguousBlockHead(style) {
295
+ const endArrow = style.endarrow ?? '';
296
+ return ((endArrow === 'block' || endArrow === 'blockthin') && style.endfill !== '0');
297
+ }
298
+ /** An aggregation read off the TARGET end has its whole at the wrong end. */
299
+ function wholeIsTarget(style) {
300
+ const startArrow = style.startarrow ?? '';
301
+ const endArrow = style.endarrow ?? '';
302
+ return (startArrow !== 'diamond' &&
303
+ startArrow !== 'diamondthin' &&
304
+ (endArrow === 'diamond' || endArrow === 'diamondthin'));
305
+ }
306
+ /**
307
+ * `x` on a relative geometry is a position ALONG the edge, from `-1` at the
308
+ * source to `+1` at the target. draw.io writes exactly `-1` and `1` for the two
309
+ * multiplicity slots and leaves a free-floating label near zero.
310
+ */
311
+ function labelEndOf(geometry) {
312
+ const x = Number.parseFloat(geometry?.attrs.x ?? '0');
313
+ if (!Number.isFinite(x) || x === 0)
314
+ return 'centre';
315
+ return x < 0 ? 'source' : 'target';
316
+ }
317
+ /**
318
+ * The `<mxCell>`s of a document, flattened, `<object>` wrappers folded in.
319
+ *
320
+ * `<object label="…" id="…"><mxCell …/></object>` is draw.io's spelling for a
321
+ * cell with custom properties, and it is the common one the moment anybody
322
+ * types into the Edit Data dialog. The identity and the label are the
323
+ * WRAPPER's, the style and the geometry the inner cell's — so a reader that
324
+ * walked `mxCell` alone would find a shape with no id and no name and carry the
325
+ * whole drawing.
326
+ */
327
+ function cellsOf(graph) {
328
+ const wrapped = new Map();
329
+ for (const node of xmlDescendants(graph)) {
330
+ if (node.local !== 'object' && node.local !== 'UserObject')
331
+ continue;
332
+ const inner = node.children.find(child => child.local === 'mxCell');
333
+ if (inner)
334
+ wrapped.set(inner, node);
335
+ }
336
+ return xmlDescendants(graph)
337
+ .filter(node => node.local === 'mxCell')
338
+ .map(node => {
339
+ const wrapper = wrapped.get(node);
340
+ const rawStyle = node.attrs.style ?? '';
341
+ return {
342
+ id: wrapper?.attrs.id ?? node.attrs.id ?? '',
343
+ value: wrapper?.attrs.label ?? node.attrs.value ?? '',
344
+ style: parseStyle(rawStyle),
345
+ rawStyle,
346
+ parent: node.attrs.parent ?? '',
347
+ vertex: node.attrs.vertex === '1',
348
+ edge: node.attrs.edge === '1',
349
+ source: node.attrs.source ?? '',
350
+ target: node.attrs.target ?? '',
351
+ geometry: node.children.find(child => child.local === 'mxGeometry'),
352
+ };
353
+ });
354
+ }
355
+ /**
356
+ * The one remark this reader raises whose wording is FIXED, as a
357
+ * `[key, English]` pair — the same table, the same shape and the same reasons
358
+ * as `UML_PLANTUML_REMARKS` (`plantuml-import.ts`, where the contract is
359
+ * written out): one entry per SHAPE of sentence, the `{{kind}}` hole filled at
360
+ * the call site by `InterchangeNote.messageParams`, the English kept here as
361
+ * the fallback.
362
+ *
363
+ * The reader's refusals ({@link importDrawio}'s `refused`) and its two
364
+ * {@link carried} fragments are deliberately NOT here: a remark that names
365
+ * something out of the file with no sentence shape worth sharing — a raw style
366
+ * dump, a one-off diagnostic — carries no key and stays English, which is what
367
+ * `InterchangeNote.messageKey`'s own contract asks for.
368
+ */
369
+ export const UML_DRAWIO_REMARKS = {
370
+ ambiguousBlockHead: [
371
+ 'com.labre.uml.import.drawio.ambiguous-block-head',
372
+ 'read as a {{kind}}, but the arrowhead is drawn filled; UML draws the generalization triangle hollow (endFill=0).',
373
+ ],
374
+ };
375
+ /* ── The reading ──────────────────────────────────────────────────────── */
376
+ const carried = (sourceId, element, message) => ({ kind: 'carried', sourceId, element, message });
377
+ /**
378
+ * A draw.io drawing as a UML model.
379
+ *
380
+ * Never throws. A file that is not a draw.io document at all comes back as an
381
+ * empty model with one `warning` note, which is the same answer as for a
382
+ * payload that is still compressed: this is the visual tier, the tier's promise
383
+ * is recognition, and "I recognised nothing" is an answer rather than a
384
+ * failure.
385
+ *
386
+ * @param xml a decoded `<mxGraphModel>`, or an `<mxfile>` whose `<diagram>`
387
+ * already holds one. See the module docblock for why the compressed form is
388
+ * the command's problem and not this function's.
389
+ * @param options.name what to call the sheet when the file names nothing — the
390
+ * importer's context name, which is the file's own name in practice.
391
+ */
392
+ export function importDrawio(xml, options = {}) {
393
+ const notes = [];
394
+ const refused = (message) => {
395
+ notes.push({ kind: 'warning', message });
396
+ return { model: emptyModel(options.name), notes, layout: { boxes: {} } };
397
+ };
398
+ if (isCompressedDrawio(xml)) {
399
+ return refused('compressed payload: decode first — this reader takes decoded mxGraphModel XML.');
400
+ }
401
+ const document = readXml(xml);
402
+ const root = document.root;
403
+ if (!root)
404
+ return refused('no XML in this file.');
405
+ const graph = root.local === 'mxGraphModel'
406
+ ? root
407
+ : xmlDescendants(root).find(node => node.local === 'mxGraphModel');
408
+ if (!graph)
409
+ return refused('no <mxGraphModel> in this file.');
410
+ const diagramName = root === graph
411
+ ? ''
412
+ : (xmlDescendants(root).find(node => node.local === 'diagram')?.attrs
413
+ .name ?? '');
414
+ const cells = cellsOf(graph);
415
+ const edgeIds = new Set(cells.filter(cell => cell.edge && cell.id).map(cell => cell.id));
416
+ // The two indexes the passes below read the tree through, built in one walk:
417
+ // a cell by its id, and the children of a cell by its parent's. A drawing is
418
+ // routinely a few thousand cells and both are asked for once per cell, so
419
+ // scanning the list for each would be the reader's only quadratic step.
420
+ const byId = new Map();
421
+ const childrenOf = new Map();
422
+ for (const cell of cells) {
423
+ if (cell.id && !byId.has(cell.id))
424
+ byId.set(cell.id, cell);
425
+ if (!cell.parent)
426
+ continue;
427
+ const siblings = childrenOf.get(cell.parent);
428
+ if (siblings)
429
+ siblings.push(cell);
430
+ else
431
+ childrenOf.set(cell.parent, [cell]);
432
+ }
433
+ const classifiers = [];
434
+ const actors = [];
435
+ const useCases = [];
436
+ const packages = [];
437
+ const umlNotes = [];
438
+ const relations = [];
439
+ // No prototype: the key is the cell id the FILE chose. On a plain object a
440
+ // cell whose id is `__proto__` rewrites the bag's own prototype, and a cell
441
+ // whose id is `toString` has the materializer hand a FUNCTION back as that
442
+ // shape's box — which is where the geometry becomes `NaN`.
443
+ const boxes = Object.create(null);
444
+ const lifelines = [];
445
+ const executions = [];
446
+ const destructions = [];
447
+ const fragments = [];
448
+ /** Every cell read as a participant — what a message's end must resolve to. */
449
+ const spineOf = new Map();
450
+ /* ── Vertices ───────────────────────────────────────────────────────── */
451
+ for (const cell of cells) {
452
+ if (!cell.vertex || !cell.id)
453
+ continue;
454
+ // A cell parented to an EDGE is that edge's label, not a shape of its own.
455
+ if (edgeIds.has(cell.parent))
456
+ continue;
457
+ // draw.io's two invisible roots — the model and its default layer.
458
+ if (cell.id === '0' || cell.id === '1')
459
+ continue;
460
+ // A member row of a stack-laid swimlane belongs to its lane, not the sheet.
461
+ const host = byId.get(cell.parent);
462
+ if (host?.vertex && 'swimlane' in host.style)
463
+ continue;
464
+ const children = (childrenOf.get(cell.id) ?? []).filter(child => child.vertex);
465
+ const compartments = drawioCompartments(cell.value);
466
+ // §17.2.4's bar is drawn INSIDE the participant's container, so its
467
+ // geometry is relative to it — the one place in this format where a box is
468
+ // not already in sheet coordinates.
469
+ const onLifeline = Boolean(host?.vertex) && spineOf.has(cell.parent);
470
+ const own = geometryOf(cell);
471
+ // The PARENT's own `mxGeometry`, not the box this reader gave it: a
472
+ // lifeline's box is rewritten below to the narrow column our element is,
473
+ // and a child's coordinates are relative to the CELL draw.io drew.
474
+ const parentBox = onLifeline
475
+ ? host
476
+ ? geometryOf(host)
477
+ : undefined
478
+ : undefined;
479
+ const geometry = own && parentBox
480
+ ? { ...own, x: own.x + parentBox.x, y: own.y + parentBox.y }
481
+ : own;
482
+ const kind = vertexKindOf(cell.style, compartments, children.length > 0, {
483
+ value: cell.value,
484
+ onLifeline,
485
+ width: own?.w ?? 0,
486
+ });
487
+ if (geometry)
488
+ boxes[cell.id] = geometry;
489
+ if (kind === 'unknown') {
490
+ notes.push(carried(cell.id, 'mxCell', `a shape with no UML artefact to be — style ${cell.rawStyle || '(none)'}`));
491
+ continue;
492
+ }
493
+ const header = stereotypesOf((compartments[0] ?? []).join('\n'));
494
+ const base = {
495
+ id: cell.id,
496
+ name: header.name,
497
+ keywords: header.keywords,
498
+ isAbstract: header.isAbstract,
499
+ ...(geometry ? { bounds: geometry } : {}),
500
+ };
501
+ switch (kind) {
502
+ case 'lifeline': {
503
+ // The ELEMENT is the spine, not the head draw.io drew: our lifeline is a
504
+ // narrow column whose perimeter is where a message attaches, and the
505
+ // head is painted across the top of it. So the column is centred on the
506
+ // cell and keeps its full height.
507
+ const ident = parseLifelineIdent(base.name);
508
+ const column = geometry
509
+ ? {
510
+ x: geometry.x + geometry.w / 2 - UML_NODE_BOX.lifeline.w / 2,
511
+ y: geometry.y,
512
+ w: UML_NODE_BOX.lifeline.w,
513
+ h: geometry.h,
514
+ }
515
+ : undefined;
516
+ if (column)
517
+ boxes[cell.id] = column;
518
+ lifelines.push({
519
+ ...base,
520
+ name: ident.selector
521
+ ? `${ident.name ?? ''}[${ident.selector}]`
522
+ : (ident.name ?? ''),
523
+ ...(ident.type ? { type: ident.type } : {}),
524
+ ...(column ? { bounds: column } : {}),
525
+ });
526
+ spineOf.set(cell.id, cell.id);
527
+ break;
528
+ }
529
+ case 'execution': {
530
+ executions.push({
531
+ ...base,
532
+ ...(geometry ? { bounds: geometry } : {}),
533
+ lifelineId: cell.parent,
534
+ y0: geometry ? geometry.y : 0,
535
+ y1: geometry ? geometry.y + geometry.h : 0,
536
+ });
537
+ spineOf.set(cell.id, cell.parent);
538
+ break;
539
+ }
540
+ case 'destruction': {
541
+ destructions.push({
542
+ ...base,
543
+ ...(geometry ? { bounds: geometry } : {}),
544
+ y: geometry ? geometry.y + geometry.h / 2 : 0,
545
+ });
546
+ break;
547
+ }
548
+ case 'fragment': {
549
+ const stated = drawioFragmentLabel(cell.value);
550
+ fragments.push({
551
+ ...base,
552
+ name: stated.guard,
553
+ operator: stated.operator,
554
+ // draw.io draws an operand separator as a dashed line the author put
555
+ // there by hand, with nothing in the file to say it is one. ONE band
556
+ // is the honest reading, and it is the one every `opt`, `loop` and
557
+ // `ref` has anyway.
558
+ operands: geometry
559
+ ? [
560
+ {
561
+ ...(stated.guard ? { guard: stated.guard } : {}),
562
+ y0: geometry.y,
563
+ y1: geometry.y + geometry.h,
564
+ },
565
+ ]
566
+ : [{ y0: 0, y1: 0 }],
567
+ coveredLifelineIds: [],
568
+ ...(geometry ? { bounds: geometry } : {}),
569
+ });
570
+ break;
571
+ }
572
+ case 'actor':
573
+ actors.push(base);
574
+ break;
575
+ case 'use-case':
576
+ useCases.push(base);
577
+ break;
578
+ case 'package':
579
+ packages.push(base);
580
+ break;
581
+ case 'note':
582
+ // A note is PROSE, not a name compartment: every line the box held,
583
+ // kept whole and in order.
584
+ umlNotes.push({
585
+ ...base,
586
+ name: '',
587
+ keywords: [],
588
+ body: compartments.flat().join('\n'),
589
+ });
590
+ break;
591
+ case 'classifier': {
592
+ // Two templates, one classifier. The HTML one separates the
593
+ // compartments with `<hr />`; the stack-laid swimlane draws each member
594
+ // as a child row and separates nothing, so the only distinction the
595
+ // notation still carries is §9.5.4's own — a member with a parameter
596
+ // list is an operation.
597
+ const rows = children.length > 0
598
+ ? children.flatMap(child => drawioCompartments(child.value).flat())
599
+ : undefined;
600
+ const attributes = [];
601
+ const operations = [];
602
+ for (const line of rows ?? compartments[1] ?? []) {
603
+ if (rows && line.includes('('))
604
+ operations.push(line);
605
+ else
606
+ attributes.push(line);
607
+ }
608
+ if (!rows)
609
+ operations.push(...(compartments[2] ?? []));
610
+ classifiers.push({
611
+ ...base,
612
+ kind: classifierKindOf(header.keywords),
613
+ attributes: attributes.map(line => parseProperty(line)),
614
+ operations: operations.map(line => parseOperation(line)),
615
+ slots: [],
616
+ lines: { attributes, operations },
617
+ });
618
+ break;
619
+ }
620
+ }
621
+ }
622
+ /* ── Edges ──────────────────────────────────────────────────────────── */
623
+ const drawn = new Set([
624
+ ...classifiers.map(entry => entry.id),
625
+ ...actors.map(entry => entry.id),
626
+ ...useCases.map(entry => entry.id),
627
+ ...packages.map(entry => entry.id),
628
+ ...umlNotes.map(entry => entry.id),
629
+ ]);
630
+ /** §17.4.4's arrows, in the order the file drew them down the sheet. */
631
+ const messages = [];
632
+ let unplaced = 0;
633
+ for (const cell of cells) {
634
+ if (!cell.edge || !cell.id)
635
+ continue;
636
+ // §17.4 — a line between two participants is a MESSAGE, whatever else the
637
+ // style says: on a sequence sheet there is no other kind of line.
638
+ if (spineOf.has(cell.source) && spineOf.has(cell.target)) {
639
+ const at = drawioEdgeHeight(cell);
640
+ messages.push({
641
+ id: cell.id,
642
+ kind: drawioMessageKind(cell.style),
643
+ sourceId: cell.source,
644
+ targetId: cell.target,
645
+ ...(drawioCompartments(cell.value).flat().join(' ').trim()
646
+ ? { label: drawioCompartments(cell.value).flat().join(' ').trim() }
647
+ : {}),
648
+ // A file that drew no waypoints has still stated an ORDER — the order
649
+ // its cells are written in — and that is what the slots stand in for.
650
+ y: at ?? umlSequenceSlot(unplaced++),
651
+ });
652
+ continue;
653
+ }
654
+ if (!drawn.has(cell.source) || !drawn.has(cell.target)) {
655
+ notes.push(carried(cell.id, 'mxCell', `an edge whose ends this reader did not draw (${cell.source || '?'} → ${cell.target || '?'})`));
656
+ continue;
657
+ }
658
+ const label = stereotypesOf(drawioCompartments(cell.value).flat().join('\n'));
659
+ const flip = wholeIsTarget(cell.style);
660
+ const relation = {
661
+ kind: relationKindOf(cell.style, label.keywords),
662
+ sourceId: flip ? cell.target : cell.source,
663
+ targetId: flip ? cell.source : cell.target,
664
+ ...(label.name ? { label: label.name } : {}),
665
+ };
666
+ relations.push(relation);
667
+ // A SOLID block head: read as the relationship it is, and named because the
668
+ // file draws it wrong — see {@link isAmbiguousBlockHead}.
669
+ if (isAmbiguousBlockHead(cell.style)) {
670
+ notes.push({
671
+ kind: 'warning',
672
+ sourceId: cell.id,
673
+ element: 'mxCell',
674
+ messageKey: UML_DRAWIO_REMARKS.ambiguousBlockHead[0],
675
+ message: `read as a ${relation.kind}, but the arrowhead is drawn filled; UML draws the generalization triangle hollow (endFill=0).`,
676
+ messageParams: { kind: relation.kind },
677
+ });
678
+ }
679
+ // The per-end labels — the `1`s of a multiplicity. Each one is pinned to an
680
+ // end by its relative geometry, and lands on the relation end DRAWN there
681
+ // (§11.5.4). `flip` is why the two are not simply "source" and "target": an
682
+ // aggregation read off the target end has already had its ends swapped, so
683
+ // the label written at the mxCell's source belongs to the relation's target.
684
+ for (const child of childrenOf.get(cell.id) ?? []) {
685
+ const text = drawioCompartments(child.value).flat().join(' ');
686
+ if (!text)
687
+ continue;
688
+ const at = labelEndOf(child.geometry);
689
+ if (at === 'centre') {
690
+ if (!relation.label)
691
+ relation.label = text;
692
+ continue;
693
+ }
694
+ const side = (at === 'source') !== flip
695
+ ? 'sourceEnd'
696
+ : 'targetEnd';
697
+ // FIRST wins: draw.io lets an author stack two labels on one end, and the
698
+ // board draws one label per end.
699
+ if (relation[side])
700
+ continue;
701
+ relation[side] = ADORNED_RELATION_KINDS.has(relation.kind)
702
+ ? parseEndLabel(text)
703
+ : { raw: text };
704
+ }
705
+ }
706
+ /* ── The sheet ──────────────────────────────────────────────────────── */
707
+ for (const note of document.notes) {
708
+ notes.push({ kind: 'warning', element: 'mxfile', message: note });
709
+ }
710
+ // ── The interaction, once every box is known ────────────────────────
711
+ //
712
+ // Two attributions by GEOMETRY, and the same two `model.ts` makes off the
713
+ // canvas: which spine a cross sits on, and which spines a fragment covers.
714
+ // draw.io states neither — a cross is a shape dropped on a line and a frame is
715
+ // a rectangle drawn round one — so the reading is the notation's own.
716
+ for (const record of destructions) {
717
+ const spine = umlLifelineAt(record.bounds, lifelines);
718
+ if (spine)
719
+ record.lifelineId = spine.id;
720
+ }
721
+ for (const record of fragments) {
722
+ const box = record.bounds;
723
+ if (!box)
724
+ continue;
725
+ record.coveredLifelineIds = lifelines
726
+ .filter(lifeline => {
727
+ const spine = lifeline.bounds
728
+ ? lifeline.bounds.x + lifeline.bounds.w / 2
729
+ : undefined;
730
+ return spine !== undefined && spine >= box.x && spine <= box.x + box.w;
731
+ })
732
+ .map(lifeline => lifeline.id);
733
+ }
734
+ messages.sort((a, b) => a.y - b.y);
735
+ const sheetName = diagramName || options.name;
736
+ const interactions = lifelines.length > 0 ||
737
+ messages.length > 0 ||
738
+ fragments.length > 0 ||
739
+ executions.length > 0 ||
740
+ destructions.length > 0
741
+ ? [
742
+ {
743
+ id: UML_DRAWIO_FORMAT_ID,
744
+ name: sheetName?.trim() || 'Imported diagram',
745
+ lifelines,
746
+ messages,
747
+ fragments,
748
+ executions,
749
+ destructions,
750
+ },
751
+ ]
752
+ : [];
753
+ const model = {
754
+ ...emptyModel(sheetName, interactions.length > 0 ? 'sd' : undefined),
755
+ classifiers,
756
+ packages,
757
+ actors,
758
+ useCases,
759
+ notes: umlNotes,
760
+ interactions,
761
+ relations,
762
+ };
763
+ return { model, notes, layout: { boxes: translate(boxes) } };
764
+ }
765
+ /**
766
+ * §17.4.4's arrow, read off the two things draw.io states about a line.
767
+ *
768
+ * The specification tells its three drawn messages apart by the LINE and the
769
+ * HEAD, and the stencil writes exactly those: a dashed line is a reply, an open
770
+ * head is an asynchronous send, and the filled block head the stencil uses by
771
+ * default is a synchronous call. `create` and `delete` share a drawing with two
772
+ * of the three and are told apart by what the arrow REACHES, which is geometry
773
+ * this tier does not read (ADR 0019) — so an arrow onto a head comes back as the
774
+ * call it looks like, and the author retypes it.
775
+ */
776
+ function drawioMessageKind(style) {
777
+ if (style.dashed === '1')
778
+ return 'message-reply';
779
+ const endArrow = style.endarrow ?? '';
780
+ if (endArrow === 'open' ||
781
+ endArrow === 'openThin' ||
782
+ endArrow === 'halfCircle') {
783
+ return 'message-async';
784
+ }
785
+ return 'message-sync';
786
+ }
787
+ /**
788
+ * The HEIGHT one message edge was drawn at — the midpoint of its two endpoints.
789
+ *
790
+ * `<mxPoint as="sourcePoint">` is where draw.io records an end that is pinned to
791
+ * a coordinate rather than to a cell, and on a sequence diagram that is every
792
+ * end: a message is attached to a lifeline at a HEIGHT, and the height is the
793
+ * only thing about the arrow that carries meaning (§17.4.4).
794
+ */
795
+ function drawioEdgeHeight(cell) {
796
+ const geometry = cell.geometry;
797
+ if (!geometry)
798
+ return undefined;
799
+ const heights = [];
800
+ for (const point of geometry.children) {
801
+ if (point.local !== 'mxPoint')
802
+ continue;
803
+ const at = point.attrs.as;
804
+ if (at !== 'sourcePoint' && at !== 'targetPoint')
805
+ continue;
806
+ const y = Number.parseFloat(point.attrs.y ?? '');
807
+ if (Number.isFinite(y))
808
+ heights.push(y);
809
+ }
810
+ if (heights.length === 0)
811
+ return undefined;
812
+ return heights.reduce((sum, y) => sum + y, 0) / heights.length;
813
+ }
814
+ /* ── Helpers ──────────────────────────────────────────────────────────── */
815
+ /** `<mxGeometry x y width height>`, when the cell carries a drawable one. */
816
+ function geometryOf(cell) {
817
+ const geometry = cell.geometry;
818
+ if (!geometry)
819
+ return undefined;
820
+ const w = Number.parseFloat(geometry.attrs.width ?? '');
821
+ const h = Number.parseFloat(geometry.attrs.height ?? '');
822
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
823
+ return undefined;
824
+ }
825
+ const x = Number.parseFloat(geometry.attrs.x ?? '0');
826
+ const y = Number.parseFloat(geometry.attrs.y ?? '0');
827
+ return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0, w, h };
828
+ }
829
+ /** The drawing's own top-left corner to `(0, 0)` — see {@link UmlDrawioLayout}. */
830
+ function translate(boxes) {
831
+ const entries = Object.entries(boxes);
832
+ if (entries.length === 0)
833
+ return boxes;
834
+ const minX = Math.min(...entries.map(([, box]) => box.x));
835
+ const minY = Math.min(...entries.map(([, box]) => box.y));
836
+ // `Object.fromEntries` would give the moved bag a prototype back, and the ids
837
+ // in it are still the file's — so it is filled by hand instead.
838
+ const moved = Object.create(null);
839
+ for (const [id, box] of entries) {
840
+ moved[id] = { ...box, x: box.x - minX, y: box.y - minY };
841
+ }
842
+ return moved;
843
+ }
844
+ const DIAGRAM_KIND = 'class';
845
+ /**
846
+ * A sheet with nothing on it.
847
+ *
848
+ * `class` for the frame, and it is a statement rather than a default: draw.io
849
+ * has no frame kind to read, and every UML shape this reader recognises — a
850
+ * compartmented box, a hollow triangle, a diamond — belongs to §9's class
851
+ * family. A use-case-only drawing lands on a class sheet and the author retags
852
+ * the frame, which is one click and honest; guessing the kind from a majority
853
+ * of shapes would be a second heuristic stacked on the first.
854
+ */
855
+ function emptyModel(name,
856
+ // The one kind this reader can name off the STENCIL rather than off a
857
+ // majority of shapes: `shape=umlLifeline` is drawn on a sequence diagram and
858
+ // nowhere else, so a drawing that holds one is an `sd` sheet and saying so
859
+ // costs the author a retag they would otherwise have to make.
860
+ kind = DIAGRAM_KIND) {
861
+ const label = name?.trim() || 'Imported diagram';
862
+ return {
863
+ diagram: {
864
+ id: UML_DRAWIO_FORMAT_ID,
865
+ kind,
866
+ name: label,
867
+ heading: `${UML_DIAGRAM_KIND_TAG[kind] ?? kind} ${label}`,
868
+ },
869
+ classifiers: [],
870
+ packages: [],
871
+ actors: [],
872
+ useCases: [],
873
+ subjects: [],
874
+ notes: [],
875
+ components: [],
876
+ ports: [],
877
+ artifacts: [],
878
+ nodes: [],
879
+ activities: [],
880
+ stateMachines: [],
881
+ interactions: [],
882
+ relations: [],
883
+ warnings: [],
884
+ };
885
+ }