@formicoidea/labre-framework-bpmn 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/actions.d.ts +213 -0
  2. package/dist/actions.js +467 -0
  3. package/dist/background.d.ts +2 -0
  4. package/dist/background.js +158 -0
  5. package/dist/commands.d.ts +4 -0
  6. package/dist/commands.js +567 -0
  7. package/dist/consts.d.ts +157 -3
  8. package/dist/consts.js +192 -3
  9. package/dist/descriptor.d.ts +8 -3
  10. package/dist/descriptor.js +6 -3
  11. package/dist/element-renderer.d.ts +10 -4
  12. package/dist/element-renderer.js +14 -55
  13. package/dist/element-view.d.ts +100 -8
  14. package/dist/element-view.js +249 -30
  15. package/dist/export.d.ts +277 -0
  16. package/dist/export.js +1802 -0
  17. package/dist/facts.d.ts +48 -0
  18. package/dist/facts.js +127 -0
  19. package/dist/import.d.ts +44 -0
  20. package/dist/import.js +1440 -0
  21. package/dist/index.d.ts +14 -1
  22. package/dist/index.js +46 -1
  23. package/dist/interchange.d.ts +109 -0
  24. package/dist/interchange.js +191 -0
  25. package/dist/morph.d.ts +61 -0
  26. package/dist/morph.js +118 -0
  27. package/dist/node/node-renderer.d.ts +0 -9
  28. package/dist/node/node-renderer.js +294 -17
  29. package/dist/pool-hit.d.ts +98 -0
  30. package/dist/pool-hit.js +130 -0
  31. package/dist/presets.d.ts +114 -0
  32. package/dist/presets.js +232 -0
  33. package/dist/profiles.d.ts +2 -0
  34. package/dist/profiles.js +189 -0
  35. package/dist/roles.d.ts +96 -0
  36. package/dist/roles.js +410 -0
  37. package/dist/rules.d.ts +199 -0
  38. package/dist/rules.js +1539 -0
  39. package/dist/templates/index.js +116 -9
  40. package/dist/toolbar/bpmn-menu.d.ts +6 -21
  41. package/dist/toolbar/bpmn-menu.js +6 -173
  42. package/dist/toolbar/bpmn-senior-button.js +8 -2
  43. package/dist/toolbar/config.d.ts +27 -2
  44. package/dist/toolbar/config.js +86 -2
  45. package/dist/toolbar/icons.d.ts +67 -0
  46. package/dist/toolbar/icons.js +141 -0
  47. package/dist/toolbar/senior-tool.js +1 -0
  48. package/dist/translations.d.ts +16 -0
  49. package/dist/translations.js +20 -0
  50. package/dist/view.d.ts +18 -0
  51. package/dist/view.js +95 -7
  52. package/package.json +2 -2
