@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.
- package/LICENSE +384 -0
- package/dist/actions.d.ts +430 -0
- package/dist/actions.js +724 -0
- package/dist/background.d.ts +153 -0
- package/dist/background.js +557 -0
- package/dist/board-hit.d.ts +137 -0
- package/dist/board-hit.js +191 -0
- package/dist/commands-manifest.d.ts +19 -0
- package/dist/commands-manifest.js +571 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +983 -0
- package/dist/component.d.ts +521 -0
- package/dist/component.js +708 -0
- package/dist/consts.d.ts +365 -0
- package/dist/consts.js +463 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/drawio-decode.d.ts +32 -0
- package/dist/drawio-decode.js +96 -0
- package/dist/drawio-import.d.ts +133 -0
- package/dist/drawio-import.js +885 -0
- package/dist/edge-morph.d.ts +154 -0
- package/dist/edge-morph.js +153 -0
- package/dist/edge-styles.d.ts +78 -0
- package/dist/edge-styles.js +210 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +64 -0
- package/dist/element-renderer.js +114 -0
- package/dist/element-view.d.ts +234 -0
- package/dist/element-view.js +430 -0
- package/dist/export.d.ts +61 -0
- package/dist/export.js +41 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +26 -0
- package/dist/frame-tag.d.ts +70 -0
- package/dist/frame-tag.js +78 -0
- package/dist/grammar.d.ts +695 -0
- package/dist/grammar.js +1436 -0
- package/dist/import.d.ts +306 -0
- package/dist/import.js +1270 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/interchange.d.ts +125 -0
- package/dist/interchange.js +372 -0
- package/dist/keywords.d.ts +204 -0
- package/dist/keywords.js +361 -0
- package/dist/kinds.d.ts +73 -0
- package/dist/kinds.js +91 -0
- package/dist/legend.d.ts +30 -0
- package/dist/legend.js +255 -0
- package/dist/model.d.ts +862 -0
- package/dist/model.js +1316 -0
- package/dist/morph.d.ts +178 -0
- package/dist/morph.js +496 -0
- package/dist/node/act-glyphs.d.ts +30 -0
- package/dist/node/act-glyphs.js +142 -0
- package/dist/node/compartment-watcher.d.ts +107 -0
- package/dist/node/compartment-watcher.js +163 -0
- package/dist/node/fit.d.ts +85 -0
- package/dist/node/fit.js +129 -0
- package/dist/node/node-renderer.d.ts +49 -0
- package/dist/node/node-renderer.js +611 -0
- package/dist/node/node-view.d.ts +51 -0
- package/dist/node/node-view.js +89 -0
- package/dist/node/paint.d.ts +84 -0
- package/dist/node/paint.js +143 -0
- package/dist/node/sd-glyphs.d.ts +36 -0
- package/dist/node/sd-glyphs.js +79 -0
- package/dist/node/stm-glyphs.d.ts +22 -0
- package/dist/node/stm-glyphs.js +111 -0
- package/dist/node/tier-metrics.d.ts +38 -0
- package/dist/node/tier-metrics.js +45 -0
- package/dist/operators.d.ts +45 -0
- package/dist/operators.js +75 -0
- package/dist/plantuml-import.d.ts +113 -0
- package/dist/plantuml-import.js +1723 -0
- package/dist/plantuml.d.ts +94 -0
- package/dist/plantuml.js +985 -0
- package/dist/presets.d.ts +149 -0
- package/dist/presets.js +302 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +358 -0
- package/dist/reading.d.ts +160 -0
- package/dist/reading.js +648 -0
- package/dist/roles.d.ts +257 -0
- package/dist/roles.js +997 -0
- package/dist/rules.d.ts +267 -0
- package/dist/rules.js +3090 -0
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +197 -0
- package/dist/toolbar/config.d.ts +228 -0
- package/dist/toolbar/config.js +833 -0
- package/dist/toolbar/icons.d.ts +440 -0
- package/dist/toolbar/icons.js +711 -0
- package/dist/toolbar/senior-tool.d.ts +11 -0
- package/dist/toolbar/senior-tool.js +21 -0
- package/dist/toolbar/uml-menu.d.ts +17 -0
- package/dist/toolbar/uml-menu.js +20 -0
- package/dist/toolbar/uml-senior-button.d.ts +18 -0
- package/dist/toolbar/uml-senior-button.js +22 -0
- package/dist/translations.d.ts +27 -0
- package/dist/translations.js +117 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +236 -0
- package/dist/xmi-import.d.ts +174 -0
- package/dist/xmi-import.js +2268 -0
- package/dist/xmi.d.ts +61 -0
- package/dist/xmi.js +1860 -0
- package/dist/xml-reader.d.ts +157 -0
- package/dist/xml-reader.js +430 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.js +96 -0
- 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 = / /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 `&lt;` in the file and one pass would leave
|
|
81
|
+
* them looking at `<`.
|
|
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
|
+
}
|