@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 +4 -4
- package/dist/consts.js +6 -6
- package/dist/drawio-decode.d.ts +38 -4
- package/dist/drawio-decode.js +118 -13
- package/dist/drawio-import.d.ts +1 -1
- package/dist/drawio-import.js +156 -21
- package/dist/edge-styles.d.ts +1 -1
- package/dist/edge-styles.js +1 -1
- package/dist/filename.js +1 -1
- package/dist/import.js +36 -12
- package/dist/interchange-headless.d.ts +13 -0
- package/dist/interchange-headless.js +13 -0
- package/dist/interchange.js +1 -1
- package/dist/keywords.js +3 -1
- package/dist/model.d.ts +8 -1
- package/dist/model.js +10 -1
- package/dist/plantuml-import.d.ts +10 -0
- package/dist/plantuml-import.js +39 -11
- package/dist/presets.d.ts +2 -1
- package/dist/presets.js +1 -1
- package/dist/translations.js +6 -2
- package/dist/xmi-import.d.ts +12 -0
- package/dist/xmi-import.js +61 -22
- package/dist/xml-reader.d.ts +1 -157
- package/dist/xml-reader.js +3 -430
- package/package.json +7 -3
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/
|
|
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.
|
package/dist/drawio-decode.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
export declare const DRAWIO_EMPTY_DIAGRAM
|
|
10
|
-
export declare const DRAWIO_BAD_PAYLOAD
|
|
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
|
|
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`
|
package/dist/drawio-decode.js
CHANGED
|
@@ -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
|
-
/**
|
|
38
|
-
|
|
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 =
|
|
47
|
-
export const DRAWIO_EMPTY_DIAGRAM =
|
|
48
|
-
export const DRAWIO_BAD_PAYLOAD =
|
|
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
|
|
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 =
|
|
176
|
+
const payload = diagramPayload(text)?.trim();
|
|
74
177
|
if (!payload)
|
|
75
|
-
throw
|
|
178
|
+
throw refusal('emptyDiagram');
|
|
76
179
|
const Decompression = globalThis.DecompressionStream;
|
|
77
180
|
if (!Decompression)
|
|
78
|
-
throw
|
|
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(
|
|
191
|
+
inflated = decodeURIComponent(new TextDecoder().decode(await readCapped(stream, DRAWIO_MAX_INFLATED_BYTES)));
|
|
89
192
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
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
|
|
199
|
+
throw refusal('badPayload');
|
|
95
200
|
return inflated;
|
|
96
201
|
}
|
package/dist/drawio-import.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InterchangeNote } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
-
import type
|
|
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
|
package/dist/drawio-import.js
CHANGED
|
@@ -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 = / /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
|
|
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
|
|
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 =
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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 =
|
|
840
|
-
|
|
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);
|
package/dist/edge-styles.d.ts
CHANGED
|
@@ -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
|
package/dist/edge-styles.js
CHANGED
|
@@ -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/
|
|
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`.
|