@formicoidea/labre-framework-uml 0.49.0 → 0.51.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/dist/consts.d.ts CHANGED
@@ -96,7 +96,7 @@ export declare const UML_DIAGRAM_MARGIN = 24;
96
96
  * pack's metrics, exactly as `consts.ts` in the C4 pack re-exports
97
97
  * `C4_BOARD_TITLE_BAND_HEIGHT`.
98
98
  */
99
- export { UML_FRAME_BAND_HEIGHT } from '@formicoidea/labre-core/model';
99
+ export { UML_FRAME_BAND_HEIGHT } from '@formicoidea/labre-core/model/headless';
100
100
  /** The size a fresh diagram frame is created at — a Context Map sheet's own. */
101
101
  export declare const UML_DIAGRAM_BOX: {
102
102
  readonly w: 1400;
@@ -246,7 +246,7 @@ export declare const UML_REGION_RADIUS = 16;
246
246
  * Re-exported here so this file stays the one place a reader looks for the
247
247
  * pack's metrics, exactly as {@link UML_FRAME_BAND_HEIGHT} is.
248
248
  */
249
- export { UML_PARTITION_BAND, UML_REGION_BAND } from '@formicoidea/labre-core/model';
249
+ export { UML_PARTITION_BAND, UML_REGION_BAND, } from '@formicoidea/labre-core/model/headless';
250
250
  /**
251
251
  * The lifeline's head box and the height of its column, owned by
252
252
  * `@labre/affine-model` because the ELEMENT's own `elementBound` and
@@ -257,7 +257,7 @@ export { UML_PARTITION_BAND, UML_REGION_BAND } from '@formicoidea/labre-core/mod
257
257
  * Re-exported here so this file stays the one place a reader looks for the
258
258
  * pack's metrics, exactly as {@link UML_FRAME_BAND_HEIGHT} is.
259
259
  */
260
- export { UML_LIFELINE_HEAD, UML_LIFELINE_SPINE } from '@formicoidea/labre-core/model';
260
+ export { UML_LIFELINE_HEAD, UML_LIFELINE_SPINE, } from '@formicoidea/labre-core/model/headless';
261
261
  /**
262
262
  * The dash pattern of a lifeline's SPINE, in model units (§17.3.4).
263
263
  *
@@ -304,7 +304,7 @@ export declare const UML_FRAGMENT_OPERAND_DASH: readonly [10, 6];
304
304
  *
305
305
  * Re-exported for the reason every other band is.
306
306
  */
307
- export { UML_FRAGMENT_BAND } from '@formicoidea/labre-core/model';
307
+ export { UML_FRAGMENT_BAND } from '@formicoidea/labre-core/model/headless';
308
308
  /**
309
309
  * The guard written in an operand's own corner, and its inset from the
310
310
  * operand's top-left.
package/dist/consts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // The lifeline's column height is a MODEL constant (the element's own bound
2
2
  // override reads it), and it is also this file's footprint for the kind — so it
3
3
  // is imported as well as re-exported below.
4
- import { UML_LIFELINE_SPINE } from '@formicoidea/labre-core/model';
5
- import { NOTATION_NEUTRALS } from '@formicoidea/labre-core/shared/consts';
4
+ import { UML_LIFELINE_SPINE } from '@formicoidea/labre-core/model/headless';
5
+ import { NOTATION_NEUTRALS } from '@formicoidea/labre-core/shared/headless';
6
6
  /**
7
7
  * Visual constants for the UML pack.
8
8
  *
@@ -104,7 +104,7 @@ export const UML_DIAGRAM_MARGIN = 24;
104
104
  * pack's metrics, exactly as `consts.ts` in the C4 pack re-exports
105
105
  * `C4_BOARD_TITLE_BAND_HEIGHT`.
106
106
  */
107
- export { UML_FRAME_BAND_HEIGHT } from '@formicoidea/labre-core/model';
107
+ export { UML_FRAME_BAND_HEIGHT } from '@formicoidea/labre-core/model/headless';
108
108
  /** The size a fresh diagram frame is created at — a Context Map sheet's own. */
109
109
  export const UML_DIAGRAM_BOX = { w: 1400, h: 900 };
110
110
  /**
@@ -345,7 +345,7 @@ export const UML_REGION_RADIUS = 16;
345
345
  * Re-exported here so this file stays the one place a reader looks for the
346
346
  * pack's metrics, exactly as {@link UML_FRAME_BAND_HEIGHT} is.
347
347
  */
348
- export { UML_PARTITION_BAND, UML_REGION_BAND } from '@formicoidea/labre-core/model';
348
+ export { UML_PARTITION_BAND, UML_REGION_BAND, } from '@formicoidea/labre-core/model/headless';
349
349
  /* ── The interaction artefacts (phase 3) ───────────────────────────────── */
350
350
  /**
351
351
  * The lifeline's head box and the height of its column, owned by
@@ -357,7 +357,7 @@ export { UML_PARTITION_BAND, UML_REGION_BAND } from '@formicoidea/labre-core/mod
357
357
  * Re-exported here so this file stays the one place a reader looks for the
358
358
  * pack's metrics, exactly as {@link UML_FRAME_BAND_HEIGHT} is.
359
359
  */
360
- export { UML_LIFELINE_HEAD, UML_LIFELINE_SPINE } from '@formicoidea/labre-core/model';
360
+ export { UML_LIFELINE_HEAD, UML_LIFELINE_SPINE, } from '@formicoidea/labre-core/model/headless';
361
361
  /**
362
362
  * The dash pattern of a lifeline's SPINE, in model units (§17.3.4).
363
363
  *
@@ -401,7 +401,7 @@ export const UML_FRAGMENT_OPERAND_DASH = [10, 6];
401
401
  *
402
402
  * Re-exported for the reason every other band is.
403
403
  */
404
- export { UML_FRAGMENT_BAND } from '@formicoidea/labre-core/model';
404
+ export { UML_FRAGMENT_BAND } from '@formicoidea/labre-core/model/headless';
405
405
  /**
406
406
  * The guard written in an operand's own corner, and its inset from the
407
407
  * operand's top-left.
@@ -1,3 +1,34 @@
1
+ /**
2
+ * The most a `<diagram>` payload may inflate to: 20 MiB.
3
+ *
4
+ * A raw deflate stream compresses a run of one byte about a thousand to one,
5
+ * so the size of the FILE says nothing about the size of what it holds: the
6
+ * P0 import spike (labre `docs/spikes/2026-10-p0-import-edge-function.md`)
7
+ * inflated a 398 KiB `.drawio` to 300 MiB and lost the isolate to it, and an
8
+ * 85 KiB one to 64 MiB in 624 ms. The real corpus is three orders of magnitude
9
+ * below the ceiling (`drawio-class-iwlayer` inflates to 12 KiB), and a drawing
10
+ * twenty megabytes of XML long is already more than the canvas draws usefully.
11
+ *
12
+ * Counted on the INFLATED BYTES as they stream out, never on the payload: the
13
+ * compressed size is the attacker's number, the inflated one is ours. The
14
+ * stream is cancelled the moment the count passes the ceiling, so the memory
15
+ * the decode can take is bounded by the ceiling plus one chunk.
16
+ */
17
+ export declare const DRAWIO_MAX_INFLATED_BYTES: number;
18
+ /**
19
+ * The decoder's four refusals, as `[key, English]` pairs (ADR 0023): every one
20
+ * is thrown as an {@link InterchangeImportError} carrying the key, so a host
21
+ * that must not echo a reader's sentence (an HTTP response, a log) has a
22
+ * STATIC identifier to hand on instead, and the editor's catch site translates
23
+ * it exactly as it translates a reader's own refusal. None takes a parameter:
24
+ * nothing out of the file is ever put into one.
25
+ */
26
+ export declare const UML_DRAWIO_DECODE_ERRORS: {
27
+ readonly noDecompression: readonly ["com.labre.uml.import.drawio.error.no-decompression", "This runtime cannot read a compressed .drawio file (DecompressionStream is unavailable). Re-save it from draw.io with File › Properties › Compressed turned off."];
28
+ readonly emptyDiagram: readonly ["com.labre.uml.import.drawio.error.empty-diagram", "This .drawio file holds no <diagram> to read."];
29
+ readonly badPayload: readonly ["com.labre.uml.import.drawio.error.bad-payload", "This .drawio file’s <diagram> is neither XML nor a payload this reader could inflate."];
30
+ readonly tooLarge: readonly ["com.labre.uml.import.drawio.error.too-large", "This .drawio file’s <diagram> inflates past the size this reader accepts."];
31
+ };
1
32
  /**
2
33
  * The sentence a host sees when the runtime cannot inflate.
3
34
  *
@@ -5,9 +36,10 @@
5
36
  * of it, and so a host that wants to offer "open it in draw.io and save it
6
37
  * uncompressed" has something to match on.
7
38
  */
8
- export declare const DRAWIO_NO_DECOMPRESSION = "This runtime cannot read a compressed .drawio file (DecompressionStream is unavailable). Re-save it from draw.io with File \u203A Properties \u203A Compressed turned off.";
9
- export declare const DRAWIO_EMPTY_DIAGRAM = "This .drawio file holds no <diagram> to read.";
10
- export declare const DRAWIO_BAD_PAYLOAD = "This .drawio file\u2019s <diagram> is neither XML nor a payload this reader could inflate.";
39
+ export declare const DRAWIO_NO_DECOMPRESSION: "This runtime cannot read a compressed .drawio file (DecompressionStream is unavailable). Re-save it from draw.io with File › Properties › Compressed turned off.";
40
+ export declare const DRAWIO_EMPTY_DIAGRAM: "This .drawio file holds no <diagram> to read.";
41
+ export declare const DRAWIO_BAD_PAYLOAD: "This .drawio file’s <diagram> is neither XML nor a payload this reader could inflate.";
42
+ export declare const DRAWIO_TOO_LARGE: "This .drawio file’s <diagram> inflates past the size this reader accepts.";
11
43
  /**
12
44
  * An `.drawio` file's text as the `<mxGraphModel>` XML inside it.
13
45
  *
@@ -23,7 +55,9 @@ export declare const DRAWIO_BAD_PAYLOAD = "This .drawio file\u2019s <diagram> is
23
55
  * page ever was. The remaining pages are what a second import of the same file
24
56
  * is for.
25
57
  *
26
- * Throws, with one of the three sentences above, when it cannot. That is the
58
+ * Throws an {@link InterchangeImportError}, with one of the four refusals
59
+ * above, when it cannot — the fourth being a payload that inflates past
60
+ * {@link DRAWIO_MAX_INFLATED_BYTES}. That is the
27
61
  * contract the import pipeline already has for a file it cannot read
28
62
  * (`importInterchangeFile` shows the thrown sentence as the failure
29
63
  * notification), and it is the right one here: an empty board with a `warning`
@@ -1,3 +1,4 @@
1
+ import { InterchangeImportError } from '@formicoidea/labre-core/interchange';
1
2
  import { isCompressedDrawio } from './drawio-import.js';
2
3
  /**
3
4
  * The one impure step of the draw.io import: inflating an `<mxfile>`'s payload
@@ -34,8 +35,69 @@ import { isCompressedDrawio } from './drawio-import.js';
34
35
  * Where the API is genuinely absent the failure is NAMED — see below — rather
35
36
  * than left as a `TypeError` about an undefined constructor.
36
37
  */
37
- /** `<diagram …>PAYLOAD</diagram>` — the first page of an `mxfile`. */
38
- const DIAGRAM = /<diagram\b[^>]*>([\s\S]*?)<\/diagram>/i;
38
+ /**
39
+ * `PAYLOAD` in `<diagram …>PAYLOAD</diagram>`, the first page of an `mxfile`.
40
+ *
41
+ * Two linear searches rather than one lazy regex: `[\s\S]*?` re-scanned the
42
+ * rest of the file from every `<diagram` that never closed, 13.7 s for 352 KiB
43
+ * (security review, 2026-10-14). Only the first open tag can match: when it
44
+ * has no `>` or no close after it, no later one has either.
45
+ */
46
+ function diagramPayload(text) {
47
+ const open = /<diagram\b/i.exec(text);
48
+ if (!open)
49
+ return undefined;
50
+ const start = text.indexOf('>', open.index);
51
+ if (start < 0)
52
+ return undefined;
53
+ const close = /<\/diagram>/gi;
54
+ close.lastIndex = start + 1;
55
+ const end = close.exec(text);
56
+ return end ? text.slice(start + 1, end.index) : undefined;
57
+ }
58
+ /**
59
+ * The most a `<diagram>` payload may inflate to: 20 MiB.
60
+ *
61
+ * A raw deflate stream compresses a run of one byte about a thousand to one,
62
+ * so the size of the FILE says nothing about the size of what it holds: the
63
+ * P0 import spike (labre `docs/spikes/2026-10-p0-import-edge-function.md`)
64
+ * inflated a 398 KiB `.drawio` to 300 MiB and lost the isolate to it, and an
65
+ * 85 KiB one to 64 MiB in 624 ms. The real corpus is three orders of magnitude
66
+ * below the ceiling (`drawio-class-iwlayer` inflates to 12 KiB), and a drawing
67
+ * twenty megabytes of XML long is already more than the canvas draws usefully.
68
+ *
69
+ * Counted on the INFLATED BYTES as they stream out, never on the payload: the
70
+ * compressed size is the attacker's number, the inflated one is ours. The
71
+ * stream is cancelled the moment the count passes the ceiling, so the memory
72
+ * the decode can take is bounded by the ceiling plus one chunk.
73
+ */
74
+ export const DRAWIO_MAX_INFLATED_BYTES = 20 * 1024 * 1024;
75
+ /**
76
+ * The decoder's four refusals, as `[key, English]` pairs (ADR 0023): every one
77
+ * is thrown as an {@link InterchangeImportError} carrying the key, so a host
78
+ * that must not echo a reader's sentence (an HTTP response, a log) has a
79
+ * STATIC identifier to hand on instead, and the editor's catch site translates
80
+ * it exactly as it translates a reader's own refusal. None takes a parameter:
81
+ * nothing out of the file is ever put into one.
82
+ */
83
+ export const UML_DRAWIO_DECODE_ERRORS = {
84
+ noDecompression: [
85
+ 'com.labre.uml.import.drawio.error.no-decompression',
86
+ 'This runtime cannot read a compressed .drawio file (DecompressionStream is unavailable). Re-save it from draw.io with File › Properties › Compressed turned off.',
87
+ ],
88
+ emptyDiagram: [
89
+ 'com.labre.uml.import.drawio.error.empty-diagram',
90
+ 'This .drawio file holds no <diagram> to read.',
91
+ ],
92
+ badPayload: [
93
+ 'com.labre.uml.import.drawio.error.bad-payload',
94
+ 'This .drawio file’s <diagram> is neither XML nor a payload this reader could inflate.',
95
+ ],
96
+ tooLarge: [
97
+ 'com.labre.uml.import.drawio.error.too-large',
98
+ 'This .drawio file’s <diagram> inflates past the size this reader accepts.',
99
+ ],
100
+ };
39
101
  /**
40
102
  * The sentence a host sees when the runtime cannot inflate.
41
103
  *
@@ -43,9 +105,48 @@ const DIAGRAM = /<diagram\b[^>]*>([\s\S]*?)<\/diagram>/i;
43
105
  * of it, and so a host that wants to offer "open it in draw.io and save it
44
106
  * uncompressed" has something to match on.
45
107
  */
46
- export const DRAWIO_NO_DECOMPRESSION = 'This runtime cannot read a compressed .drawio file (DecompressionStream is unavailable). Re-save it from draw.io with File › Properties › Compressed turned off.';
47
- export const DRAWIO_EMPTY_DIAGRAM = 'This .drawio file holds no <diagram> to read.';
48
- export const DRAWIO_BAD_PAYLOAD = 'This .drawio file’s <diagram> is neither XML nor a payload this reader could inflate.';
108
+ export const DRAWIO_NO_DECOMPRESSION = UML_DRAWIO_DECODE_ERRORS.noDecompression[1];
109
+ export const DRAWIO_EMPTY_DIAGRAM = UML_DRAWIO_DECODE_ERRORS.emptyDiagram[1];
110
+ export const DRAWIO_BAD_PAYLOAD = UML_DRAWIO_DECODE_ERRORS.badPayload[1];
111
+ export const DRAWIO_TOO_LARGE = UML_DRAWIO_DECODE_ERRORS.tooLarge[1];
112
+ const refusal = (which) => {
113
+ const [key, message] = UML_DRAWIO_DECODE_ERRORS[which];
114
+ return new InterchangeImportError(message, { messageKey: key });
115
+ };
116
+ /** The ceiling was passed — a sentinel, so the `catch` below can tell it apart. */
117
+ class InflateCeiling extends Error {
118
+ }
119
+ /**
120
+ * Read a byte stream to its end, refusing past `limit` bytes.
121
+ *
122
+ * `new Response(stream).text()` buffers whatever the stream yields, which is
123
+ * the unbounded read the ceiling exists to replace.
124
+ */
125
+ async function readCapped(stream, limit) {
126
+ const reader = stream.getReader();
127
+ const chunks = [];
128
+ let total = 0;
129
+ for (;;) {
130
+ const { done, value } = await reader.read();
131
+ if (done)
132
+ break;
133
+ total += value.byteLength;
134
+ if (total > limit) {
135
+ // Stops the inflater, rather than letting it run to the end of a stream
136
+ // nobody will read.
137
+ await reader.cancel().catch(() => undefined);
138
+ throw new InflateCeiling();
139
+ }
140
+ chunks.push(value);
141
+ }
142
+ const bytes = new Uint8Array(total);
143
+ let offset = 0;
144
+ for (const chunk of chunks) {
145
+ bytes.set(chunk, offset);
146
+ offset += chunk.byteLength;
147
+ }
148
+ return bytes;
149
+ }
49
150
  /**
50
151
  * An `.drawio` file's text as the `<mxGraphModel>` XML inside it.
51
152
  *
@@ -61,7 +162,9 @@ export const DRAWIO_BAD_PAYLOAD = 'This .drawio file’s <diagram> is neither XM
61
162
  * page ever was. The remaining pages are what a second import of the same file
62
163
  * is for.
63
164
  *
64
- * Throws, with one of the three sentences above, when it cannot. That is the
165
+ * Throws an {@link InterchangeImportError}, with one of the four refusals
166
+ * above, when it cannot — the fourth being a payload that inflates past
167
+ * {@link DRAWIO_MAX_INFLATED_BYTES}. That is the
65
168
  * contract the import pipeline already has for a file it cannot read
66
169
  * (`importInterchangeFile` shows the thrown sentence as the failure
67
170
  * notification), and it is the right one here: an empty board with a `warning`
@@ -70,12 +173,12 @@ export const DRAWIO_BAD_PAYLOAD = 'This .drawio file’s <diagram> is neither XM
70
173
  export async function decodeDrawio(text) {
71
174
  if (!isCompressedDrawio(text))
72
175
  return text;
73
- const payload = DIAGRAM.exec(text)?.[1]?.trim();
176
+ const payload = diagramPayload(text)?.trim();
74
177
  if (!payload)
75
- throw new Error(DRAWIO_EMPTY_DIAGRAM);
178
+ throw refusal('emptyDiagram');
76
179
  const Decompression = globalThis.DecompressionStream;
77
180
  if (!Decompression)
78
- throw new Error(DRAWIO_NO_DECOMPRESSION);
181
+ throw refusal('noDecompression');
79
182
  let inflated;
80
183
  try {
81
184
  const binary = atob(payload);
@@ -85,12 +188,14 @@ export async function decodeDrawio(text) {
85
188
  .pipeThrough(new Decompression('deflate-raw'));
86
189
  // draw.io deflates the URI-ENCODED xml, so this is percent-escaped text and
87
190
  // not the document yet.
88
- inflated = decodeURIComponent(await new Response(stream).text());
191
+ inflated = decodeURIComponent(new TextDecoder().decode(await readCapped(stream, DRAWIO_MAX_INFLATED_BYTES)));
89
192
  }
90
- catch {
91
- throw new Error(DRAWIO_BAD_PAYLOAD);
193
+ catch (error) {
194
+ if (error instanceof InflateCeiling)
195
+ throw refusal('tooLarge');
196
+ throw refusal('badPayload');
92
197
  }
93
198
  if (!inflated.includes('<mxGraphModel'))
94
- throw new Error(DRAWIO_BAD_PAYLOAD);
199
+ throw refusal('badPayload');
95
200
  return inflated;
96
201
  }
@@ -1,5 +1,5 @@
1
1
  import type { InterchangeNote } from '@formicoidea/labre-core/blocks/surface';
2
- import type { UmlBox } from './component.js';
2
+ import { type UmlBox } from './component.js';
3
3
  import type { UmlModel } from './model.js';
4
4
  /**
5
5
  * A draw.io drawing, read as a UML model — the **visual** tier of
@@ -1,9 +1,11 @@
1
- import { UML_DIAGRAM_KIND_TAG } from '@formicoidea/labre-core/model';
1
+ import { UML_DIAGRAM_KIND_TAG } from '@formicoidea/labre-core/model/headless';
2
+ import { umlComponentSiblings, } from './component.js';
2
3
  import { UML_NODE_BOX } from './consts.js';
3
4
  import { parseEndLabel, parseLifelineIdent, parseOperation, parseProperty, } from './grammar.js';
4
5
  import { umlSequenceSlot } from './import.js';
5
6
  import { stereotypesOf } from './keywords.js';
6
- import { ADORNED_RELATION_KINDS, umlLifelineAt } from './model.js';
7
+ import { ADORNED_RELATION_KINDS, umlLifelineAt, umlRelationKindOfRole, } from './model.js';
8
+ import { UML_ROLE } from './roles.js';
7
9
  import { decodeXmlText, readXml, xmlDescendants, } from './xml-reader.js';
8
10
  /** The format id foreign draw.io matter rides under (`docs/adr/0012` D2). */
9
11
  export const UML_DRAWIO_FORMAT_ID = 'drawio';
@@ -52,7 +54,6 @@ function parseStyle(raw) {
52
54
  /* ── The label HTML ───────────────────────────────────────────────────── */
53
55
  const HR = /<hr\s*\/?>/i;
54
56
  const LINE_BREAK = /<br\s*\/?>|<\/p>|<\/div>|<\/li>|<\/tr>/gi;
55
- const TAGS = /<[^>]*>/g;
56
57
  /**
57
58
  * Annex C's ASCII guillemets, rescued before the markup strip runs.
58
59
  *
@@ -64,6 +65,26 @@ const TAGS = /<[^>]*>/g;
64
65
  * the string the tag strip then walks.
65
66
  */
66
67
  const ASCII_GUILLEMETS = /<<([^<>]{0,64})>>/g;
68
+ /**
69
+ * The text with every `<…>` taken out — what `/<[^>]*>/g` did, without its
70
+ * re-scan of the rest of the label from every `<` that never closes (quadratic
71
+ * on a label of repeated `<`, counter-review of 49aa247ce). An unclosed `<`
72
+ * stays, as it did.
73
+ */
74
+ function stripTags(text) {
75
+ const kept = [];
76
+ let from = 0;
77
+ for (;;) {
78
+ const open = text.indexOf('<', from);
79
+ const close = open < 0 ? -1 : text.indexOf('>', open);
80
+ if (close < 0)
81
+ break;
82
+ kept.push(text.slice(from, open));
83
+ from = close + 1;
84
+ }
85
+ kept.push(text.slice(from));
86
+ return kept.join('');
87
+ }
67
88
  /** The one entity `decodeXmlText` leaves standing that a LABEL routinely has. */
68
89
  const NBSP = /&nbsp;/gi;
69
90
  /**
@@ -92,11 +113,7 @@ export function drawioCompartments(value) {
92
113
  return [];
93
114
  return raw
94
115
  .split(HR)
95
- .map(part => decodeXmlText(part
96
- .replaceAll(LINE_BREAK, '\n')
97
- .replaceAll(ASCII_GUILLEMETS, '«$1»')
98
- .replaceAll(TAGS, '')
99
- .replaceAll(NBSP, ' '))
116
+ .map(part => decodeXmlText(stripTags(part.replaceAll(LINE_BREAK, '\n').replaceAll(ASCII_GUILLEMETS, '«$1»')).replaceAll(NBSP, ' '))
100
117
  .split('\n')
101
118
  .map(line => line.trim())
102
119
  .filter(line => line.length > 0 && line !== '...' && line !== '…'))
@@ -314,6 +331,37 @@ function labelEndOf(geometry) {
314
331
  return 'centre';
315
332
  return x < 0 ? 'source' : 'target';
316
333
  }
334
+ /* ── What Labre's own writer says ─────────────────────────────────────── */
335
+ /**
336
+ * The UML node roles a drawing written by Labre's canvas (`canvas:drawio:export`,
337
+ * `docs/adr/0019` amendment of 2026-10-14) names in its `labre-role`
338
+ * attributes, and what each one is drawn as here.
339
+ *
340
+ * That writer is GENERIC: a classifier leaves as a draw.io group holding the
341
+ * shape and one text per compartment, each cell carrying the role it had on
342
+ * the canvas — exactly the arrangement `component.ts` builds, resolved by
343
+ * {@link umlComponentSiblings}, the function that reads it off the canvas. So
344
+ * where a role says what a cell IS, it is read instead of guessed from a style
345
+ * string; everywhere else (a role this table does not list, or a file another
346
+ * tool wrote) the style heuristics below answer as before. Still the visual
347
+ * tier: a role is a claim the file makes, and a hand-edited file can make a
348
+ * false one at no cost but its own drawing (ADR 0032's argument for the SVG
349
+ * markers).
350
+ *
351
+ * Only the kinds this reader already materialises: anything else a role names
352
+ * is left to the style and, failing that, carried.
353
+ */
354
+ const LABRE_NODE_KINDS = Object.assign(Object.create(null), {
355
+ [UML_ROLE.class]: { kind: 'classifier', classifier: 'class' },
356
+ [UML_ROLE.interface]: { kind: 'classifier', classifier: 'interface' },
357
+ [UML_ROLE.enumeration]: { kind: 'classifier', classifier: 'enumeration' },
358
+ [UML_ROLE.actor]: { kind: 'actor' },
359
+ [UML_ROLE['use-case']]: { kind: 'use-case' },
360
+ [UML_ROLE.note]: { kind: 'note' },
361
+ [UML_ROLE.package]: { kind: 'package' },
362
+ });
363
+ /** A cell's label as plain lines — the compartments, flattened. */
364
+ const plainLines = (value) => drawioCompartments(value).flat();
317
365
  /**
318
366
  * The `<mxCell>`s of a document, flattened, `<object>` wrappers folded in.
319
367
  *
@@ -341,6 +389,7 @@ function cellsOf(graph) {
341
389
  return {
342
390
  id: wrapper?.attrs.id ?? node.attrs.id ?? '',
343
391
  value: wrapper?.attrs.label ?? node.attrs.value ?? '',
392
+ labreRole: wrapper?.attrs['labre-role'] ?? '',
344
393
  style: parseStyle(rawStyle),
345
394
  rawStyle,
346
395
  parent: node.attrs.parent ?? '',
@@ -430,6 +479,74 @@ export function importDrawio(xml, options = {}) {
430
479
  else
431
480
  childrenOf.set(cell.parent, [cell]);
432
481
  }
482
+ // ── A draw.io `group`'s children sit in its coordinates ──────────────
483
+ //
484
+ // The one nesting this reader resolves for every cell: draw.io writes a
485
+ // grouped cell relative to its group, and Labre's own writer groups every
486
+ // framework artefact. (A lifeline's bar is resolved below, by its own rule.)
487
+ const groupOffset = (cell) => {
488
+ let x = 0;
489
+ let y = 0;
490
+ let at = byId.get(cell.parent);
491
+ for (let hops = 0; at?.vertex && 'group' in at.style && hops < 64; hops++) {
492
+ const parsed = (name) => Number.parseFloat(at?.geometry?.attrs[name] ?? '0');
493
+ if (Number.isFinite(parsed('x')))
494
+ x += parsed('x');
495
+ if (Number.isFinite(parsed('y')))
496
+ y += parsed('y');
497
+ at = byId.get(at.parent);
498
+ }
499
+ return { x, y };
500
+ };
501
+ // ── Labre's own components: a group, a roled shape, its roled words ────
502
+ //
503
+ // Read with the canvas's own resolution (`umlComponentSiblings`): the shape
504
+ // says what the artefact IS, the texts say what is written in which
505
+ // compartment, and the group and the texts are absorbed into the one
506
+ // artefact rather than carried as three unknown shapes.
507
+ const labreComponents = new Map();
508
+ const absorbed = new Set();
509
+ for (const cell of cells) {
510
+ if (!cell.vertex || !cell.id || !('group' in cell.style))
511
+ continue;
512
+ const members = (childrenOf.get(cell.id) ?? []).filter(child => child.vertex && child.id);
513
+ const component = umlComponentSiblings({ id: cell.id, childIds: members.map(member => member.id) }, members.map(member => ({
514
+ id: member.id,
515
+ ...(member.labreRole ? { role: member.labreRole } : {}),
516
+ text: plainLines(member.value).join('\n'),
517
+ })));
518
+ const node = component.node;
519
+ const recognised = node?.role ? LABRE_NODE_KINDS[node.role] : undefined;
520
+ if (!node || !recognised)
521
+ continue;
522
+ const linesOf = (tier) => tier
523
+ ? String(tier.text ?? '')
524
+ .split('\n')
525
+ .filter(Boolean)
526
+ : [];
527
+ const heading = linesOf(component.name ?? component.label ?? component.ident);
528
+ labreComponents.set(node.id, {
529
+ ...recognised,
530
+ compartments: [
531
+ heading.length > 0
532
+ ? heading
533
+ : plainLines(byId.get(node.id)?.value ?? ''),
534
+ linesOf(component.attributes),
535
+ linesOf(component.operations),
536
+ ],
537
+ });
538
+ absorbed.add(cell.id);
539
+ for (const tier of [
540
+ component.name,
541
+ component.attributes,
542
+ component.operations,
543
+ component.label,
544
+ component.ident,
545
+ ]) {
546
+ if (tier)
547
+ absorbed.add(tier.id);
548
+ }
549
+ }
433
550
  const classifiers = [];
434
551
  const actors = [];
435
552
  const useCases = [];
@@ -457,17 +574,25 @@ export function importDrawio(xml, options = {}) {
457
574
  // draw.io's two invisible roots — the model and its default layer.
458
575
  if (cell.id === '0' || cell.id === '1')
459
576
  continue;
577
+ // A Labre component's group and words: read with its shape, below.
578
+ if (absorbed.has(cell.id))
579
+ continue;
460
580
  // A member row of a stack-laid swimlane belongs to its lane, not the sheet.
461
581
  const host = byId.get(cell.parent);
462
582
  if (host?.vertex && 'swimlane' in host.style)
463
583
  continue;
464
584
  const children = (childrenOf.get(cell.id) ?? []).filter(child => child.vertex);
465
- const compartments = drawioCompartments(cell.value);
585
+ const labre = labreComponents.get(cell.id);
586
+ const compartments = labre?.compartments ?? drawioCompartments(cell.value);
466
587
  // §17.2.4's bar is drawn INSIDE the participant's container, so its
467
588
  // geometry is relative to it — the one place in this format where a box is
468
589
  // not already in sheet coordinates.
469
590
  const onLifeline = Boolean(host?.vertex) && spineOf.has(cell.parent);
470
- const own = geometryOf(cell);
591
+ const offset = groupOffset(cell);
592
+ const drawn = geometryOf(cell);
593
+ const own = drawn
594
+ ? { ...drawn, x: drawn.x + offset.x, y: drawn.y + offset.y }
595
+ : undefined;
471
596
  // The PARENT's own `mxGeometry`, not the box this reader gave it: a
472
597
  // lifeline's box is rewritten below to the narrow column our element is,
473
598
  // and a child's coordinates are relative to the CELL draw.io drew.
@@ -479,11 +604,12 @@ export function importDrawio(xml, options = {}) {
479
604
  const geometry = own && parentBox
480
605
  ? { ...own, x: own.x + parentBox.x, y: own.y + parentBox.y }
481
606
  : own;
482
- const kind = vertexKindOf(cell.style, compartments, children.length > 0, {
483
- value: cell.value,
484
- onLifeline,
485
- width: own?.w ?? 0,
486
- });
607
+ const kind = labre?.kind ??
608
+ vertexKindOf(cell.style, compartments, children.length > 0, {
609
+ value: cell.value,
610
+ onLifeline,
611
+ width: own?.w ?? 0,
612
+ });
487
613
  if (geometry)
488
614
  boxes[cell.id] = geometry;
489
615
  if (kind === 'unknown') {
@@ -609,7 +735,7 @@ export function importDrawio(xml, options = {}) {
609
735
  operations.push(...(compartments[2] ?? []));
610
736
  classifiers.push({
611
737
  ...base,
612
- kind: classifierKindOf(header.keywords),
738
+ kind: labre?.classifier ?? classifierKindOf(header.keywords),
613
739
  attributes: attributes.map(line => parseProperty(line)),
614
740
  operations: operations.map(line => parseOperation(line)),
615
741
  slots: [],
@@ -656,9 +782,14 @@ export function importDrawio(xml, options = {}) {
656
782
  continue;
657
783
  }
658
784
  const label = stereotypesOf(drawioCompartments(cell.value).flat().join('\n'));
659
- const flip = wholeIsTarget(cell.style);
785
+ // A role Labre's writer stamped states the relationship, and its ends in
786
+ // the canvas's own direction (ADR 0010) — so it is read as stated and
787
+ // never flipped. A message role off a spine is not a relation here.
788
+ const roled = umlRelationKindOfRole(cell.labreRole || undefined);
789
+ const stated = roled && !roled.startsWith('message-') ? roled : undefined;
790
+ const flip = stated ? false : wholeIsTarget(cell.style);
660
791
  const relation = {
661
- kind: relationKindOf(cell.style, label.keywords),
792
+ kind: stated ?? relationKindOf(cell.style, label.keywords),
662
793
  sourceId: flip ? cell.target : cell.source,
663
794
  targetId: flip ? cell.source : cell.target,
664
795
  ...(label.name ? { label: label.name } : {}),
@@ -666,7 +797,7 @@ export function importDrawio(xml, options = {}) {
666
797
  relations.push(relation);
667
798
  // A SOLID block head: read as the relationship it is, and named because the
668
799
  // file draws it wrong — see {@link isAmbiguousBlockHead}.
669
- if (isAmbiguousBlockHead(cell.style)) {
800
+ if (!stated && isAmbiguousBlockHead(cell.style)) {
670
801
  notes.push({
671
802
  kind: 'warning',
672
803
  sourceId: cell.id,
@@ -836,8 +967,12 @@ function translate(boxes) {
836
967
  const entries = Object.entries(boxes);
837
968
  if (entries.length === 0)
838
969
  return boxes;
839
- const minX = Math.min(...entries.map(([, box]) => box.x));
840
- const minY = Math.min(...entries.map(([, box]) => box.y));
970
+ const minX = entries
971
+ .map(([, box]) => box.x)
972
+ .reduce((a, b) => Math.min(a, b), Infinity);
973
+ const minY = entries
974
+ .map(([, box]) => box.y)
975
+ .reduce((a, b) => Math.min(a, b), Infinity);
841
976
  // `Object.fromEntries` would give the moved bag a prototype back, and the ids
842
977
  // in it are still the file's — so it is filled by hand instead.
843
978
  const moved = Object.create(null);
@@ -1,4 +1,4 @@
1
- import { PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
1
+ import { PointStyle, StrokeStyle } from '@formicoidea/labre-core/model/headless';
2
2
  /**
3
3
  * The UML line notation, as data — read by the TOOL that arms a fresh connector
4
4
  * (`actions.ts`) and by the MORPH that retypes one already drawn
@@ -1,4 +1,4 @@
1
- import { PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
1
+ import { PointStyle, StrokeStyle } from '@formicoidea/labre-core/model/headless';
2
2
  /**
3
3
  * The whole UML line notation, in one table — §11.5.4 (association, aggregation
4
4
  * and composition), §9.2.4 (generalization), §10.4.4 (interface realization),
package/dist/filename.js CHANGED
@@ -1,4 +1,4 @@
1
- import { safeFilename } from '@formicoidea/labre-core/shared/utils';
1
+ import { safeFilename } from '@formicoidea/labre-core/shared/headless';
2
2
  /**
3
3
  * A name a file system will accept, minus the extension: the shared
4
4
  * `safeFilename`, held to it by `safe-filename-parity.unit.spec.ts`.