package/dist/import.js ADDED
@@ -0,0 +1,1440 @@
1
+ import { ConnectorMode, PointStyle, StrokeStyle } from '@formicoidea/labre-core/model';
2
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
3
+ import { ASSOCIATION_STROKE, ASSOCIATION_WIDTH, MESSAGE_STROKE, MESSAGE_WIDTH, NODE_SIZE, POOL_BAND_WIDTH, POOL_REF_HEIGHT, POOL_REF_WIDTH, SEQUENCE_STROKE, SEQUENCE_WIDTH, } from './consts.js';
4
+ import { BPMN_FORMAT_ID, BPMN_NS, BPMN_OWN_DECLARATIONS, BPMN_SCOPE, BPMN_XML_OF_KIND, escapeAttr, escapeText, } from './export.js';
5
+ import { bpmnNodeProps } from './presets.js';
6
+ import { BPMN_ROLE } from './roles.js';
7
+ /**
8
+ * A BPMN 2.0 interchange document, read as a board — the inverse of `export.ts`
9
+ * on the vocabulary Labre draws, and an honest accounting of everything else
10
+ * (`docs/adr/0012`, D1–D6).
11
+ *
12
+ * ## Pure by construction, like its mirror
13
+ *
14
+ * A string in, element PROPS out. No `BlockStdScope`, no surface, no store, no
15
+ * clock, no randomness — this function has no surface to add anything to, and
16
+ * giving it one would cost exactly the property that lets one implementation
17
+ * serve an editor command and a labre-mcp tool (ADR 0012, P3). The caller does
18
+ * the writing.
19
+ *
20
+ * ## What the caller owes, and it is one thing
21
+ *
22
+ * `surface.addElement` mints its own nanoid and ignores any id it is handed —
23
+ * surface identity is Labre's and never the file's (D3) — so a connector's
24
+ * `source` / `target` below name the **source file's** ids rather than surface
25
+ * ones. The caller creates the elements, folds the array into a map from each
26
+ * element's `interchange.bpmn.id` to the id the surface minted for it, and
27
+ * rewrites the two endpoints. Every element carries that id, so the map is a
28
+ * fold over the very array this returned; nothing else is needed to finish it.
29
+ *
30
+ * ## Three states, and no fourth
31
+ *
32
+ * Every node of the file is **mapped** (there is a Labre artefact: drawn,
33
+ * editable, re-emitted from the drawing), **carried** (no artefact: kept
34
+ * verbatim in `interchange.bpmn` on the nearest mapped element, invisible on
35
+ * the canvas) or **quarantined** (kept, and deliberately never written back,
36
+ * because re-emitting it would produce a file that contradicts the drawing).
37
+ * Nothing is dropped in silence, and the report says which happened to what.
38
+ *
39
+ * What Labre judges is what Labre can DRAW: a carried element is on no canvas,
40
+ * so no validation rule sees it and no audit counts it. That is correct, and it
41
+ * is the sentence the report has to be read with.
42
+ *
43
+ * ## Reading XML by hand, and why
44
+ *
45
+ * The tree is walked child by child rather than through
46
+ * `getElementsByTagNameNS`, which happy-dom does not implement for a parsed XML
47
+ * document: it answers an empty list for every namespaced query, so a unit
48
+ * suite built on it would pass by asserting nothing. The same environment
49
+ * decodes neither numeric character references nor `'` in an attribute
50
+ * value, which is why the escaped-label round trip is pinned in chromium
51
+ * (`integration-test/src/__tests__/edgeless/bpmn.spec.ts`) and not in the unit
52
+ * spec. Both traps are `export.unit.spec.ts`'s findings, inherited rather than
53
+ * rediscovered.
54
+ *
55
+ * ## Failure is an exception, not a report
56
+ *
57
+ * A source that is not a readable BPMN document — malformed XML, a root that is
58
+ * not `definitions`, or a `definitions` whose only root is a choreography or a
59
+ * conversation (D1's one refusal) — makes this function THROW. The report's
60
+ * five note kinds are a closed list and not one of them can say "this is not a
61
+ * file I can read"; a result of three zeroes and no elements would claim an
62
+ * empty process where there was none. The command layer catches it and tells
63
+ * the user which of the three it was.
64
+ *
65
+ * ## The loss table
66
+ *
67
+ * Every semantic capability owes one (ADR 0012), and this is `.bpmn`'s. What is
68
+ * INVISIBLE is not what is LOST, and the distinction is the deliverable: a
69
+ * carried fragment is in the document and out of the picture, and only the
70
+ * bottom rows are gone for good.
71
+ *
72
+ * | what | state | after a round trip |
73
+ * | ----------------------------------------------------------- | ----------- | --------------------------------------------------- |
74
+ * | the 17 kinds, pools, flat lanes, the 3 edge roles, the DI | mapped | drawn, and written back from the drawing |
75
+ * | element ids (participants, flow nodes, flows, lanes) | mapped | given back verbatim — the fixed point (D3) |
76
+ * | `documentation`, `ioSpecification`, `conditionExpression`, … | carried | invisible on the canvas, written back in its XSD slot |
77
+ * | `process/@isExecutable="true"` | carried | written back; the `false` this writer mints is overridden |
78
+ * | a flow onto a carried node (a boundary event's error path) | carried | never drawn loose, and re-emitted beside the node it runs to |
79
+ * | a `BPMNShape` drawing an element the file does not declare | carried | kept under the id it names, and put back on the plane |
80
+ * | loop / multi-instance / compensation markers | carried | a plain task on the canvas, still marked in the file |
81
+ * | Analytic elements (boundary, inclusive, event-based, …) | carried | not drawn; re-emitted in the flow-element slot it came out of |
82
+ * | `camunda:` / `zeebe:` / `signavio:` extensions | carried | re-emitted verbatim, declarations included |
83
+ * | the file's own prefix for a namespace (`bpmn2:`, `semantic:`) | carried | re-declared on `definitions`, so the fragments under it parse |
84
+ * | a colour set in bpmn.io (`bioc:`, `color:`) | quarantined | imports grey; the colour is kept and not written back |
85
+ * | the body of an expanded sub-process | quarantined | drawn collapsed; the body and its DI survive in the document |
86
+ * | lane nesting (`childLaneSet`) | quarantined | flat lanes with joined names; the nesting survives |
87
+ * | `definitions`-level `<import>` | quarantined | single-file import only |
88
+ * | an edge's explicit `di:waypoint` routing | **lost** | re-routed from the two ends, and it says so |
89
+ * | a CARRIED shape's position, once the drawing has moved | **lost** | the fragment is verbatim, so it keeps the file's own coordinates while the rest is translated to the plane origin. The export warns; nothing is lost, something is displaced |
90
+ * | an `xmlns:` binding one of Labre's own four prefixes elsewhere | **lost on export** | kept in the document; not written back, because it would rebind the prefix every `dc:Bounds` in the file is under. The fragments carried under it ARE written, and are then read under Labre's binding — reinterpreted rather than lost, which is the worse of the two, so the export warns about both halves |
91
+ * | an `xmlns:` declared on anything but `definitions` | **lost** | the reader carries declarations off `definitions` only, so a fragment relying on one scoped to its own ancestor comes back unparseable. bpmn.io and Camunda both hoist to `definitions`, so this is theoretical on a real file |
92
+ * | two pools disagreeing about one `definitions` attribute | **lost on export** | one value can be written and the last wins; matter carried from the other file is read under it. Both are in the document and the export warns |
93
+ * | a carried element whose id another carried element already claimed | **lost on export** | the first is written and the rest are not — a BPMN id is unique across a document. They stay in the document; the export names them where the two disagree, and writes an exact duplicate once in silence |
94
+ * | the file's `definitions/@id`, `@targetNamespace`, `@exporter` | **lost** | Labre writes its own |
95
+ * | the file's `process/@id`, where a participant names one | **lost** | re-minted from the participant's id, which IS kept |
96
+ * | `laneSet/@id`, `collaboration/@id` and `@name`, `BPMNDiagram/@id`, `BPMNPlane/@id`, every `BPMNShape/@id` and `BPMNEdge/@id`, the folded `dataObject/@id` | **lost** | re-derived from the id its element settled on, which is what makes the fixed point a fixed point (D3) |
97
+ * | a carried fragment's SLOT inside its parent | **lost, and re-derived** | the scope records the parent, not the slot; the writer places it from the XSD sequence, which is legal but need not be where the file had it |
98
+ * | a gap or an overlap between two lane bands | **lost** | lanes are weights: Labre lays its bands end to end |
99
+ * | the plane offset (§12.3) | **lost** | shape exact, origin at (0, 0) — the export's doing |
100
+ * | surface identity across a re-import | **lost** | a new board beside the old one, never a merge |
101
+ *
102
+ * The row that used to be owed is owed no longer: **the carried payload IS
103
+ * re-emitted on export**, into the element its scope names and the slot the
104
+ * XSD puts it in, with the namespace declarations it needs. The property that
105
+ * makes the claim checkable is the mirror of D3's: read a foreign file, write
106
+ * it, read it again, and the carried payloads are identical — a fixed point on
107
+ * matter this library does not understand. `import.unit.spec.ts` pins it.
108
+ *
109
+ * Quarantined material is the deliberate exception and always was: it is kept
110
+ * in the document and never written back, so a second read of an exported file
111
+ * holds none of it. That is not a gap in the round trip, it is D5.
112
+ */
113
+ /* ── The inverse of the export's tables ───────────────────────────────── */
114
+ /** The key {@link BPMN_KIND_OF_XML} is read by: an element and its trigger. */
115
+ function xmlKindKey(element, eventDefinition) {
116
+ return `${element}#${eventDefinition ?? ''}`;
117
+ }
118
+ /**
119
+ * `startEvent` + `messageEventDefinition` → `startEventMessage`, and the
120
+ * sixteen other answers.
121
+ *
122
+ * DERIVED from {@link BPMN_XML_OF_KIND} rather than typed out a second time,
123
+ * which is the only arrangement in which the two directions cannot drift: a
124
+ * kind added to the pack gains its reading the moment it gains its writing, and
125
+ * an element name corrected in the table is corrected in both. The inverse of
126
+ * that table is not a function in general — `startEvent` alone is four kinds —
127
+ * so the key is the PAIR (element name, event definition), which is exactly
128
+ * what the table tells them apart by.
129
+ *
130
+ * That the pair is injective — seventeen kinds, seventeen distinct keys — is a
131
+ * property of the table rather than of this code, so the spec asserts it
132
+ * instead of this line assuming it.
133
+ */
134
+ export const BPMN_KIND_OF_XML = new Map(Object.entries(BPMN_XML_OF_KIND).map(([kind, mapping]) => [
135
+ xmlKindKey(mapping.element, mapping.eventDefinition),
136
+ kind,
137
+ ]));
138
+ /**
139
+ * `.bpmn`'s scope vocabulary — where a carried fragment came off (D2, as
140
+ * amended in #157), and where the writer puts it back.
141
+ *
142
+ * Declared in `export.ts` and re-exported here, for the reason
143
+ * {@link BPMN_FORMAT_ID} is: the reader files a fragment under a scope and the
144
+ * writer looks it up under one, and a table written twice is a table that
145
+ * drifts. See its doc comment there for what a scope means.
146
+ */
147
+ export { BPMN_SCOPE };
148
+ /**
149
+ * The three edge elements, and the role each one IS.
150
+ *
151
+ * A role is a statement (`docs/adr/0010`), and an imported edge makes the same
152
+ * statement a drawn one does: the file said `messageFlow`, so the arrow says
153
+ * "sends a message to". Nothing here invents a role for an untyped edge —
154
+ * there is no such thing in BPMN, where every connecting object is one of
155
+ * these three.
156
+ */
157
+ const EDGE_ROLE_OF_ELEMENT = {
158
+ sequenceFlow: BPMN_ROLE.sequenceFlow,
159
+ messageFlow: BPMN_ROLE.messageFlow,
160
+ association: BPMN_ROLE.association,
161
+ };
162
+ /**
163
+ * The colour extensions D5 quarantines (case 1), by namespace and by prefix.
164
+ *
165
+ * bpmn.io's own `bioc:` and the OMG's non-normative colour extension. They
166
+ * collide with `strokeColor` / `fillColor`, which Labre owns and the user can
167
+ * change from the shape toolbar — so writing a stale `bioc:fill` back beside a
168
+ * recoloured shape would produce a file that disagrees with itself. Adopting
169
+ * them as real colours would lift the case out of quarantine in both directions
170
+ * at once, and is a chantier of its own (ADR 0012, non-goals).
171
+ */
172
+ const COLOUR_NS = new Set([
173
+ 'http://bpmn.io/schema/bpmn/biocolor/1.0',
174
+ 'http://www.omg.org/spec/BPMN/non-normative/color/1.0',
175
+ ]);
176
+ const COLOUR_PREFIX = new Set(['bioc', 'color']);
177
+ /** The four reasons a fragment is kept and not written back (D5). Closed. */
178
+ export const BPMN_QUARANTINE_REASON = {
179
+ colour: 'A vendor colour extension. Labre owns the stroke and the fill of the shape ' +
180
+ 'it was on, so writing it back beside a recoloured artefact would produce a ' +
181
+ 'file that disagrees with itself.',
182
+ expanded: 'The body of an expanded sub-process. Labre draws the collapsed form, and a ' +
183
+ 'body written under a shape flagged collapsed is a model and a diagram that ' +
184
+ 'contradict each other. It stays in the document.',
185
+ nestedLanes: 'A nested lane set. A Labre pool is one flat list of bands, so the leaves ' +
186
+ 'were imported carrying their whole path as a name; writing the nesting back ' +
187
+ 'alongside them would describe the pool twice.',
188
+ imported: 'A `definitions`-level <import>. Labre reads one file (§15.3.1 asks for a ' +
189
+ 'self-contained set), so writing this back would claim a resolution of ' +
190
+ 'another document that never happened.',
191
+ };
192
+ /**
193
+ * The children of an activity that are NOT its body.
194
+ *
195
+ * They describe the activity itself — its documentation, its extensions, its
196
+ * loop marker, its data plumbing — so they are carried and written back like
197
+ * any other unmodelled child. Everything else inside an activity the diagram
198
+ * flags `isExpanded="true"` is the flow drawn INSIDE it, which is the thing
199
+ * D5 case 2 is about.
200
+ */
201
+ const NOT_A_BODY = new Set([
202
+ 'documentation',
203
+ 'extensionElements',
204
+ 'incoming',
205
+ 'outgoing',
206
+ 'ioSpecification',
207
+ 'property',
208
+ 'dataInputAssociation',
209
+ 'dataOutputAssociation',
210
+ 'multiInstanceLoopCharacteristics',
211
+ 'standardLoopCharacteristics',
212
+ ]);
213
+ /* ── Reading the DOM by hand ──────────────────────────────────────────── */
214
+ /** The `Node.nodeType`s a fragment can be made of, spelled rather than numbered. */
215
+ const ELEMENT_NODE = 1;
216
+ const TEXT_NODE = 3;
217
+ const CDATA_NODE = 4;
218
+ const PI_NODE = 7;
219
+ const COMMENT_NODE = 8;
220
+ /** The element children of `node`, in document order. */
221
+ function childrenOf(node) {
222
+ return Array.from(node.children);
223
+ }
224
+ /** Every element under `node`, at any depth, in document order. */
225
+ function descendantsOf(node) {
226
+ return childrenOf(node).flatMap(child => [child, ...descendantsOf(child)]);
227
+ }
228
+ /** Is this element in the BPMN MODEL namespace, whatever prefix it wears? */
229
+ function isModel(element) {
230
+ return element.namespaceURI === BPMN_NS.model;
231
+ }
232
+ /** MODEL-namespaced children with this local name, in document order. */
233
+ function modelChildren(parent, local) {
234
+ return childrenOf(parent).filter(child => isModel(child) && child.localName === local);
235
+ }
236
+ /** The first MODEL child with this local name, if any. */
237
+ function modelChild(parent, local) {
238
+ return modelChildren(parent, local)[0];
239
+ }
240
+ /** An attribute, or `undefined` — never `null`, which reads as a value. */
241
+ function attrOf(element, name) {
242
+ const value = element.getAttribute(name);
243
+ return value === null ? undefined : value;
244
+ }
245
+ /** The prefix of a qualified name, `''` when it has none. */
246
+ function prefixOf(qualified) {
247
+ const colon = qualified.indexOf(':');
248
+ return colon < 0 ? '' : qualified.slice(0, colon);
249
+ }
250
+ /** Is this attribute one of the colour extensions D5 quarantines? */
251
+ function isColourAttr(attr) {
252
+ return ((attr.namespaceURI !== null && COLOUR_NS.has(attr.namespaceURI)) ||
253
+ COLOUR_PREFIX.has(prefixOf(attr.name)));
254
+ }
255
+ /**
256
+ * One element, back as XML text — the verbatim form D1 promises for anything
257
+ * carried or quarantined.
258
+ *
259
+ * Serialized here rather than through `outerHTML`, for two halves of one
260
+ * reason: `outerHTML` is an HTML serializer in several DOM implementations
261
+ * (void elements, lower-cased names, attribute newlines left raw), and what is
262
+ * stored here is a document VALUE that a later export has to be able to write
263
+ * back character for character. Prefixes are kept exactly as the file spelled
264
+ * them, because a `camunda:properties` fragment only means anything under the
265
+ * declaration its `definitions` carried — which is why those declarations are
266
+ * carried too (see the residue, below).
267
+ *
268
+ * Escaping is `export.ts`'s own, so a fragment stored by this reader and a name
269
+ * written by that writer treat a newline in an attribute value the same way
270
+ * (XML 1.0 §3.3.3 — only a character reference survives normalization).
271
+ */
272
+ function fragmentOf(element) {
273
+ const attrs = Array.from(element.attributes)
274
+ .map(attr => ` ${attr.name}="${escapeAttr(attr.value)}"`)
275
+ .join('');
276
+ const parts = [];
277
+ for (const child of Array.from(element.childNodes)) {
278
+ if (child.nodeType === ELEMENT_NODE) {
279
+ parts.push(fragmentOf(child));
280
+ }
281
+ else if (child.nodeType === TEXT_NODE || child.nodeType === CDATA_NODE) {
282
+ parts.push(escapeText(child.nodeValue ?? ''));
283
+ }
284
+ else if (child.nodeType === COMMENT_NODE) {
285
+ // A comment inside a vendor extension is documentation somebody wrote by
286
+ // hand, and dropping it while promising the fragment back "character for
287
+ // character" would make that promise false in the one place a human
288
+ // would notice.
289
+ parts.push(`<!--${child.nodeValue ?? ''}-->`);
290
+ }
291
+ else if (child.nodeType === PI_NODE) {
292
+ const instruction = child;
293
+ parts.push(`<?${instruction.target} ${instruction.data}?>`);
294
+ }
295
+ }
296
+ const name = element.nodeName;
297
+ if (parts.length === 0)
298
+ return `<${name}${attrs} />`;
299
+ return `<${name}${attrs}>${parts.join('')}</${name}>`;
300
+ }
301
+ /**
302
+ * The document, or an exception naming what is wrong with it.
303
+ *
304
+ * `DOMParser` reports a malformed document as a `parsererror` element rather
305
+ * than by throwing, which is a well-formedness answer nobody asked for in the
306
+ * shape of a document — so it is turned into the exception the caller can
307
+ * actually act on.
308
+ */
309
+ function parseDefinitions(source) {
310
+ const doc = new DOMParser().parseFromString(source, 'application/xml');
311
+ const error = doc.querySelector('parsererror');
312
+ if (error) {
313
+ throw new Error(`This file is not well-formed XML, so no BPMN can be read out of it: ` +
314
+ `${(error.textContent ?? '').trim().slice(0, 200)}`);
315
+ }
316
+ const root = doc.documentElement;
317
+ if (!root || root.localName !== 'definitions') {
318
+ throw new Error(`A BPMN file opens on <definitions>; this one opens on <${root?.localName ?? 'nothing'}>.`);
319
+ }
320
+ // The NAMESPACE, not the element name — `<definitions>` is also the root of a
321
+ // DMN decision model, and of anything else built on the same OMG scaffolding.
322
+ // Without this a `.dmn` imports as an empty board, which is exactly the
323
+ // "three zeroes claiming an empty process" this reader refuses to return.
324
+ if (root.namespaceURI !== BPMN_NS.model) {
325
+ throw new Error(`This <definitions> is in "${root.namespaceURI ?? 'no namespace'}", not ` +
326
+ `in BPMN 2.0's ("${BPMN_NS.model}"). A DMN decision model and a BPMN ` +
327
+ `process open on the same element name and are not the same file.`);
328
+ }
329
+ return root;
330
+ }
331
+ /** A `dc:Bounds` child, as four numbers — `null` when it has none, or junk. */
332
+ function boundsOf(shape) {
333
+ const box = childrenOf(shape).find(child => child.namespaceURI === BPMN_NS.dc && child.localName === 'Bounds');
334
+ if (!box)
335
+ return null;
336
+ const numbers = ['x', 'y', 'width', 'height'].map(name => Number(attrOf(box, name) ?? Number.NaN));
337
+ if (numbers.some(value => !Number.isFinite(value)))
338
+ return null;
339
+ return { x: numbers[0], y: numbers[1], w: numbers[2], h: numbers[3] };
340
+ }
341
+ /**
342
+ * Every `BPMNShape` and `BPMNEdge` of the document, keyed by what it draws.
343
+ *
344
+ * All planes of all diagrams, flattened, first occurrence winning:
345
+ * `bpmnElement` is an id and an id is document-unique, so two shapes for one
346
+ * element is a contradiction in the source rather than a case to model. A file
347
+ * carrying a second diagram for an expanded sub-process therefore contributes
348
+ * its shapes here, and the sub-process body is quarantined all the same — the
349
+ * DI follows whatever it describes.
350
+ *
351
+ * The INDEX is what makes the round trip a fixed point: the exporter writes
352
+ * shapes in document order, so reading them back in plane order is what lets
353
+ * the export after an import land on the bytes of the export before it.
354
+ */
355
+ function diagramIndex(definitions) {
356
+ const shapes = new Map();
357
+ const edges = new Map();
358
+ let index = 0;
359
+ for (const diagram of childrenOf(definitions)) {
360
+ if (diagram.namespaceURI !== BPMN_NS.bpmndi ||
361
+ diagram.localName !== 'BPMNDiagram') {
362
+ continue;
363
+ }
364
+ for (const plane of childrenOf(diagram)) {
365
+ if (plane.namespaceURI !== BPMN_NS.bpmndi ||
366
+ plane.localName !== 'BPMNPlane') {
367
+ continue;
368
+ }
369
+ for (const child of childrenOf(plane)) {
370
+ if (child.namespaceURI !== BPMN_NS.bpmndi)
371
+ continue;
372
+ const target = attrOf(child, 'bpmnElement');
373
+ if (target === undefined)
374
+ continue;
375
+ if (child.localName === 'BPMNShape' && !shapes.has(target)) {
376
+ shapes.set(target, {
377
+ bounds: boundsOf(child),
378
+ index: index++,
379
+ element: child,
380
+ });
381
+ }
382
+ else if (child.localName === 'BPMNEdge' && !edges.has(target)) {
383
+ edges.set(target, {
384
+ waypoints: childrenOf(child).filter(point => point.namespaceURI === BPMN_NS.di &&
385
+ point.localName === 'waypoint').length,
386
+ index: index++,
387
+ element: child,
388
+ });
389
+ }
390
+ }
391
+ }
392
+ }
393
+ return { shapes, edges };
394
+ }
395
+ /**
396
+ * The attributes the reader UNDERSTANDS, per element — and therefore the ones
397
+ * it does not carry, because they are already in the drawing.
398
+ *
399
+ * The default row is the honest one: an `id` and a `name` are the model
400
+ * everywhere in this format, and everything else on an element Labre draws is
401
+ * something Labre does not model and must not lose.
402
+ */
403
+ const READ_ATTRS = {
404
+ definitions: ['id', 'name', 'targetNamespace', 'exporter', 'exporterVersion'],
405
+ participant: ['id', 'name', 'processRef'],
406
+ process: ['id', 'name', 'isExecutable'],
407
+ lane: ['id', 'name'],
408
+ laneSet: ['id', 'name'],
409
+ textAnnotation: ['id', 'textFormat'],
410
+ group: ['id', 'categoryValueRef'],
411
+ dataObjectReference: ['id', 'name', 'dataObjectRef'],
412
+ sequenceFlow: ['id', 'name', 'sourceRef', 'targetRef'],
413
+ messageFlow: ['id', 'name', 'sourceRef', 'targetRef'],
414
+ // `associationDirection` is read only when it says what the exporter would
415
+ // write anyway; see the edge reader for the other values.
416
+ association: ['id', 'name', 'sourceRef', 'targetRef', 'associationDirection'],
417
+ '': ['id', 'name'],
418
+ };
419
+ /** What a `BPMNShape` says that the drawing already carries. */
420
+ const READ_SHAPE_ATTRS = [
421
+ 'id',
422
+ 'bpmnElement',
423
+ 'isExpanded',
424
+ 'isMarkerVisible',
425
+ 'isHorizontal',
426
+ ];
427
+ /** How far off to the side an undrawn artefact is swept, and on what grid. */
428
+ const SWEEP_GAP = 160;
429
+ const SWEEP_STEP = 200;
430
+ const SWEEP_COLUMNS = 4;
431
+ /** The margin between a minted pool's plot and the work inside it. */
432
+ const MINTED_POOL_PADDING = 40;
433
+ /** The connector a role is drawn as — the styles the tools arm, one table. */
434
+ function connectorProps(role) {
435
+ const base = { type: 'connector', role, mode: ConnectorMode.Orthogonal };
436
+ if (role === BPMN_ROLE.messageFlow) {
437
+ return {
438
+ ...base,
439
+ stroke: MESSAGE_STROKE,
440
+ strokeWidth: MESSAGE_WIDTH,
441
+ strokeStyle: StrokeStyle.Dash,
442
+ frontEndpointStyle: PointStyle.Circle,
443
+ rearEndpointStyle: PointStyle.Arrow,
444
+ };
445
+ }
446
+ if (role === BPMN_ROLE.association) {
447
+ return {
448
+ ...base,
449
+ stroke: ASSOCIATION_STROKE,
450
+ strokeWidth: ASSOCIATION_WIDTH,
451
+ strokeStyle: StrokeStyle.Dash,
452
+ // No head at either end: an association claims no direction, and an
453
+ // arrowhead would be the picture claiming one (`docs/adr/0010`).
454
+ frontEndpointStyle: PointStyle.None,
455
+ rearEndpointStyle: PointStyle.None,
456
+ };
457
+ }
458
+ return {
459
+ ...base,
460
+ stroke: SEQUENCE_STROKE,
461
+ strokeWidth: SEQUENCE_WIDTH,
462
+ strokeStyle: StrokeStyle.Solid,
463
+ frontEndpointStyle: PointStyle.None,
464
+ rearEndpointStyle: PointStyle.Triangle,
465
+ };
466
+ }
467
+ /**
468
+ * The version of the format this file declares (ADR 0012, P2 as amended).
469
+ *
470
+ * Always `2.0` and never the namespace URI itself: `parseDefinitions` has
471
+ * already refused anything that is not in BPMN 2.0's MODEL namespace, so there
472
+ * is no case in which a foreign URI could leak out of here into a UI that would
473
+ * render it as a version.
474
+ */
475
+ function sourceVersionOf(definitions) {
476
+ const version = '2.0';
477
+ const exporter = attrOf(definitions, 'exporter');
478
+ if (exporter === undefined)
479
+ return version;
480
+ const exporterVersion = attrOf(definitions, 'exporterVersion');
481
+ return `${version} (${[exporter, exporterVersion].filter(Boolean).join(' ')})`;
482
+ }
483
+ /** `[x,y,w,h]` back off a draft that already has one. */
484
+ function boundOf(draft) {
485
+ return Bound.deserialize(String(draft.props.xywh));
486
+ }
487
+ /**
488
+ * Where the file placed nothing (D4).
489
+ *
490
+ * A shape with no `dc:Bounds` is still imported — it is in the model, and a
491
+ * model element the reader can draw is not a thing to drop — but its position
492
+ * is Labre's and the report says so. Swept onto a grid to the RIGHT of
493
+ * everything the file did place, in document order, so the same file always
494
+ * lands the same board and nothing an author drew is covered by something they
495
+ * did not.
496
+ */
497
+ function layOutTheUndrawn(drafts, minted) {
498
+ const placed = drafts.filter(draft => draft.needsLayout === undefined &&
499
+ draft !== minted &&
500
+ draft.kind !== 'edge');
501
+ const boxes = placed.map(boundOf);
502
+ const maxX = boxes.length > 0 ? Math.max(...boxes.map(box => box.x + box.w)) : 0;
503
+ const minY = boxes.length > 0 ? Math.min(...boxes.map(box => box.y)) : 0;
504
+ let seat = 0;
505
+ for (const draft of drafts) {
506
+ if (!draft.needsLayout)
507
+ continue;
508
+ const column = seat % SWEEP_COLUMNS;
509
+ const row = Math.floor(seat / SWEEP_COLUMNS);
510
+ seat++;
511
+ draft.props.xywh = new Bound(maxX + SWEEP_GAP + column * SWEEP_STEP, minY + row * SWEEP_STEP, draft.needsLayout.w, draft.needsLayout.h).serialize();
512
+ }
513
+ // The pool minted for a file that had no participant (D6) is sized LAST, to
514
+ // hold everything: a pool's plot is what decides which artefacts are in it,
515
+ // and an artefact drawn outside every plot would be exported back into a
516
+ // process of its own.
517
+ if (!minted)
518
+ return;
519
+ const inside = drafts
520
+ .filter(draft => draft !== minted && draft.kind === 'node')
521
+ .map(boundOf);
522
+ if (inside.length === 0)
523
+ return;
524
+ const left = Math.min(...inside.map(box => box.x)) - MINTED_POOL_PADDING;
525
+ const top = Math.min(...inside.map(box => box.y)) - MINTED_POOL_PADDING;
526
+ const right = Math.max(...inside.map(box => box.x + box.w)) + MINTED_POOL_PADDING;
527
+ const bottom = Math.max(...inside.map(box => box.y + box.h)) + MINTED_POOL_PADDING;
528
+ minted.props.xywh = new Bound(
529
+ // The name band is drawn INSIDE the frame and is not part of the plot, so
530
+ // the frame starts a band's width further left than the work does.
531
+ left - POOL_BAND_WIDTH, top, right - left + POOL_BAND_WIDTH, bottom - top).serialize();
532
+ }
533
+ /* ── The reader ───────────────────────────────────────────────────────── */
534
+ /**
535
+ * Read a BPMN 2.0 interchange document as element props plus a report.
536
+ *
537
+ * See the module comment for the contract, and `docs/adr/0012` D1–D6 for why it
538
+ * is this contract and not a shorter one.
539
+ */
540
+ export function importBpmnXml(source, context = {}) {
541
+ // The caller's name has no landing place in an array of elements: what a
542
+ // board is CALLED is the document's, not any element's. A caller that wants
543
+ // to name the doc after the file reads `collaboration/@name` itself.
544
+ void context;
545
+ const definitions = parseDefinitions(source);
546
+ const { shapes, edges: diEdges } = diagramIndex(definitions);
547
+ const notes = [];
548
+ const note = (entry) => notes.push(entry);
549
+ let carried = 0;
550
+ let quarantined = 0;
551
+ let explicitRoutes = 0;
552
+ const drafts = [];
553
+ const seenSourceIds = new Set();
554
+ /** Source ids that became an artefact a flow may attach to: pools and nodes. */
555
+ const mappedSourceIds = new Set();
556
+ /** Source ids kept verbatim on some element instead: never a connector end. */
557
+ const carriedSourceIds = new Set();
558
+ /** Every source id whose diagram element this reader consumed or kept. */
559
+ const drawnSourceIds = new Set();
560
+ /**
561
+ * Source ids whose diagram element is QUARANTINED, and must therefore not be
562
+ * picked up by the orphan sweep at the end.
563
+ *
564
+ * Quarantine means kept and deliberately not written back (D5), so a shape
565
+ * that escaped into the carried column would be re-emitted by the writer and
566
+ * the quarantine would mean nothing. It is a third answer to "was this
567
+ * diagram element accounted for", beside drawn and carried, and it is exactly
568
+ * as good an answer as either.
569
+ */
570
+ const quarantinedSourceIds = new Set();
571
+ /* ── Roots ─────────────────────────────────────────────────────────── */
572
+ const roots = childrenOf(definitions).filter(isModel);
573
+ const collaborations = roots.filter(root => root.localName === 'collaboration');
574
+ const processes = roots.filter(root => root.localName === 'process');
575
+ // D1's one refusal, and it is at the document level: half a choreography is
576
+ // not a smaller choreography, and a conversation is a different picture of a
577
+ // different thing. Declined by name, with no partial import.
578
+ if (processes.length === 0 && collaborations.length === 0) {
579
+ const declined = roots.find(root => ['choreography', 'globalChoreographyTask', 'conversation'].includes(root.localName));
580
+ if (declined) {
581
+ throw new Error(`This file is a BPMN ${declined.localName}, which Labre does not draw. ` +
582
+ `Only a process or a collaboration can be imported.`);
583
+ }
584
+ }
585
+ /** `categoryValue` id → the label a `group` pointing at it wears. */
586
+ const categoryValues = new Map();
587
+ for (const root of roots) {
588
+ if (root.localName !== 'category')
589
+ continue;
590
+ for (const value of modelChildren(root, 'categoryValue')) {
591
+ const id = attrOf(value, 'id');
592
+ if (id !== undefined)
593
+ categoryValues.set(id, attrOf(value, 'value') ?? '');
594
+ }
595
+ }
596
+ /* ── The four ways something is kept ───────────────────────────────── */
597
+ /** Records a source id, and names the SECOND element to claim it (D3). */
598
+ const claim = (sourceId, element) => {
599
+ if (sourceId === undefined)
600
+ return;
601
+ if (seenSourceIds.has(sourceId)) {
602
+ note({
603
+ kind: 'substituted-id',
604
+ sourceId,
605
+ element,
606
+ message: `Two elements in this file share the id "${sourceId}", which BPMN ` +
607
+ `requires to be unique across a document. Both were imported; the ` +
608
+ `second will be written back under an id Labre mints.`,
609
+ });
610
+ }
611
+ seenSourceIds.add(sourceId);
612
+ };
613
+ /** One attribute, kept under the scope of the element that carried it. */
614
+ const carryAttr = (payload, scope, name, value) => {
615
+ payload.attrs = {
616
+ ...payload.attrs,
617
+ [scope]: { ...payload.attrs?.[scope], [name]: value },
618
+ };
619
+ carried++;
620
+ };
621
+ /**
622
+ * One fragment, kept under the scope of the element it was a CHILD of.
623
+ *
624
+ * `announce: false` for a fragment whose own note is written at the call site
625
+ * — a flow onto a carried node is carried for a REASON, and two notes about
626
+ * one flow, one of them generic, is a worse report than one that is precise.
627
+ */
628
+ const carryChild = (payload, scope, child, sourceId, announce = true) => {
629
+ payload.children = {
630
+ ...payload.children,
631
+ [scope]: [...(payload.children?.[scope] ?? []), fragmentOf(child)],
632
+ };
633
+ carried++;
634
+ if (!announce)
635
+ return;
636
+ note({
637
+ kind: 'carried',
638
+ element: child.nodeName,
639
+ sourceId,
640
+ message: `<${child.nodeName}> has no Labre artefact, so it is kept verbatim on ` +
641
+ `the nearest element that has one. It is not drawn, and no validation ` +
642
+ `rule sees it.`,
643
+ });
644
+ };
645
+ /** One diagram fragment, kept under the scope of what it DRAWS. */
646
+ const carryDi = (payload, scope, fragment) => {
647
+ payload.di = {
648
+ ...payload.di,
649
+ [scope]: [...(payload.di?.[scope] ?? []), fragment],
650
+ };
651
+ carried++;
652
+ };
653
+ const quarantine = (payload, fragment, reason, entry) => {
654
+ payload.quarantined = [
655
+ ...(payload.quarantined ?? []),
656
+ { fragment, reason },
657
+ ];
658
+ quarantined++;
659
+ note({ kind: 'quarantined', ...entry, message: reason });
660
+ };
661
+ /** Every attribute the reader does not model — colours quarantined (D5). */
662
+ const sortAttributes = (element, payload, scope, sourceId, understood = READ_ATTRS[element.localName] ??
663
+ READ_ATTRS['']) => {
664
+ for (const attr of Array.from(element.attributes)) {
665
+ if (attr.name === 'xmlns' || attr.name.startsWith('xmlns:'))
666
+ continue;
667
+ if (understood.includes(attr.name))
668
+ continue;
669
+ if (isColourAttr(attr)) {
670
+ quarantine(payload, `${attr.name}="${escapeAttr(attr.value)}"`, BPMN_QUARANTINE_REASON.colour, { sourceId, element: attr.name });
671
+ continue;
672
+ }
673
+ carryAttr(payload, scope, attr.name, attr.value);
674
+ }
675
+ };
676
+ /** The DI element's own extras: colours quarantined, the rest kept as `di`. */
677
+ const sortShapeExtras = (shape, payload, sourceId) => {
678
+ if (!shape)
679
+ return;
680
+ sortAttributes(shape, payload, BPMN_SCOPE.shape, sourceId, READ_SHAPE_ATTRS);
681
+ for (const child of childrenOf(shape)) {
682
+ // The bounds and the waypoints ARE the drawing, and the drawing is what
683
+ // was mapped. A `BPMNLabel` and anything else is kept as diagram matter.
684
+ if (child.namespaceURI === BPMN_NS.dc && child.localName === 'Bounds') {
685
+ continue;
686
+ }
687
+ if (child.namespaceURI === BPMN_NS.di && child.localName === 'waypoint') {
688
+ continue;
689
+ }
690
+ carryDi(payload, BPMN_SCOPE.shape, fragmentOf(child));
691
+ }
692
+ };
693
+ /* ── Pools, from participants ──────────────────────────────────────── */
694
+ const processById = new Map();
695
+ for (const process of processes) {
696
+ const id = attrOf(process, 'id');
697
+ if (id !== undefined)
698
+ processById.set(id, process);
699
+ }
700
+ /** The process a participant named → the pool that draws the pair. */
701
+ const poolOfProcess = new Map();
702
+ const participants = collaborations.flatMap(collaboration => modelChildren(collaboration, 'participant'));
703
+ for (const participant of participants) {
704
+ const sourceId = attrOf(participant, 'id');
705
+ claim(sourceId, 'participant');
706
+ if (sourceId !== undefined) {
707
+ // A pool is an end a message flow may legally attach to (§10.6).
708
+ mappedSourceIds.add(sourceId);
709
+ drawnSourceIds.add(sourceId);
710
+ }
711
+ const shape = sourceId === undefined ? undefined : shapes.get(sourceId);
712
+ const payload = {};
713
+ // The PARTICIPANT's id, because the participant is what the pool draws and
714
+ // what a `BPMNShape` points at (D3). The process behind it is re-minted
715
+ // from this one on export — that is the one id of the pair the round trip
716
+ // does not keep, and it is in the loss table.
717
+ if (sourceId !== undefined)
718
+ payload.id = sourceId;
719
+ const ref = attrOf(participant, 'processRef');
720
+ const process = ref === undefined ? undefined : processById.get(ref);
721
+ sortAttributes(participant, payload, BPMN_SCOPE.self, sourceId);
722
+ sortShapeExtras(shape?.element, payload, sourceId);
723
+ // Labre draws ONE thing where the format writes two, so the process's own
724
+ // foreign matter rides on the pool that stands for it — under its own
725
+ // scope, because it is a different source element with its own attributes.
726
+ if (process) {
727
+ sortAttributes(process, payload, BPMN_SCOPE.process, attrOf(process, 'id'));
728
+ // A model downgrade if it were dropped: the writer emits
729
+ // `isExecutable="false"` for every process it writes, so a file that says
730
+ // `true` is saying something Labre does not model and must not lose.
731
+ const executable = attrOf(process, 'isExecutable');
732
+ if (executable !== undefined && executable !== 'false') {
733
+ carryAttr(payload, BPMN_SCOPE.process, 'isExecutable', executable);
734
+ }
735
+ }
736
+ const bounds = shape?.bounds ?? null;
737
+ const draft = {
738
+ props: {
739
+ type: 'bpmnPool',
740
+ // The FRAME the flow objects are drawn in, and a role of its own: a
741
+ // rule written on the artefacts must never fall on the pool.
742
+ role: BPMN_ROLE.pool,
743
+ // `''` and not `undefined`: the model's own default is "Pool", and a
744
+ // participant the file left unnamed must not acquire a name here.
745
+ name: attrOf(participant, 'name') ?? '',
746
+ xywh: (bounds
747
+ ? new Bound(bounds.x, bounds.y, bounds.w, bounds.h)
748
+ : new Bound(0, 0, POOL_REF_WIDTH, POOL_REF_HEIGHT)).serialize(),
749
+ },
750
+ payload,
751
+ order: shape?.index ?? Number.POSITIVE_INFINITY,
752
+ kind: 'pool',
753
+ ...(bounds
754
+ ? {}
755
+ : { needsLayout: { w: POOL_REF_WIDTH, h: POOL_REF_HEIGHT } }),
756
+ };
757
+ drafts.push(draft);
758
+ if (process)
759
+ poolOfProcess.set(process, draft);
760
+ if (!bounds) {
761
+ note({
762
+ kind: 'invented-layout',
763
+ sourceId,
764
+ element: 'participant',
765
+ message: `The participant "${draft.props.name || 'unnamed'}" arrived with no ` +
766
+ `diagram, so Labre placed its pool beside the drawing.`,
767
+ });
768
+ }
769
+ }
770
+ /**
771
+ * A pool for a file that named no participant (D6).
772
+ *
773
+ * A bare `process` is exactly what a poolless Labre board exports as, and it
774
+ * is what a good half of the single-participant files in the wild are. It
775
+ * gets a pool minted for it — the framework's background element, and the
776
+ * only thing there is for the document's residue to ride on — and that pool
777
+ * SAYS it stands for a process, which is what tells the exporter to give the
778
+ * poolless form back rather than invent a collaboration nobody drew.
779
+ */
780
+ const bareProcess = participants.length === 0 ? processes[0] : undefined;
781
+ let mintedPool;
782
+ if (bareProcess) {
783
+ const sourceId = attrOf(bareProcess, 'id');
784
+ claim(sourceId, 'process');
785
+ if (sourceId !== undefined) {
786
+ mappedSourceIds.add(sourceId);
787
+ drawnSourceIds.add(sourceId);
788
+ }
789
+ const payload = { element: 'process' };
790
+ if (sourceId !== undefined)
791
+ payload.id = sourceId;
792
+ // `@self` and not `@process`: this pool IS the process, which is exactly
793
+ // what `element: 'process'` says.
794
+ sortAttributes(bareProcess, payload, BPMN_SCOPE.self, sourceId);
795
+ const bareExecutable = attrOf(bareProcess, 'isExecutable');
796
+ if (bareExecutable !== undefined && bareExecutable !== 'false') {
797
+ carryAttr(payload, BPMN_SCOPE.self, 'isExecutable', bareExecutable);
798
+ }
799
+ mintedPool = {
800
+ props: {
801
+ type: 'bpmnPool',
802
+ role: BPMN_ROLE.pool,
803
+ name: attrOf(bareProcess, 'name') ?? '',
804
+ xywh: new Bound(0, 0, POOL_REF_WIDTH, POOL_REF_HEIGHT).serialize(),
805
+ },
806
+ payload,
807
+ // Behind everything: it is a frame the file never drew.
808
+ order: -1,
809
+ kind: 'pool',
810
+ };
811
+ drafts.push(mintedPool);
812
+ poolOfProcess.set(bareProcess, mintedPool);
813
+ note({
814
+ kind: 'invented-layout',
815
+ sourceId,
816
+ element: 'process',
817
+ message: `This file names no participant, so its process was drawn in a pool of ` +
818
+ `Labre's own. The pool is not the file's: exporting writes the process ` +
819
+ `back without one.`,
820
+ });
821
+ }
822
+ /**
823
+ * Where the document's own residue rides (D6): the first pool there is.
824
+ *
825
+ * A stated asymmetry rather than an oversight — delete that pool and the
826
+ * file's document-scope residue goes with it. Accepted: it is one value that
827
+ * copy-pastes, undoes and syncs with something the user can see, and an
828
+ * architect who has deleted the only pool of an imported process has deleted
829
+ * the process.
830
+ */
831
+ const residence = () => drafts.find(draft => draft.kind === 'pool');
832
+ /* ── Lanes ─────────────────────────────────────────────────────────── */
833
+ const laneBands = new Map();
834
+ for (const [process, pool] of poolOfProcess) {
835
+ const laneSet = modelChild(process, 'laneSet');
836
+ if (!laneSet)
837
+ continue;
838
+ sortAttributes(laneSet, pool.payload, BPMN_SCOPE.laneSet, attrOf(laneSet, 'id'));
839
+ const bands = [];
840
+ /**
841
+ * Walks a lane set, flattening a nested one onto its leaves (D5 case 3).
842
+ *
843
+ * `pool.lanes` is ONE flat list of bands over one plot: there is no gesture
844
+ * that puts a lane inside a lane, and inventing a containment model is a
845
+ * bigger decision than an importer gets to take. So the leaves are what
846
+ * land, named by their whole path ("Sales / Back office") so nothing about
847
+ * the original is unreadable, and the `childLaneSet` is quarantined —
848
+ * written back beside the flat set, it would describe the pool twice.
849
+ */
850
+ const walkLanes = (set, path) => {
851
+ for (const lane of modelChildren(set, 'lane')) {
852
+ const sourceId = attrOf(lane, 'id');
853
+ claim(sourceId, 'lane');
854
+ const name = attrOf(lane, 'name') ?? '';
855
+ const nested = modelChild(lane, 'childLaneSet');
856
+ if (nested) {
857
+ quarantine(pool.payload, fragmentOf(nested), BPMN_QUARANTINE_REASON.nestedLanes, { sourceId, element: 'childLaneSet' });
858
+ // A lane holding a child set is not a band Labre paints — its LEAVES
859
+ // are — so the shape drawing it describes a subdivision the flat pool
860
+ // does not have. Accounted for by the quarantine, and kept out of the
861
+ // orphan sweep, or the writer would put a stray band back.
862
+ if (sourceId !== undefined)
863
+ quarantinedSourceIds.add(sourceId);
864
+ walkLanes(nested, [...path, name]);
865
+ continue;
866
+ }
867
+ const rect = sourceId === undefined
868
+ ? null
869
+ : (shapes.get(sourceId)?.bounds ?? null);
870
+ // The file's id, verbatim: a lane has no interchange payload of its
871
+ // own, and this prop IS where its identity is kept (D3). The exporter
872
+ // writes it back unprefixed for exactly that reason. It is also this
873
+ // lane's SCOPE, so two lanes carrying one foreign attribute keep two
874
+ // values.
875
+ const laneId = sourceId ?? `lane-${bands.length + 1}`;
876
+ // A lane is drawn — its band is the pool's own subdivision — so its
877
+ // shape is consumed rather than orphaned, but it is not something a
878
+ // flow may attach to.
879
+ if (sourceId !== undefined)
880
+ drawnSourceIds.add(sourceId);
881
+ bands.push({
882
+ lane: {
883
+ id: laneId,
884
+ name: [...path, name].filter(Boolean).join(' / '),
885
+ // A relative WEIGHT, and the band's drawn height is the truest one
886
+ // there is: the plot is shared in proportion, so two bands that
887
+ // were 120 and 240 units tall come back as a third and two thirds,
888
+ // whatever the pool is resized to afterwards. Filled in below,
889
+ // because a set in which only SOME bands were drawn cannot mix the
890
+ // two kinds of number.
891
+ size: rect && rect.h > 0 ? rect.h : 1,
892
+ },
893
+ rect,
894
+ refs: modelChildren(lane, 'flowNodeRef')
895
+ .map(ref => (ref.textContent ?? '').trim())
896
+ .filter(Boolean),
897
+ });
898
+ // Everything else about the lane rides on the pool, which is the
899
+ // nearest thing that HAS a payload — under the lane's own scope.
900
+ sortAttributes(lane, pool.payload, laneId, sourceId);
901
+ for (const child of childrenOf(lane)) {
902
+ if (isModel(child) && child.localName === 'flowNodeRef')
903
+ continue;
904
+ carryChild(pool.payload, laneId, child, sourceId);
905
+ }
906
+ }
907
+ };
908
+ walkLanes(laneSet, []);
909
+ if (bands.length === 0)
910
+ continue;
911
+ // Top to bottom, which is the order a pool paints its bands in. Sorted by
912
+ // the DRAWING when the drawing says (D4: the file's diagram wins at
913
+ // import), and left in document order when it does not.
914
+ const allDrawn = bands.every(band => band.rect !== null);
915
+ if (allDrawn) {
916
+ bands.sort((a, b) => a.rect.y - b.rect.y);
917
+ }
918
+ else {
919
+ // A drawn height and the fallback `1` are not the same KIND of number: a
920
+ // band of 200 beside a band of 1 paints a hairline nobody drew. So a set
921
+ // that is not wholly drawn is split equally, and — like every other
922
+ // position this reader invents (D4) — it says so.
923
+ for (const band of bands)
924
+ band.lane.size = 1;
925
+ note({
926
+ kind: 'invented-layout',
927
+ sourceId: attrOf(laneSet, 'id'),
928
+ element: 'laneSet',
929
+ message: `${bands.length === 1 ? 'This lane' : `Some of these ${bands.length} lanes`} ` +
930
+ `arrived with no diagram, so Labre split the pool into equal bands. ` +
931
+ `The proportions are Labre's and not the file's.`,
932
+ });
933
+ }
934
+ // Bands that do not tile the pool are still only WEIGHTS here — Labre lays
935
+ // them end to end — so a file that drew a gap or an overlap between two
936
+ // lanes comes back with the gap closed. That changes the picture, so it is
937
+ // said once rather than discovered.
938
+ if (allDrawn && bands.length > 1) {
939
+ const gap = bands.slice(1).some((band, index) => {
940
+ const above = bands[index].rect;
941
+ return Math.abs(band.rect.y - (above.y + above.h)) > 0.5;
942
+ });
943
+ if (gap) {
944
+ note({
945
+ kind: 'invented-layout',
946
+ sourceId: attrOf(laneSet, 'id'),
947
+ element: 'laneSet',
948
+ message: `The lanes of this pool are drawn with a gap or an overlap between ` +
949
+ `them. Labre lays its bands end to end, so their heights were kept ` +
950
+ `in proportion and the space between them was closed.`,
951
+ });
952
+ }
953
+ }
954
+ pool.props.lanes = bands.map(band => band.lane);
955
+ laneBands.set(pool, bands);
956
+ }
957
+ /* ── Flow nodes, data references, artifacts ────────────────────────── */
958
+ /** Source id → the box it was drawn in, for the lane membership check. */
959
+ const nodeBounds = new Map();
960
+ /** The `dataObject`s a `dataObjectReference` folds in (§10.4.1). */
961
+ const foldedDataObjects = new Set();
962
+ for (const process of processes) {
963
+ for (const reference of modelChildren(process, 'dataObjectReference')) {
964
+ const ref = attrOf(reference, 'dataObjectRef');
965
+ if (ref !== undefined)
966
+ foldedDataObjects.add(ref);
967
+ }
968
+ }
969
+ /**
970
+ * One semantic element of a scope: mapped, or carried on `host` under
971
+ * `hostScope` — which is the element it was a child of, because that is where
972
+ * an exporter has to put it back.
973
+ */
974
+ const readNode = (element, host, hostScope) => {
975
+ const sourceId = attrOf(element, 'id');
976
+ const local = element.localName;
977
+ // The `dataObject` behind a reference is folded INTO the reference — DI
978
+ // attaches to the reference, and the exporter writes the object back out
979
+ // of the drawing — so it is neither mapped nor carried.
980
+ if (local === 'dataObject' &&
981
+ sourceId !== undefined &&
982
+ foldedDataObjects.has(sourceId)) {
983
+ return;
984
+ }
985
+ // What TRIGGERS an event, in either of the two forms §10.5.2 allows: the
986
+ // definition written inside the event, or a reference to one declared at
987
+ // root scope (Table 10.82). Both are read, because both say the same thing
988
+ // — and an event whose trigger is named by reference must never come back
989
+ // as the None event the spec says an event with no definition is.
990
+ const trigger = childrenOf(element).find(child => isModel(child) && child.localName.endsWith('EventDefinition'));
991
+ const triggerRef = modelChild(element, 'eventDefinitionRef');
992
+ const referenced = (() => {
993
+ const ref = triggerRef?.textContent?.trim();
994
+ if (!ref)
995
+ return undefined;
996
+ // A QName, resolved by id within this one file — which is what every tool
997
+ // does with the unprefixed form this format writes everywhere else.
998
+ return roots.find(root => attrOf(root, 'id') === ref.split(':').pop() &&
999
+ root.localName.endsWith('EventDefinition'));
1000
+ })();
1001
+ const kind = BPMN_KIND_OF_XML.get(xmlKindKey(local, (trigger ?? referenced)?.localName));
1002
+ // A trigger we could not read is not a trigger we may drop: the event goes
1003
+ // whole into the carried branch below rather than onto the canvas claiming
1004
+ // something the file did not say.
1005
+ if (kind === undefined && triggerRef !== undefined) {
1006
+ note({
1007
+ kind: 'warning',
1008
+ sourceId,
1009
+ element: local,
1010
+ message: `<${local}> names its trigger by reference to ` +
1011
+ `"${triggerRef.textContent?.trim() ?? ''}", which Labre does not ` +
1012
+ `draw. The event was kept whole rather than drawn as a plain one.`,
1013
+ });
1014
+ }
1015
+ if (kind === undefined) {
1016
+ // CARRIED, and standing on its own: an Analytic or executable flow node —
1017
+ // a boundary event, an inclusive gateway, a transaction — that Labre has
1018
+ // no artefact for. It rides on the pool of the process it was written
1019
+ // in, which is the nearest mapped element there is, and its DI rides with
1020
+ // it so that whatever writes it back can draw it where it was.
1021
+ if (!host)
1022
+ return;
1023
+ carryChild(host.payload, hostScope, element, sourceId);
1024
+ if (sourceId !== undefined)
1025
+ carriedSourceIds.add(sourceId);
1026
+ const shape = sourceId === undefined ? undefined : shapes.get(sourceId);
1027
+ if (shape) {
1028
+ // Keyed by what it DRAWS, which is the carried element itself — the
1029
+ // only way a writer can pair the two back up.
1030
+ carryDi(host.payload, sourceId ?? hostScope, fragmentOf(shape.element));
1031
+ }
1032
+ return;
1033
+ }
1034
+ claim(sourceId, local);
1035
+ if (sourceId !== undefined) {
1036
+ mappedSourceIds.add(sourceId);
1037
+ drawnSourceIds.add(sourceId);
1038
+ }
1039
+ const payload = {};
1040
+ if (sourceId !== undefined)
1041
+ payload.id = sourceId;
1042
+ const shape = sourceId === undefined ? undefined : shapes.get(sourceId);
1043
+ const bounds = shape?.bounds ?? null;
1044
+ sortAttributes(element, payload, BPMN_SCOPE.self, sourceId);
1045
+ sortShapeExtras(shape?.element, payload, sourceId);
1046
+ // The label, from wherever this kind keeps it: an annotation's is a child
1047
+ // element, a group's is the value of the category it points at, everything
1048
+ // else's is its own `name`.
1049
+ let text = attrOf(element, 'name') ?? '';
1050
+ if (local === 'textAnnotation') {
1051
+ text = modelChild(element, 'text')?.textContent?.trim() ?? '';
1052
+ }
1053
+ else if (local === 'group') {
1054
+ const ref = attrOf(element, 'categoryValueRef');
1055
+ text = (ref !== undefined ? categoryValues.get(ref) : undefined) ?? '';
1056
+ }
1057
+ // D5 case 2: an activity the DIAGRAM says is expanded holds a flow drawn
1058
+ // inside it, and the pack draws the collapsed form only.
1059
+ const expanded = shape !== undefined && attrOf(shape.element, 'isExpanded') === 'true';
1060
+ for (const child of childrenOf(element)) {
1061
+ // The trigger IS the kind — in either of its two forms — and the
1062
+ // annotation's text IS the label: children that were read, not carried.
1063
+ if (child === trigger || child === triggerRef)
1064
+ continue;
1065
+ if (isModel(child) &&
1066
+ local === 'textAnnotation' &&
1067
+ child.localName === 'text') {
1068
+ continue;
1069
+ }
1070
+ if (expanded && isModel(child) && !NOT_A_BODY.has(child.localName)) {
1071
+ quarantine(payload, fragmentOf(child), BPMN_QUARANTINE_REASON.expanded, { sourceId, element: child.nodeName });
1072
+ // The body's diagram goes with the body, all the way down. A shape left
1073
+ // behind here is an ORPHAN — nothing declares what it draws any more —
1074
+ // so the residue sweep at the end of this reader would pick it up and
1075
+ // carry it, and the writer would then draw it: the quarantine defeated
1076
+ // by its own leftovers, which is the "absent from the re-export" half
1077
+ // of D5 that nothing could fail on until re-emission landed.
1078
+ for (const held of [child, ...descendantsOf(child)]) {
1079
+ const inner = attrOf(held, 'id');
1080
+ if (inner === undefined)
1081
+ continue;
1082
+ quarantinedSourceIds.add(inner);
1083
+ const drawn = shapes.get(inner)?.element ?? diEdges.get(inner)?.element;
1084
+ if (!drawn)
1085
+ continue;
1086
+ payload.quarantined = [
1087
+ ...(payload.quarantined ?? []),
1088
+ {
1089
+ fragment: fragmentOf(drawn),
1090
+ reason: BPMN_QUARANTINE_REASON.expanded,
1091
+ },
1092
+ ];
1093
+ }
1094
+ continue;
1095
+ }
1096
+ carryChild(payload, BPMN_SCOPE.self, child, sourceId);
1097
+ }
1098
+ const size = NODE_SIZE[kind];
1099
+ drafts.push({
1100
+ props: bpmnNodeProps(kind, {
1101
+ xywh: (bounds
1102
+ ? new Bound(bounds.x, bounds.y, bounds.w, bounds.h)
1103
+ : new Bound(0, 0, size.w, size.h)).serialize(),
1104
+ text: text || undefined,
1105
+ }),
1106
+ payload,
1107
+ order: shape?.index ?? Number.POSITIVE_INFINITY,
1108
+ kind: 'node',
1109
+ ...(bounds ? {} : { needsLayout: size }),
1110
+ });
1111
+ if (sourceId !== undefined && bounds)
1112
+ nodeBounds.set(sourceId, bounds);
1113
+ if (!bounds) {
1114
+ note({
1115
+ kind: 'invented-layout',
1116
+ sourceId,
1117
+ element: local,
1118
+ message: `<${local}> arrived with no diagram, so Labre placed it beside the ` +
1119
+ `drawing. Its position is Labre's and not the file's.`,
1120
+ });
1121
+ }
1122
+ };
1123
+ /**
1124
+ * The edges, held back until every node of the document has been read.
1125
+ *
1126
+ * A flow may name an end declared further down the file, so whether both of
1127
+ * its ends were MAPPED is not knowable while the walk is still going. It has
1128
+ * to be knowable: a flow onto a carried node — a boundary event's error path,
1129
+ * which is the commonest Analytic construct there is — must not become a
1130
+ * connector with a dead end, drawn on the canvas, attached to nothing and
1131
+ * dropped by the next export. See {@link readEdges}.
1132
+ */
1133
+ const pendingEdges = [];
1134
+ /** `true` when this element WAS an edge, whatever became of it. */
1135
+ const collectEdge = (element, host, hostScope) => {
1136
+ if (EDGE_ROLE_OF_ELEMENT[element.localName] === undefined)
1137
+ return false;
1138
+ pendingEdges.push({ element, host, hostScope });
1139
+ return true;
1140
+ };
1141
+ /** Every flow, once the whole document is known. */
1142
+ const readEdges = () => {
1143
+ for (const { element, host, hostScope } of pendingEdges) {
1144
+ const local = element.localName;
1145
+ const role = EDGE_ROLE_OF_ELEMENT[local];
1146
+ const sourceId = attrOf(element, 'id');
1147
+ const from = attrOf(element, 'sourceRef');
1148
+ const to = attrOf(element, 'targetRef');
1149
+ if (!from || !to) {
1150
+ note({
1151
+ kind: 'warning',
1152
+ sourceId,
1153
+ element: local,
1154
+ message: `<${local}> names only one of its two ends, so there is no arrow ` +
1155
+ `to draw between them. It was left out.`,
1156
+ });
1157
+ continue;
1158
+ }
1159
+ // An end on something Labre did not draw. Carried whole, beside the node
1160
+ // it points at and under the same scope, so the pair travels together and
1161
+ // re-emits together — never a live connector with a dead end, which would
1162
+ // be the fourth state D1 says does not exist.
1163
+ const dangling = [from, to].filter(end => !mappedSourceIds.has(end));
1164
+ if (dangling.length > 0) {
1165
+ if (!host)
1166
+ continue;
1167
+ carryChild(host.payload, hostScope, element, sourceId, false);
1168
+ if (sourceId !== undefined)
1169
+ carriedSourceIds.add(sourceId);
1170
+ const edgeDi = sourceId === undefined ? undefined : diEdges.get(sourceId);
1171
+ if (edgeDi) {
1172
+ carryDi(host.payload, sourceId ?? hostScope, fragmentOf(edgeDi.element));
1173
+ }
1174
+ note({
1175
+ kind: 'warning',
1176
+ sourceId,
1177
+ element: local,
1178
+ message: `<${local}> runs to ${dangling.map(end => `"${end}"`).join(' and ')}, ` +
1179
+ `which ${dangling.length === 1 ? 'is' : 'are'} not drawn on this ` +
1180
+ `canvas. The flow is kept whole beside ` +
1181
+ `${dangling.length === 1 ? 'it' : 'them'} rather than drawn with a ` +
1182
+ `loose end.`,
1183
+ });
1184
+ continue;
1185
+ }
1186
+ claim(sourceId, local);
1187
+ if (sourceId !== undefined)
1188
+ drawnSourceIds.add(sourceId);
1189
+ const payload = {};
1190
+ if (sourceId !== undefined)
1191
+ payload.id = sourceId;
1192
+ sortAttributes(element, payload, BPMN_SCOPE.self, sourceId);
1193
+ // The exporter writes `associationDirection="None"` on every association
1194
+ // — the role is declared without a direction — so only another value is
1195
+ // something the model does not hold.
1196
+ const direction = attrOf(element, 'associationDirection');
1197
+ if (local === 'association' &&
1198
+ direction !== undefined &&
1199
+ direction !== 'None') {
1200
+ carryAttr(payload, BPMN_SCOPE.self, 'associationDirection', direction);
1201
+ }
1202
+ for (const child of childrenOf(element)) {
1203
+ carryChild(payload, BPMN_SCOPE.self, child, sourceId);
1204
+ }
1205
+ const di = sourceId === undefined ? undefined : diEdges.get(sourceId);
1206
+ sortShapeExtras(di?.element, payload, sourceId);
1207
+ if (di && di.waypoints > 2)
1208
+ explicitRoutes++;
1209
+ const name = attrOf(element, 'name');
1210
+ drafts.push({
1211
+ props: {
1212
+ ...connectorProps(role),
1213
+ // The SOURCE FILE's ids — the caller remaps them onto the ones the
1214
+ // surface minted. See the module comment.
1215
+ source: { id: from, position: [0.5, 0.5] },
1216
+ target: { id: to, position: [0.5, 0.5] },
1217
+ ...(name ? { text: name } : {}),
1218
+ },
1219
+ payload,
1220
+ order: di?.index ?? Number.POSITIVE_INFINITY,
1221
+ kind: 'edge',
1222
+ });
1223
+ }
1224
+ };
1225
+ /* ── Walking the document ──────────────────────────────────────────── */
1226
+ for (const collaboration of collaborations) {
1227
+ const host = residence();
1228
+ const scope = BPMN_SCOPE.collaboration;
1229
+ if (host) {
1230
+ sortAttributes(collaboration, host.payload, scope, attrOf(collaboration, 'id'));
1231
+ }
1232
+ for (const child of childrenOf(collaboration)) {
1233
+ if (child.localName === 'participant' && isModel(child))
1234
+ continue;
1235
+ if (isModel(child) && collectEdge(child, host, scope))
1236
+ continue;
1237
+ if (isModel(child)) {
1238
+ readNode(child, host, scope);
1239
+ continue;
1240
+ }
1241
+ if (host) {
1242
+ carryChild(host.payload, scope, child, attrOf(collaboration, 'id'));
1243
+ }
1244
+ }
1245
+ }
1246
+ for (const process of processes) {
1247
+ const pool = poolOfProcess.get(process);
1248
+ const host = pool ?? residence();
1249
+ // `@self` when the pool IS this process (a file with no participant),
1250
+ // `@process` when the pool is a participant standing in front of it.
1251
+ const scope = pool && pool === mintedPool ? BPMN_SCOPE.self : BPMN_SCOPE.process;
1252
+ for (const child of childrenOf(process)) {
1253
+ if (child.localName === 'laneSet' && isModel(child))
1254
+ continue;
1255
+ if (isModel(child) && collectEdge(child, host, scope))
1256
+ continue;
1257
+ if (isModel(child)) {
1258
+ readNode(child, host, scope);
1259
+ continue;
1260
+ }
1261
+ if (host)
1262
+ carryChild(host.payload, scope, child, attrOf(process, 'id'));
1263
+ }
1264
+ }
1265
+ readEdges();
1266
+ /* ── The document's own residue (D6) ───────────────────────────────── */
1267
+ const host = residence();
1268
+ const residue = [];
1269
+ for (const root of childrenOf(definitions)) {
1270
+ if (root.namespaceURI === BPMN_NS.bpmndi)
1271
+ continue;
1272
+ if (isModel(root) &&
1273
+ ['collaboration', 'process', 'category'].includes(root.localName)) {
1274
+ continue;
1275
+ }
1276
+ residue.push(root);
1277
+ }
1278
+ if (host) {
1279
+ // `definitions`' own foreign attributes, and every namespace declaration
1280
+ // this library is not going to write for itself: a carried `camunda:`
1281
+ // fragment means nothing without the declaration it was written under, and
1282
+ // neither does a `bpmn2:boundaryEvent`.
1283
+ //
1284
+ // The test is the PAIR and not the URI. `xmlns:bpmn2` and `xmlns:bpmn` name
1285
+ // the same namespace and are not interchangeable to a fragment stored
1286
+ // verbatim: dropping the file's prefix would leave every carried fragment
1287
+ // in this document unreadable, which is what the writer half found. What is
1288
+ // dropped is only an exact match of a declaration `export.ts` makes anyway
1289
+ // — which is what keeps a Labre file's payload empty.
1290
+ for (const attr of Array.from(definitions.attributes)) {
1291
+ if (attr.name === 'xmlns' || attr.name.startsWith('xmlns:')) {
1292
+ if (BPMN_OWN_DECLARATIONS[attr.name] !== attr.value) {
1293
+ carryAttr(host.payload, BPMN_SCOPE.definitions, attr.name, attr.value);
1294
+ }
1295
+ continue;
1296
+ }
1297
+ if (READ_ATTRS.definitions.includes(attr.name))
1298
+ continue;
1299
+ carryAttr(host.payload, BPMN_SCOPE.definitions, attr.name, attr.value);
1300
+ }
1301
+ for (const root of residue) {
1302
+ // D5 case 4: §15.3.1 wants the file set self-contained and v1 reads one
1303
+ // file, so writing an `<import>` back would claim a resolution we never
1304
+ // made.
1305
+ if (isModel(root) && root.localName === 'import') {
1306
+ quarantine(host.payload, fragmentOf(root), BPMN_QUARANTINE_REASON.imported, { element: 'import' });
1307
+ continue;
1308
+ }
1309
+ // Not quarantine: the file telling us our reading of it may be wrong,
1310
+ // which is a thing to say out loud and not a thing to withhold.
1311
+ if (isModel(root) &&
1312
+ root.localName === 'extension' &&
1313
+ attrOf(root, 'mustUnderstand') === 'true') {
1314
+ note({
1315
+ kind: 'warning',
1316
+ element: 'extension',
1317
+ message: `The file declares an extension that it says MUST be understood to ` +
1318
+ `read the model correctly. Labre does not understand it: the import ` +
1319
+ `went ahead, and this reading of the process may be wrong.`,
1320
+ });
1321
+ }
1322
+ carryChild(host.payload, BPMN_SCOPE.definitions, root, attrOf(definitions, 'id'));
1323
+ }
1324
+ // A `BPMNShape` or `BPMNEdge` that draws an element the file never
1325
+ // declares. It is broken in the source — nothing can resolve it — but it is
1326
+ // still a node of the file, and D1 has no state for "quietly forgotten":
1327
+ // kept under the id it names, and named in the report so a reader can go
1328
+ // and look.
1329
+ for (const [target, shape] of [
1330
+ ...[...shapes].map(([id, entry]) => [id, entry.element]),
1331
+ ...[...diEdges].map(([id, entry]) => [id, entry.element]),
1332
+ ]) {
1333
+ if (drawnSourceIds.has(target) ||
1334
+ carriedSourceIds.has(target) ||
1335
+ quarantinedSourceIds.has(target)) {
1336
+ continue;
1337
+ }
1338
+ carryDi(host.payload, target, fragmentOf(shape));
1339
+ note({
1340
+ kind: 'warning',
1341
+ sourceId: target,
1342
+ element: shape.localName,
1343
+ message: `The diagram draws "${target}", which the file does not declare. ` +
1344
+ `The shape is kept, and nothing is drawn for it.`,
1345
+ });
1346
+ }
1347
+ }
1348
+ else if (residue.length > 0) {
1349
+ // Nothing was drawn, so there is nothing for the residue to ride on. Said
1350
+ // rather than swallowed: D2's carrier is an element, and a file with no
1351
+ // process and no participant has none.
1352
+ note({
1353
+ kind: 'warning',
1354
+ message: `This file declares ${residue.length} root ` +
1355
+ `${residue.length === 1 ? 'element' : 'elements'} and no process to ` +
1356
+ `draw, so there was no artefact for ` +
1357
+ `${residue.length === 1 ? 'it' : 'them'} to be kept on. Nothing was ` +
1358
+ `imported.`,
1359
+ });
1360
+ }
1361
+ /* ── Lane membership: checked against the drawing, never stored (D3) ─ */
1362
+ for (const bands of laneBands.values()) {
1363
+ if (!bands.every(band => band.rect !== null))
1364
+ continue;
1365
+ for (const band of bands) {
1366
+ for (const ref of band.refs) {
1367
+ const box = nodeBounds.get(ref);
1368
+ if (!box)
1369
+ continue;
1370
+ const centre = box.y + box.h / 2;
1371
+ const drawnIn = bands.find(candidate => candidate.rect !== null &&
1372
+ centre >= candidate.rect.y &&
1373
+ centre <= candidate.rect.y + candidate.rect.h);
1374
+ if (!drawnIn || drawnIn === band)
1375
+ continue;
1376
+ note({
1377
+ kind: 'warning',
1378
+ sourceId: ref,
1379
+ element: 'flowNodeRef',
1380
+ message: `The file lists this artefact in the lane "${band.lane.name}" and ` +
1381
+ `draws it in "${drawnIn.lane.name}". Labre reads the drawing: a ` +
1382
+ `lane holds what is drawn inside it.`,
1383
+ });
1384
+ }
1385
+ }
1386
+ }
1387
+ /* ── What the file did not place (D4) ──────────────────────────────── */
1388
+ layOutTheUndrawn(drafts, mintedPool);
1389
+ if (explicitRoutes > 0) {
1390
+ note({
1391
+ kind: 'invented-layout',
1392
+ message: `${explicitRoutes} ${explicitRoutes === 1 ? 'flow carries' : 'flows carry'} ` +
1393
+ `an explicit routing in the file. Labre routes a flow between its two ` +
1394
+ `ends and re-routes it whenever they move, so ` +
1395
+ `${explicitRoutes === 1 ? 'its bend points are' : 'their bend points are'} ` +
1396
+ `not kept.`,
1397
+ });
1398
+ }
1399
+ /* ── Out ───────────────────────────────────────────────────────────── */
1400
+ // The DRAWING's order is the board's order: the exporter writes its shapes in
1401
+ // document order, so reading them back in plane order is what lets an export
1402
+ // after an import land on the bytes of the export before it. Anything undrawn
1403
+ // keeps its document order, after everything drawn.
1404
+ const ordered = drafts
1405
+ .map((draft, index) => ({ draft, index }))
1406
+ .sort((a, b) => a.draft.order === b.draft.order
1407
+ ? a.index - b.index
1408
+ : a.draft.order - b.draft.order)
1409
+ .map(entry => entry.draft);
1410
+ const elements = ordered.map(draft => {
1411
+ const payload = draft.payload;
1412
+ const empty = payload.id === undefined &&
1413
+ payload.element === undefined &&
1414
+ payload.attrs === undefined &&
1415
+ payload.children === undefined &&
1416
+ payload.di === undefined &&
1417
+ payload.quarantined === undefined;
1418
+ // Written as ONE whole blob, or not written at all: the Y.Map entry is the
1419
+ // entire record, so a partial update is a last-write-wins overwrite of
1420
+ // everything, and an element that carried nothing must keep no key (D2).
1421
+ return empty
1422
+ ? draft.props
1423
+ : { ...draft.props, interchange: { [BPMN_FORMAT_ID]: payload } };
1424
+ });
1425
+ const lanes = ordered.reduce((total, draft) => total + (Array.isArray(draft.props.lanes) ? draft.props.lanes.length : 0), 0);
1426
+ const sourceVersion = sourceVersionOf(definitions);
1427
+ return {
1428
+ elements,
1429
+ report: {
1430
+ // Everything that became a drawn, editable artefact: the pools, the flow
1431
+ // objects, the arrows — and the LANES, which are drawn and editable and
1432
+ // are not elements of their own.
1433
+ mapped: ordered.length + lanes,
1434
+ carried,
1435
+ quarantined,
1436
+ notes,
1437
+ ...(sourceVersion !== undefined ? { sourceVersion } : {}),
1438
+ },
1439
+ };
1440
+ }