@formicoidea/labre-framework-bpmn 0.32.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 (47) hide show
  1. package/dist/actions.d.ts +202 -6
  2. package/dist/actions.js +421 -43
  3. package/dist/background.d.ts +2 -0
  4. package/dist/background.js +158 -0
  5. package/dist/commands.js +496 -5
  6. package/dist/consts.d.ts +157 -3
  7. package/dist/consts.js +192 -3
  8. package/dist/element-renderer.d.ts +10 -4
  9. package/dist/element-renderer.js +14 -55
  10. package/dist/element-view.d.ts +100 -8
  11. package/dist/element-view.js +249 -30
  12. package/dist/export.d.ts +277 -0
  13. package/dist/export.js +1802 -0
  14. package/dist/facts.d.ts +48 -0
  15. package/dist/facts.js +127 -0
  16. package/dist/import.d.ts +44 -0
  17. package/dist/import.js +1440 -0
  18. package/dist/index.d.ts +12 -0
  19. package/dist/index.js +44 -0
  20. package/dist/interchange.d.ts +109 -0
  21. package/dist/interchange.js +191 -0
  22. package/dist/morph.d.ts +61 -0
  23. package/dist/morph.js +118 -0
  24. package/dist/node/node-renderer.d.ts +0 -9
  25. package/dist/node/node-renderer.js +294 -17
  26. package/dist/pool-hit.d.ts +98 -0
  27. package/dist/pool-hit.js +130 -0
  28. package/dist/presets.d.ts +114 -0
  29. package/dist/presets.js +232 -0
  30. package/dist/profiles.d.ts +2 -0
  31. package/dist/profiles.js +189 -0
  32. package/dist/roles.d.ts +96 -0
  33. package/dist/roles.js +410 -0
  34. package/dist/rules.d.ts +199 -0
  35. package/dist/rules.js +1539 -0
  36. package/dist/templates/index.js +116 -9
  37. package/dist/toolbar/bpmn-senior-button.js +8 -2
  38. package/dist/toolbar/config.d.ts +27 -2
  39. package/dist/toolbar/config.js +86 -2
  40. package/dist/toolbar/icons.d.ts +67 -0
  41. package/dist/toolbar/icons.js +141 -0
  42. package/dist/toolbar/senior-tool.js +1 -0
  43. package/dist/translations.d.ts +3 -1
  44. package/dist/translations.js +8 -3
  45. package/dist/view.d.ts +6 -2
  46. package/dist/view.js +68 -5
  47. package/package.json +2 -2
package/dist/export.js ADDED
@@ -0,0 +1,1802 @@
1
+ import { backgroundInstanceZones, backgroundPlot, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { BPMN_POOL_BACKGROUND } from './background.js';
3
+ import { bpmnLaneOf, bpmnPoolOf } from './facts.js';
4
+ import { BPMN_ROLE } from './roles.js';
5
+ /** The namespace the pool's instance zones report under. See `facts.ts`. */
6
+ const LANE_PREFIX = BPMN_POOL_BACKGROUND.instanceZones?.idPrefix ?? 'lane';
7
+ /**
8
+ * The board, as a BPMN 2.0 interchange document (clause 15) — semantic model
9
+ * plus BPMN DI, in one `definitions` element.
10
+ *
11
+ * ## Pure by construction
12
+ *
13
+ * Element models in, string out. No `BlockStdScope`, no surface, no DOM, no
14
+ * clock and no randomness — the same discipline `facts.ts` holds itself to, and
15
+ * for the same three reasons: a host can call it, a test can call it with plain
16
+ * stubs, and the same board always serializes to the same bytes. The command
17
+ * that downloads the file is the only thing that knows what a canvas is.
18
+ *
19
+ * ## What it says, and what it refuses to say
20
+ *
21
+ * The export speaks the author's STATEMENTS and nothing else. A connector
22
+ * carrying no BPMN role relates nothing — `docs/adr/0010` is explicit that the
23
+ * role is the statement — so it is not a sequence flow that happens to be
24
+ * untyped, it is not a flow at all, and it is absent. A plain rectangle drawn
25
+ * beside a pool is likewise not an unnamed task. The alternative — guessing —
26
+ * would put words in an architect's mouth in a file they are about to hand to
27
+ * an execution engine.
28
+ *
29
+ * ## Conformance target
30
+ *
31
+ * The **Descriptive** sub-class of BPMN 2.0 (spec Table 2.1), which is exactly
32
+ * the vocabulary the pack draws: the seventeen artefacts map onto the
33
+ * seventeen-odd element names that table lists, and nothing here needs the
34
+ * executable half of the metamodel. Clause 15.1 explicitly licenses a partial
35
+ * model — implementers "disregard missing attributes marked required" — which
36
+ * is what lets a picture drawn for humans round-trip through bpmn.io without
37
+ * inventing an `ioSpecification` nobody asked for.
38
+ */
39
+ /* ── Namespaces ───────────────────────────────────────────────────────── */
40
+ /**
41
+ * The four namespaces an interchange file is written in, with the prefixes the
42
+ * spec's own schema uses (`bpmndi`, `di`, `dc`; §12.2.4 and Annex B).
43
+ *
44
+ * Prefixes are arbitrary and URIs are not — bpmn.io writes the same two DD
45
+ * namespaces as `omgdi` / `omgdc` — so the URIs are what is pinned by the tests
46
+ * and the prefixes merely have to be consistent with themselves. The MODEL
47
+ * namespace is given the explicit `bpmn` prefix rather than made the default,
48
+ * because a reader of the file should never have to work out which of two
49
+ * unprefixed vocabularies an element belongs to.
50
+ *
51
+ * The stale `.../BPMNDI/1.0.0` that appears in the spec's own clause 15.3.1
52
+ * example is a documented erratum and is NOT what the normative schema says.
53
+ */
54
+ export const BPMN_NS = {
55
+ model: 'http://www.omg.org/spec/BPMN/20100524/MODEL',
56
+ bpmndi: 'http://www.omg.org/spec/BPMN/20100524/DI',
57
+ di: 'http://www.omg.org/spec/DD/20100524/DI',
58
+ dc: 'http://www.omg.org/spec/DD/20100524/DC',
59
+ };
60
+ /**
61
+ * The four declarations this library writes, as they appear on `definitions` —
62
+ * the PAIR, prefix and URI together, and the pair is the point.
63
+ *
64
+ * A reader carries a file's namespace declarations because a carried fragment
65
+ * is stored with the prefixes the file spelled it in, and a `camunda:property`
66
+ * or a `bpmn2:boundaryEvent` means nothing under a declaration nobody wrote.
67
+ * What it must NOT carry is a declaration this writer is going to make anyway,
68
+ * or the payload gains four permanent entries, every Labre file reports four
69
+ * things carried, and the "a file we wrote comes back with an empty middle
70
+ * column" property — the no-slow-leak property — stops being true.
71
+ *
72
+ * Keyed by the attribute NAME rather than by the URI, because the prefix is
73
+ * exactly what differs: bpmn.io writes the model namespace as `bpmn2:` and this
74
+ * library writes it as `bpmn:`, and a fragment carrying `bpmn2:` is unreadable
75
+ * unless `xmlns:bpmn2` comes back with it. Same URI, different prefix,
76
+ * different fate — which a set of URIs cannot express.
77
+ */
78
+ export const BPMN_OWN_DECLARATIONS = {
79
+ 'xmlns:bpmn': BPMN_NS.model,
80
+ 'xmlns:bpmndi': BPMN_NS.bpmndi,
81
+ 'xmlns:di': BPMN_NS.di,
82
+ 'xmlns:dc': BPMN_NS.dc,
83
+ };
84
+ /**
85
+ * Where the ids this exporter mints live.
86
+ *
87
+ * `targetNamespace` is the ONE attribute `definitions` requires (spec §15.3.1),
88
+ * and it has to be a URI nobody else claims: it is what a second document
89
+ * IMPORTING this one would qualify its references with.
90
+ *
91
+ * It is deliberately NOT what the references inside this file resolve through.
92
+ * Several of them are typed `xsd:QName` — `participant/@processRef`,
93
+ * `group/@categoryValueRef`, `dataObjectReference/@dataObjectRef`, every DI
94
+ * `bpmnElement`, and `messageFlow` / `association` `sourceRef` / `targetRef` —
95
+ * and an unprefixed QName resolves against the DEFAULT namespace, which this
96
+ * document declares none of (see the note on {@link BPMN_NS} for why the MODEL
97
+ * namespace takes an explicit prefix instead). They are emitted as bare local
98
+ * names, which is byte for byte what bpmn.io and Camunda Modeler write, and
99
+ * every tool resolves them by id within the one file.
100
+ */
101
+ const TARGET_NAMESPACE = 'https://labre.app/bpmn';
102
+ /**
103
+ * The interchange format's id — the key under which foreign matter from a
104
+ * `.bpmn` rides on an element (`interchange.bpmn`, ADR 0012 D2), and the middle
105
+ * term of both capability ids.
106
+ *
107
+ * Declared here, in the module both directions already depend on, so that the
108
+ * writer, the reader and the registry entry cannot disagree about which key
109
+ * they are talking about.
110
+ */
111
+ export const BPMN_FORMAT_ID = 'bpmn';
112
+ const EXPORTER = 'Labre';
113
+ /**
114
+ * The whole notation, kind by kind — the table this module is really about.
115
+ *
116
+ * `Record<BpmnNodeKind, …>` and therefore COMPILE-TOTAL: a kind added to the
117
+ * pack without a BPMN element name to serialize it as fails the build here,
118
+ * which is the only place that failure is cheap. A kind that reached a
119
+ * document and had no mapping would be an artefact the author drew, saved, and
120
+ * then silently lost on export.
121
+ *
122
+ * Three of the seventeen do not map one-for-one and the reasons are the spec's:
123
+ *
124
+ * - the four TRIGGERED events are `startEvent` / `endEvent` carrying an event
125
+ * definition child, never elements of their own — "message start event" is a
126
+ * start event with a `messageEventDefinition` in it (§10.4.2);
127
+ * - a data object serializes as `dataObjectReference`, because DI attaches to
128
+ * the REFERENCE and not to the `dataObject` it points at (§10.4.1, and the
129
+ * spec's own rule that "Data Object Reference cannot specify item
130
+ * definitions, and Data Objects cannot specify states"). The `dataObject`
131
+ * itself is emitted alongside it;
132
+ * - a `group` carries no `name` at all. Its visible label is the `value` of the
133
+ * `categoryValue` it points at, which is a ROOT element of the document — the
134
+ * one place in this file where drawing a box round three tasks costs two
135
+ * extra elements somewhere else entirely (§10.4, Table 8.30).
136
+ */
137
+ export const BPMN_XML_OF_KIND = {
138
+ startEvent: { element: 'startEvent', slot: 'flowNode' },
139
+ startEventMessage: {
140
+ element: 'startEvent',
141
+ slot: 'flowNode',
142
+ eventDefinition: 'messageEventDefinition',
143
+ },
144
+ startEventTimer: {
145
+ element: 'startEvent',
146
+ slot: 'flowNode',
147
+ eventDefinition: 'timerEventDefinition',
148
+ },
149
+ endEvent: { element: 'endEvent', slot: 'flowNode' },
150
+ endEventMessage: {
151
+ element: 'endEvent',
152
+ slot: 'flowNode',
153
+ eventDefinition: 'messageEventDefinition',
154
+ },
155
+ endEventTerminate: {
156
+ element: 'endEvent',
157
+ slot: 'flowNode',
158
+ eventDefinition: 'terminateEventDefinition',
159
+ },
160
+ task: { element: 'task', slot: 'flowNode' },
161
+ taskUser: { element: 'userTask', slot: 'flowNode' },
162
+ taskService: { element: 'serviceTask', slot: 'flowNode' },
163
+ subProcess: { element: 'subProcess', slot: 'flowNode' },
164
+ callActivity: { element: 'callActivity', slot: 'flowNode' },
165
+ gatewayExclusive: { element: 'exclusiveGateway', slot: 'flowNode' },
166
+ gatewayParallel: { element: 'parallelGateway', slot: 'flowNode' },
167
+ dataObject: { element: 'dataObjectReference', slot: 'data' },
168
+ dataStore: { element: 'dataStoreReference', slot: 'data' },
169
+ textAnnotation: { element: 'textAnnotation', slot: 'artifact' },
170
+ group: { element: 'group', slot: 'artifact' },
171
+ };
172
+ /** The three edge roles this exporter writes, and what each becomes. */
173
+ const EDGE_ELEMENT = {
174
+ [BPMN_ROLE.sequenceFlow]: 'sequenceFlow',
175
+ [BPMN_ROLE.messageFlow]: 'messageFlow',
176
+ [BPMN_ROLE.association]: 'association',
177
+ };
178
+ const el = (name, attrs = {}, children = []) => ({ name, attrs, children });
179
+ const textEl = (name, text, attrs = {}) => ({
180
+ name,
181
+ attrs,
182
+ children: [],
183
+ text,
184
+ });
185
+ /**
186
+ * Character DATA — the three characters that would otherwise start markup.
187
+ *
188
+ * A newline, a tab and a carriage return are left exactly as they are, which is
189
+ * what makes `<bpmn:text>` carry a multi-line annotation faithfully: inside an
190
+ * element, whitespace is content.
191
+ */
192
+ export function escapeText(value) {
193
+ return value
194
+ .replaceAll('&', '&amp;')
195
+ .replaceAll('<', '&lt;')
196
+ .replaceAll('>', '&gt;');
197
+ }
198
+ /**
199
+ * An attribute VALUE, which needs strictly more than character data does.
200
+ *
201
+ * The quotes are the obvious half. The other half is the one that loses data
202
+ * silently: XML 1.0 §3.3.3 makes every conformant parser replace a literal
203
+ * `#xA`, `#xD` or `#x9` in an attribute value with a SPACE before anyone sees
204
+ * it — attribute-value normalization, and it is not optional. Only a character
205
+ * reference survives it.
206
+ *
207
+ * That matters here because a multi-line label is ordinary on this canvas (it
208
+ * is how a task fits in its box) and `name` is where nearly all of them go:
209
+ * every flow node, the participant, the lane, the flows, and
210
+ * `categoryValue/@value`. Written raw, a two-line task name comes back as one
211
+ * line, with no warning and no way for the author to tell. Written as `&#10;`
212
+ * it comes back as it went in.
213
+ */
214
+ export function escapeAttr(value) {
215
+ return escapeText(value)
216
+ .replaceAll('"', '&quot;')
217
+ .replaceAll("'", '&apos;')
218
+ .replaceAll('\n', '&#10;')
219
+ .replaceAll('\r', '&#13;')
220
+ .replaceAll('\t', '&#9;');
221
+ }
222
+ /**
223
+ * A coordinate, as few characters as it can honestly be.
224
+ *
225
+ * Two decimals: DI coordinates are `xsd:double`, a canvas produces fractions no
226
+ * eye can see, and `123.45000000000002` in a file a human reads is noise. The
227
+ * rounding is applied AFTER the plane translation, so it never accumulates.
228
+ */
229
+ function num(value) {
230
+ if (!Number.isFinite(value))
231
+ return '0';
232
+ const rounded = Math.round(value * 100) / 100;
233
+ return String(Object.is(rounded, -0) ? 0 : rounded);
234
+ }
235
+ function serializeElement(node, indent) {
236
+ // Verbatim, and only indented: whitespace INSIDE the fragment is content and
237
+ // is never reflowed. See {@link XmlFragment}.
238
+ if ('fragment' in node)
239
+ return `${indent}${node.fragment}`;
240
+ const attrs = Object.entries(node.attrs)
241
+ .filter(([, value]) => value !== undefined)
242
+ .map(([key, value]) => ` ${key}="${escapeAttr(String(value))}"`)
243
+ .join('');
244
+ if (node.text !== undefined) {
245
+ return `${indent}<${node.name}${attrs}>${escapeText(node.text)}</${node.name}>`;
246
+ }
247
+ if (node.children.length === 0) {
248
+ return `${indent}<${node.name}${attrs} />`;
249
+ }
250
+ const inner = node.children
251
+ .map(child => serializeElement(child, `${indent} `))
252
+ .join('\n');
253
+ return `${indent}<${node.name}${attrs}>\n${inner}\n${indent}</${node.name}>`;
254
+ }
255
+ /* ── Ids ──────────────────────────────────────────────────────────────── */
256
+ /**
257
+ * Which characters an XML `Name` admits — a conservative but UNICODE-AWARE
258
+ * reading of NameStartChar / NameChar.
259
+ *
260
+ * Letters rather than `[A-Za-z]`, because NCName has always allowed them and an
261
+ * architect writing in French or Portuguese should not have `tâche-1` folded to
262
+ * `t_che-1`: two accented ids one letter apart would then differ only by the
263
+ * minter's `_2` suffix, in the properties panel where a human reads them. The
264
+ * production's exotic tail (combining marks, extenders, `·`) is deliberately
265
+ * not enumerated — nothing on this canvas mints one, and a character wrongly
266
+ * replaced by `_` is safe where a character wrongly kept is not.
267
+ */
268
+ const NAME_START = /[\p{L}_]/u;
269
+ const NAME_CHAR = /[\p{L}\p{N}_.\-]/u;
270
+ /**
271
+ * A surface id, as an XML NCName.
272
+ *
273
+ * `id` is `xsd:ID` throughout BPMN, which means NCName and means
274
+ * DOCUMENT-unique — a `BPMNShape` may not carry the id of the `task` it
275
+ * describes. Surface ids are nanoid-shaped: they routinely open on a digit and
276
+ * may carry a `-`, both of which a validating parser refuses on an `xsd:ID`.
277
+ *
278
+ * So: every disallowed character becomes `_`, and an id that does not open on a
279
+ * letter or `_` is prefixed with one. The transformation is lossy on purpose —
280
+ * two distinct surface ids can collapse onto the same NCName — which is what
281
+ * {@link IdMinter} is for.
282
+ */
283
+ export function toNcName(raw) {
284
+ let out = '';
285
+ for (const char of raw) {
286
+ out += NAME_CHAR.test(char) ? char : '_';
287
+ }
288
+ if (out.length === 0 || !NAME_START.test(out[0]))
289
+ out = `_${out}`;
290
+ return out;
291
+ }
292
+ /** Whether a string is already an NCName, and can therefore be given back. */
293
+ export function isNcName(value) {
294
+ if (value.length === 0 || !NAME_START.test(value[0]))
295
+ return false;
296
+ for (const char of value) {
297
+ if (!NAME_CHAR.test(char))
298
+ return false;
299
+ }
300
+ return true;
301
+ }
302
+ /**
303
+ * The id a `.bpmn` import recorded for this element, verbatim (ADR 0012, D3).
304
+ *
305
+ * The read half of the round trip, and the whole of what this module knows
306
+ * about importing: `interchange.bpmn.id` is what the file called this thing,
307
+ * and giving it back is what makes the id map a FIXED POINT after one cycle —
308
+ * the first export renames (surface id → NCName), every export after an import
309
+ * gives back what it was given. The export writes nothing here, ever.
310
+ *
311
+ * Keyed by the FORMAT and not the framework, because a `.bpmn` and an OWM file
312
+ * make different promises about the same element. Read defensively: the value
313
+ * came out of a Y.Map and is whatever a peer wrote.
314
+ */
315
+ function carriedBpmnId(model) {
316
+ const given = model.interchange?.[BPMN_FORMAT_ID]?.id;
317
+ return typeof given === 'string' && given.length > 0 ? given : undefined;
318
+ }
319
+ /** The source element name an import recorded, when the element was carried. */
320
+ function carriedBpmnElement(model) {
321
+ const name = model.interchange?.[BPMN_FORMAT_ID]?.element;
322
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
323
+ }
324
+ /* ── Foreign matter, and where it goes back ───────────────────────────── */
325
+ /**
326
+ * `.bpmn`'s scope vocabulary — where a carried fragment came off, and therefore
327
+ * where this writer has to put it back (ADR 0012, D2 as amended in #157).
328
+ *
329
+ * Declared HERE, in the module both directions already depend on, for the same
330
+ * reason {@link BPMN_FORMAT_ID} is: the reader files a fragment under a scope
331
+ * and the writer looks it up under one, and a table written twice is a table
332
+ * that drifts. `import.ts` re-exports it.
333
+ *
334
+ * One Labre element stands for several source elements: a pool is a
335
+ * `participant` AND its `process`, plus a `laneSet`, every `lane`, the
336
+ * `BPMNShape` that draws it, and — on the first pool of a document — the
337
+ * `collaboration` and `definitions` themselves. Everything they carry lands in
338
+ * ONE payload, so what came off which is recorded, or two lanes with the same
339
+ * foreign attribute leave one value in a persisted field and a report that says
340
+ * two.
341
+ *
342
+ * A scope is either a source element's **id, verbatim** — every carried flow
343
+ * node, every lane, every carried root element — or one of the `@` keys below,
344
+ * for the parts of the document that have no id worth naming or whose identity
345
+ * is their relation to this element. `@` is not an XML NameStartChar, so no id
346
+ * in a conformant file can ever collide with one.
347
+ *
348
+ * The rule for a fragment is always the same: **the scope is the element it was
349
+ * a child of**. For an attribute it is the element that carried the attribute;
350
+ * for a `di` fragment, what that fragment draws.
351
+ */
352
+ export const BPMN_SCOPE = {
353
+ /** The element this payload rides on: the participant, the flow node, the flow. */
354
+ self: '@self',
355
+ /** Its `BPMNShape` or `BPMNEdge`. */
356
+ shape: '@shape',
357
+ /** The `process` behind a participant — the pool's other half. */
358
+ process: '@process',
359
+ /** The pool's `laneSet`. */
360
+ laneSet: '@laneSet',
361
+ /** The `collaboration`, whose residue rides on the first pool (D6). */
362
+ collaboration: '@collaboration',
363
+ /** `definitions` itself: its foreign attributes, its declarations, its roots. */
364
+ definitions: '@definitions',
365
+ };
366
+ /**
367
+ * What a `.bpmn` import left on this element, read defensively.
368
+ *
369
+ * The value came out of a Y.Map and is whatever a peer wrote — an older build,
370
+ * a hand-edited document, a paste from a board that met a different importer —
371
+ * so every member below is checked before it is believed. A payload that is not
372
+ * an object at all is simply not there.
373
+ */
374
+ function carriedOf(model) {
375
+ const payload = model.interchange?.[BPMN_FORMAT_ID];
376
+ return payload !== null && typeof payload === 'object' ? payload : undefined;
377
+ }
378
+ /**
379
+ * Whether a carried attribute NAME can be written back as one.
380
+ *
381
+ * The serializer escapes attribute VALUES and interpolates NAMES, which is the
382
+ * only asymmetry in this file that matters for the shape of the document: a
383
+ * value can say anything and stay inside its quotes, and a name cannot. A
384
+ * "name" of `x="1"><task id="INJECTED" /><y z` closes the element it was on and
385
+ * opens two more, so the damage is not confined to the element carrying the bad
386
+ * payload — it unbalances the whole file.
387
+ *
388
+ * `interchange` is ordinary collaborative Y.Map data: any peer with write
389
+ * access, any hand-edited document, any paste from a board that met a different
390
+ * importer. So a name is written only if it IS a name — an NCName, or the
391
+ * `prefix:local` pair of them that every foreign attribute in a `.bpmn` wears.
392
+ * {@link isNcName} rejects `:` itself, so "one colon" needs no separate check.
393
+ *
394
+ * This also disposes of a degenerate payload shape for free: `attrs: []` puts
395
+ * `Object.entries` on an array, whose keys are `"0"`, `"1"` — not NCNames,
396
+ * because an XML name cannot open on a digit.
397
+ */
398
+ function isAttrName(name) {
399
+ const colon = name.indexOf(':');
400
+ if (colon < 0)
401
+ return isNcName(name);
402
+ return isNcName(name.slice(0, colon)) && isNcName(name.slice(colon + 1));
403
+ }
404
+ /**
405
+ * The `xsd:ID` a carried fragment's ROOT element claims, if it claims one.
406
+ *
407
+ * Read off the opening tag by hand rather than by parsing, because this module
408
+ * has no parser and is not going to grow one (it is a pure function of the
409
+ * board — ADR 0012 P3). That is enough for what it is for: an id is what makes
410
+ * two carried fragments the same fragment, and the root's is the one a second
411
+ * copy would duplicate.
412
+ *
413
+ * The whole scan is quote-aware — `id=` is only recognised OUTSIDE a quoted
414
+ * value, so an attribute whose value contains ` id='X'` (a condition string,
415
+ * an XPath) cannot shadow the element's real id, and a value containing `>`
416
+ * does not truncate the tag. `id` is required to be preceded by whitespace,
417
+ * so `camunda:id` and `bpmnElement` are not mistaken for it.
418
+ */
419
+ function carriedRootId(fragment) {
420
+ let quote;
421
+ let capturing = false;
422
+ let valueStart = 0;
423
+ for (let index = 0; index < fragment.length; index++) {
424
+ const char = fragment[index];
425
+ if (quote !== undefined) {
426
+ if (char !== quote)
427
+ continue;
428
+ if (capturing) {
429
+ const value = fragment.slice(valueStart, index);
430
+ return value.length > 0 ? value : undefined;
431
+ }
432
+ quote = undefined;
433
+ continue;
434
+ }
435
+ if (char === '"' || char === "'") {
436
+ quote = char;
437
+ continue;
438
+ }
439
+ if (char === '>')
440
+ break;
441
+ if (char === 'i' && /\s/.test(fragment[index - 1] ?? '')) {
442
+ const match = /^id\s*=\s*("|')/.exec(fragment.slice(index));
443
+ if (match) {
444
+ quote = match[1];
445
+ capturing = true;
446
+ valueStart = index + match[0].length;
447
+ index += match[0].length - 1;
448
+ }
449
+ }
450
+ }
451
+ return undefined;
452
+ }
453
+ /**
454
+ * The carried half of one export: what has already been written back, and what
455
+ * this writer refused to write.
456
+ *
457
+ * Stateful, and created per call, because both of the invariants it holds are
458
+ * DOCUMENT-wide and neither is checkable from one element:
459
+ *
460
+ * - **an `xsd:ID` is unique across the file.** `interchange` is declared on the
461
+ * base element model precisely so a payload survives a paste (PR #73), so a
462
+ * pool imported from a `.bpmn` and then copy-pasted holds its carried
463
+ * boundary event, its lane's `documentation` and the document's residue
464
+ * TWICE. Written twice, they are duplicate ids, and a duplicate `xsd:ID` is
465
+ * the one thing no BPMN tool survives. First claim wins — it is the one
466
+ * already written and already referenced — and a SECOND, DIFFERENT fragment
467
+ * claiming the same id is a conflict the export reports rather than resolves;
468
+ * - **an attribute name is a name.** See {@link isAttrName}.
469
+ *
470
+ * The order in which the caller asks decides who wins, so the caller is the
471
+ * document's own emission order, which is deterministic in the board.
472
+ */
473
+ class Carried {
474
+ constructor() {
475
+ /** The fragment already written for each id a carried root claimed. */
476
+ this.#byId = new Map();
477
+ /** Id-less DOCUMENT-scope fragments, of which two pools carry ONE. */
478
+ this.#shared = new Set();
479
+ /** Ids claimed a second time by a DIFFERENT fragment. */
480
+ this.conflictingIds = [];
481
+ /** Attribute names refused because they are not names. */
482
+ this.refusedNames = [];
483
+ }
484
+ /** The fragment already written for each id a carried root claimed. */
485
+ #byId;
486
+ /** Id-less DOCUMENT-scope fragments, of which two pools carry ONE. */
487
+ #shared;
488
+ /**
489
+ * The carried attributes of one or more scopes, merged left to right.
490
+ *
491
+ * Merged rather than concatenated because an attribute is a NAME on an
492
+ * element and there is only one of each: a later scope wins, which is what
493
+ * lets the caller order the scopes by how specific they are.
494
+ *
495
+ * NEITHER half of a carried attribute is trusted as markup. The value is
496
+ * re-escaped by the serializer like any other; the name is checked against
497
+ * {@link isAttrName}, because the serializer interpolates it and a name is
498
+ * the half that can escape its own element.
499
+ */
500
+ attrs(payload, ...scopes) {
501
+ const out = {};
502
+ for (const scope of scopes) {
503
+ const bag = payload?.attrs?.[scope];
504
+ if (bag === null || typeof bag !== 'object')
505
+ continue;
506
+ for (const [name, value] of Object.entries(bag)) {
507
+ if (typeof value !== 'string')
508
+ continue;
509
+ // `__proto__` is a valid NCName and still not a key this writer will
510
+ // carry into an object it builds — the prototype-pollution exclusion
511
+ // PR #73 draws round its own verbatim write, drawn here too.
512
+ if (name === '__proto__')
513
+ continue;
514
+ if (!isAttrName(name)) {
515
+ this.refusedNames.push(name);
516
+ continue;
517
+ }
518
+ out[name] = value;
519
+ }
520
+ }
521
+ return out;
522
+ }
523
+ /**
524
+ * The fragments of this bag that have not been written back already.
525
+ *
526
+ * @param shared for matter that belongs to the DOCUMENT rather than to the
527
+ * element carrying it — D6's `definitions` and `collaboration` residue, and
528
+ * the plane's carried diagram elements. Two pools holding the same payload
529
+ * hold ONE of those, so an id-less one is deduplicated on its text as well.
530
+ * An id-less fragment at ELEMENT scope is never deduplicated: two tasks may
531
+ * each carry their own `<documentation>`, and two pools each their own
532
+ * `<BPMNLabel />`, and those are two fragments rather than one written twice.
533
+ */
534
+ keep(fragments, shared = false) {
535
+ const out = [];
536
+ for (const node of fragments) {
537
+ if (!('fragment' in node)) {
538
+ out.push(node);
539
+ continue;
540
+ }
541
+ const id = carriedRootId(node.fragment);
542
+ if (id === undefined) {
543
+ if (shared) {
544
+ if (this.#shared.has(node.fragment))
545
+ continue;
546
+ this.#shared.add(node.fragment);
547
+ }
548
+ out.push(node);
549
+ continue;
550
+ }
551
+ const written = this.#byId.get(id);
552
+ if (written !== undefined) {
553
+ // The same characters twice is one thing carried twice — a paste — and
554
+ // writing it once is the whole job. DIFFERENT characters under one id
555
+ // are two things that cannot both be in a file, and that is a sentence
556
+ // the person exporting is entitled to hear.
557
+ if (written !== node.fragment)
558
+ this.conflictingIds.push(id);
559
+ continue;
560
+ }
561
+ this.#byId.set(id, node.fragment);
562
+ out.push(node);
563
+ }
564
+ return out;
565
+ }
566
+ }
567
+ /** Whether a stored fragment is a string this writer can put back. */
568
+ function isFragment(value) {
569
+ return typeof value === 'string' && value.trimStart().startsWith('<');
570
+ }
571
+ /** The carried child fragments of one or more scopes, in the stored order. */
572
+ function carriedFragments(bag, ...scopes) {
573
+ const out = [];
574
+ for (const scope of scopes) {
575
+ const list = bag?.[scope];
576
+ if (!Array.isArray(list))
577
+ continue;
578
+ for (const fragment of list) {
579
+ if (isFragment(fragment))
580
+ out.push({ fragment });
581
+ }
582
+ }
583
+ return out;
584
+ }
585
+ /**
586
+ * Whole diagram elements the file drew for something this board does not: the
587
+ * `BPMNShape` of a carried boundary event, the `BPMNEdge` of its error path, an
588
+ * orphan shape naming an element the file never declared.
589
+ *
590
+ * They are the `di` entries whose scope is an ID rather than a role key —
591
+ * `@shape` means "unmodelled parts of MY OWN diagram element" and is written by
592
+ * the element itself. Keys are sorted so that two documents holding the same
593
+ * payload write the same plane, whatever order a Y.Map happened to hand them
594
+ * back in.
595
+ */
596
+ function carriedPlaneDi(payload) {
597
+ const bag = payload?.di;
598
+ if (bag === null || typeof bag !== 'object')
599
+ return [];
600
+ const ids = Object.keys(bag)
601
+ .filter(scope => !scope.startsWith('@'))
602
+ .sort();
603
+ return carriedFragments(bag, ...ids);
604
+ }
605
+ /* ── Where a carried fragment is XSD-legal ────────────────────────────── */
606
+ /**
607
+ * The qualified name a fragment opens on, reduced to its local part.
608
+ *
609
+ * A fragment carries the FILE's prefix (`bpmn2:`, `semantic:`, none at all) and
610
+ * this writer cannot resolve it — the declaration it was written under is on
611
+ * `definitions`, not on the fragment. The local name is what the XSD's own
612
+ * sequences are written in, so it is what the slot tables below read, and a
613
+ * name none of them knows falls to the open slot rather than to a guess.
614
+ */
615
+ function localNameOf(fragment) {
616
+ const opened = /^\s*<\s*([^\s/>]+)/.exec(fragment);
617
+ if (!opened)
618
+ return '';
619
+ const qualified = opened[1];
620
+ const colon = qualified.indexOf(':');
621
+ return colon < 0 ? qualified : qualified.slice(colon + 1);
622
+ }
623
+ /**
624
+ * `tProcess`, before its `flowElement*` slot.
625
+ *
626
+ * The XSD sequence in full is `documentation* → extensionElements? →
627
+ * supportedInterfaceRef* → ioSpecification? → ioBinding* → auditing? →
628
+ * monitoring? → processRole* → property* → laneSet* → flowElement* →
629
+ * artifact* → resourceRole* → correlationSubscription* → supports*` — the
630
+ * first five inherited from `tBaseElement` and `tCallableElement`, which in an
631
+ * `xsd:extension` come FIRST. A carried `<auditing>` written after the laneSet
632
+ * this exporter emits is a document a validating parser rejects, so the head is
633
+ * a table and not a comment.
634
+ */
635
+ const PROCESS_HEAD = new Set([
636
+ 'documentation',
637
+ 'extensionElements',
638
+ 'supportedInterfaceRef',
639
+ 'ioSpecification',
640
+ 'ioBinding',
641
+ 'auditing',
642
+ 'monitoring',
643
+ 'processRole',
644
+ 'property',
645
+ 'laneSet',
646
+ ]);
647
+ /** `tProcess` and `tCollaboration` both end their drawable half on this slot. */
648
+ const ARTIFACT_LOCALS = new Set(['association', 'group', 'textAnnotation']);
649
+ /**
650
+ * `tProcess`, after its `artifact*` slot.
651
+ *
652
+ * `resourceRole` is the abstract head of a substitution group, so the three
653
+ * names a file actually writes are here beside it (Table 10.142).
654
+ *
655
+ * Both spellings of `correlationSub(s)cription` are here because the NORMATIVE
656
+ * schema misspells it: `tProcess` really does declare
657
+ * `correlationSubcription`, without the second `s` (Table 10.136, ISO p. 311).
658
+ * A file written against the published XSD carries the typo and a file written
659
+ * against the prose carries the correction, and both belong in this slot.
660
+ */
661
+ const PROCESS_TAIL = new Set([
662
+ 'resourceRole',
663
+ 'performer',
664
+ 'humanPerformer',
665
+ 'potentialOwner',
666
+ 'correlationSubscription',
667
+ 'correlationSubcription',
668
+ 'supports',
669
+ ]);
670
+ /** `tCollaboration`, before its `participant*` slot. */
671
+ const COLLABORATION_HEAD = new Set([
672
+ 'documentation',
673
+ 'extensionElements',
674
+ 'choreography',
675
+ ]);
676
+ function processSlotOf(fragment) {
677
+ const local = localNameOf(fragment);
678
+ if (PROCESS_HEAD.has(local))
679
+ return 'head';
680
+ if (ARTIFACT_LOCALS.has(local))
681
+ return 'artifact';
682
+ if (PROCESS_TAIL.has(local))
683
+ return 'tail';
684
+ return 'flowElement';
685
+ }
686
+ function collaborationSlotOf(fragment) {
687
+ const local = localNameOf(fragment);
688
+ if (COLLABORATION_HEAD.has(local))
689
+ return 'head';
690
+ if (local === 'messageFlow')
691
+ return 'messageFlow';
692
+ if (ARTIFACT_LOCALS.has(local))
693
+ return 'artifact';
694
+ return 'tail';
695
+ }
696
+ function definitionsSlotOf(fragment) {
697
+ const local = localNameOf(fragment);
698
+ if (local === 'extension')
699
+ return 'extension';
700
+ if (local === 'relationship')
701
+ return 'relationship';
702
+ return 'root';
703
+ }
704
+ /** Fragments sorted into their slots, keeping the stored order inside each. */
705
+ function bySlot(fragments, slotOf) {
706
+ const out = {};
707
+ for (const node of fragments) {
708
+ if (!('fragment' in node))
709
+ continue;
710
+ const slot = slotOf(node.fragment);
711
+ (out[slot] ??= []).push(node);
712
+ }
713
+ return out;
714
+ }
715
+ /** One slot of a sorted bag, which is empty far more often than not. */
716
+ function slot(bag, name) {
717
+ return bag[name] ?? [];
718
+ }
719
+ /**
720
+ * Mints document-unique NCNames, and remembers what it minted.
721
+ *
722
+ * Uniqueness is settled by a counting suffix rather than by a hash: `Task_x`
723
+ * and `Task_x_2` are both readable in bpmn.io's properties panel, which is
724
+ * where a human will actually meet them during the recette.
725
+ */
726
+ class IdMinter {
727
+ constructor() {
728
+ this.#taken = new Set();
729
+ /** How many ids an import gave us that could not be given back (D3). */
730
+ this.substituted = 0;
731
+ }
732
+ #taken;
733
+ /**
734
+ * The id the FILE gave this element, when it can still be given back —
735
+ * otherwise a freshly minted one, and the substitution is counted.
736
+ *
737
+ * Two things can make a recorded id unusable, and neither is recoverable by
738
+ * guessing: it may not be an NCName (a hand-edited file, another format's
739
+ * id), or the document being written may already have claimed it. The
740
+ * alternative — inverting {@link toNcName} to reconstruct what we think we
741
+ * sent — is exactly what D3 rejects: `_7abc` has two preimages.
742
+ */
743
+ given(given, prefix, raw) {
744
+ if (given !== undefined && isNcName(given) && !this.#taken.has(given)) {
745
+ this.#taken.add(given);
746
+ return given;
747
+ }
748
+ if (given !== undefined)
749
+ this.substituted++;
750
+ return this.mint(prefix, raw);
751
+ }
752
+ mint(prefix, raw) {
753
+ const base = toNcName(prefix ? `${prefix}_${raw}` : raw);
754
+ if (!this.#taken.has(base)) {
755
+ this.#taken.add(base);
756
+ return base;
757
+ }
758
+ let n = 2;
759
+ while (this.#taken.has(`${base}_${n}`))
760
+ n++;
761
+ const unique = `${base}_${n}`;
762
+ this.#taken.add(unique);
763
+ return unique;
764
+ }
765
+ }
766
+ /* ── The plan ─────────────────────────────────────────────────────────── */
767
+ /**
768
+ * Which element an artefact is written INSIDE.
769
+ *
770
+ * A number indexes `processes`; {@link COLLABORATION} is the collaboration
771
+ * itself, which `tCollaboration` allows to carry artifacts directly
772
+ * (`participant* → messageFlow* → artifact*`).
773
+ *
774
+ * The distinction is not cosmetic and the live recette is what found it. A
775
+ * process with no participant cannot be DRAWN on a collaboration plane — there
776
+ * is no shape for it — so bpmn-js imports its contents and then renders
777
+ * nothing at all. An annotation dropped beside the pools used to disappear on
778
+ * import; as a child of the collaboration it is drawn where it was put.
779
+ */
780
+ const COLLABORATION = -1;
781
+ /**
782
+ * Each lane of a pool as an ABSOLUTE rectangle, in the order they are painted.
783
+ *
784
+ * Read off `backgroundInstanceZones` rather than off `pool.lanes` directly, for
785
+ * the reason {@link bpmnLaneOf} gives and one more:
786
+ *
787
+ * - the primitive is what NORMALISES the weights into rectangles and DROPS the
788
+ * rows a user's typo made unusable, so a second reading of the raw prop would
789
+ * place a `BPMNShape` on a band the pool does not paint;
790
+ * - and it makes the `laneSet` and the DI agree by construction — the lanes
791
+ * this returns are the lanes written to both, so a dropped row is absent from
792
+ * the file rather than present as a lane with no shape and no members.
793
+ *
794
+ * ## Where the band starts, and the 30-unit convention
795
+ *
796
+ * The x origin is the PLOT's, which is the pool's frame plus its participant
797
+ * name band (`POOL_BAND_WIDTH`, 28) — literally where the lane is drawn on the
798
+ * canvas. bpmn-js lays its own lanes out 30 units right of the participant, so
799
+ * the two conventions agree to within two units and a file written here reopens
800
+ * looking like a file bpmn.io wrote. Deriving it from the declaration rather
801
+ * than hard-coding the foreign 30 is deliberate: the DI must describe the
802
+ * picture the author is looking at, and if the band width ever changes the
803
+ * export follows it without anybody remembering to.
804
+ */
805
+ function poolLaneBands(pool) {
806
+ const rows = Array.isArray(pool.lanes) ? pool.lanes : [];
807
+ if (rows.length === 0)
808
+ return [];
809
+ const frame = pool.elementBound;
810
+ const plot = backgroundPlot(BPMN_POOL_BACKGROUND, frame.w, frame.h);
811
+ if (!(plot.width > 0) || !(plot.height > 0))
812
+ return [];
813
+ const zones = backgroundInstanceZones(BPMN_POOL_BACKGROUND, pool);
814
+ const bands = [];
815
+ for (const zone of zones) {
816
+ const row = rows.find(lane => zone.id === `${LANE_PREFIX}:${lane.id}`);
817
+ if (!row)
818
+ continue;
819
+ bands.push({
820
+ lane: row,
821
+ // Minted by the caller, which owns the document-wide id space.
822
+ id: '',
823
+ bound: {
824
+ x: frame.x + plot.x0 + zone.rect.x * plot.width,
825
+ y: frame.y + plot.y0 + zone.rect.y * plot.height,
826
+ w: zone.rect.w * plot.width,
827
+ h: zone.rect.h * plot.height,
828
+ },
829
+ });
830
+ }
831
+ return bands;
832
+ }
833
+ /** The text an element carries, as a plain trimmed string. */
834
+ function labelOf(value) {
835
+ if (value === null || value === undefined)
836
+ return '';
837
+ return String(value).trim();
838
+ }
839
+ /**
840
+ * Both ends of a connector, when both are attached to something.
841
+ *
842
+ * A dangling endpoint is the one case the spec cannot express at all:
843
+ * `sourceRef` and `targetRef` are REQUIRED on every flow, so an arrow with a
844
+ * free end has nothing to be written as. It is dropped, silently and on
845
+ * purpose — the picture keeps it, the interchange file cannot carry it, and
846
+ * inventing an anchor would be the export asserting a link the author never
847
+ * drew.
848
+ */
849
+ function endsOf(connector) {
850
+ const source = connector.source?.id;
851
+ const target = connector.target?.id;
852
+ if (!source || !target)
853
+ return null;
854
+ return { source, target };
855
+ }
856
+ /**
857
+ * Where an edge is drawn, in surface coordinates.
858
+ *
859
+ * `absolutePath` is `@local()` — it is computed by the connector manager while
860
+ * the board is on screen — so a document that has been loaded but never
861
+ * rendered has none. When it is there it is the truth (it is the polyline the
862
+ * user is looking at, elbows and all); when it is not, the straight line
863
+ * between the two centres is the honest fallback, and it is what every tool
864
+ * draws for a flow it has no routing for. Two waypoints is also the minimum
865
+ * `di:Edge` accepts.
866
+ */
867
+ function waypointsOf(connector, source, target) {
868
+ const path = connector.absolutePath;
869
+ if (Array.isArray(path) && path.length >= 2) {
870
+ return path.map(point => [point[0], point[1]]);
871
+ }
872
+ return [
873
+ [source.bound.x + source.bound.w / 2, source.bound.y + source.bound.h / 2],
874
+ [target.bound.x + target.bound.w / 2, target.bound.y + target.bound.h / 2],
875
+ ];
876
+ }
877
+ /**
878
+ * Which element a flow is written INSIDE.
879
+ *
880
+ * Three rules, one per kind of edge, and the middle one is the whole of what
881
+ * the live recette taught:
882
+ *
883
+ * - a **message flow** is the collaboration's by definition (`tCollaboration`),
884
+ * and there is no message flow without one — the caller drops it first;
885
+ * - an **association** goes wherever BOTH its ends are, when they agree. When
886
+ * they do not — an annotation beside the pools tied to a task inside one —
887
+ * it belongs to neither scope, and the collaboration is the common ancestor
888
+ * that can legally hold it. Filing it with its source instead would put it in
889
+ * a process that cannot draw it, or in a pool the other end is not in;
890
+ * - a **sequence flow** is a `flowElement` and can only ever be a process's, so
891
+ * it is filed with its SOURCE. A flow that crosses two pools is invalid BPMN
892
+ * and a picture the author nevertheless drew: the file says so, and the
893
+ * validation rules are what tell them about it. Should its source somehow be
894
+ * an artifact on the collaboration — an arrow drawn out of an annotation —
895
+ * it falls back to the participant-less process, because there is nowhere
896
+ * else in the format for it.
897
+ */
898
+ function edgeScope(element, source, target, ctx) {
899
+ if (element === 'messageFlow')
900
+ return COLLABORATION;
901
+ if (element === 'association') {
902
+ if (source.scope === target.scope)
903
+ return source.scope;
904
+ return ctx.hasCollaboration ? COLLABORATION : source.scope;
905
+ }
906
+ return source.scope === COLLABORATION
907
+ ? ctx.orphanProcessIndex()
908
+ : source.scope;
909
+ }
910
+ /* ── The serializer ───────────────────────────────────────────────────── */
911
+ /**
912
+ * Serialize a board as a BPMN 2.0 XML interchange document.
913
+ *
914
+ * ## The shape of the document, and what decides it
915
+ *
916
+ * One `definitions`, always. Then:
917
+ *
918
+ * - **at least one pool** — a `collaboration` holding one `participant` per
919
+ * pool, one `process` per pool, and the message flows (which are the
920
+ * collaboration's, never a process's). Things drawn OUTSIDE every pool split
921
+ * in two: ARTIFACTS (annotation, group, and the associations that tie them to
922
+ * anything) become children of the collaboration itself, where
923
+ * `tCollaboration` allows them and where bpmn-js draws them; FLOW OBJECTS get
924
+ * ONE extra participant-less process, and only if there are any. See
925
+ * {@link COLLABORATION} for what the live recette found out about the
926
+ * difference, and the note on the orphan process for what it still cannot fix;
927
+ * - **no pool at all** — a single `process` and no collaboration, which is what
928
+ * a process drawn without swimlanes IS. The `BPMNPlane` then points at that
929
+ * process; with a collaboration it must point at the collaboration, or most
930
+ * tools draw the flow and none of the pools (spec §12.3.2).
931
+ *
932
+ * Attribution is {@link bpmnPoolOf} and {@link bpmnLaneOf} — the CENTRE against
933
+ * the pool's PLOT, containment only, no nearest-pool fallback. Deliberately the
934
+ * same arithmetic the audit and the validation rules read, so a task the audit
935
+ * reports in "Back office" is in the `lane` named "Back office" here.
936
+ */
937
+ export function exportBpmnXml(board, options = {}) {
938
+ return exportBpmnXmlWithWarnings(board, options).text;
939
+ }
940
+ /**
941
+ * The same serialization, with the loss channel attached — see
942
+ * {@link BpmnExportOutcome}.
943
+ *
944
+ * {@link exportBpmnXml} is the thin wrapper over it, kept because a caller that
945
+ * only wants the bytes should not have to reach past a report to get them, and
946
+ * because #149's forty-six tests and the live integration spec pin that
947
+ * signature. The interchange capability calls THIS one.
948
+ */
949
+ export function exportBpmnXmlWithWarnings(board, options = {}) {
950
+ const minter = new IdMinter();
951
+ const pools = board.pools;
952
+ /* ── Processes and participants ──────────────────────────────────── */
953
+ /**
954
+ * Is this board still the poolless one an import minted a pool for?
955
+ *
956
+ * A pool that stands for a bare `process` (`interchange.bpmn.element =
957
+ * 'process'`, ADR 0012 D6) is what tells this writer to give the poolless
958
+ * form back rather than invent a collaboration the author never drew — but
959
+ * ONLY while it is the whole board. Draw a second pool beside it and the
960
+ * author has made a collaboration: from then on it is a participant like any
961
+ * other, because the alternative is a pool they can see and drag that has no
962
+ * shape in the file and is drawn by nothing that opens it.
963
+ */
964
+ const givesBackPoollessForm = pools.length === 1 && carriedBpmnElement(pools[0]) === 'process';
965
+ const processes = pools.map(pool => {
966
+ const wasBareProcess = givesBackPoollessForm && carriedBpmnElement(pool) === 'process';
967
+ const given = carriedBpmnId(pool);
968
+ // The pool's OWN element is the participant — it is what a `BPMNShape`
969
+ // points at and what a message flow can reference — so that is the id the
970
+ // file gets to keep. Everything the pool drags along is minted FROM it, so
971
+ // that an export after an import lands on the same ids as the export
972
+ // before it: the process is derived from the participant either way.
973
+ const participantId = wasBareProcess
974
+ ? undefined
975
+ : minter.given(given, 'Participant', pool.id);
976
+ const id = wasBareProcess
977
+ ? minter.given(given, 'Process', pool.id)
978
+ : minter.mint('Process', participantId);
979
+ // The bands the pool actually PAINTS, not the raw prop — see
980
+ // `poolLaneBands`. A lane's stored id is what the file called it (an import
981
+ // records it verbatim), so it goes in unprefixed: `id` is document-unique
982
+ // across the WHOLE file and the minter is what keeps it so, but a lane that
983
+ // arrived as `Lane_3` must leave as `Lane_3` and not as `Lane_Lane_3`.
984
+ const lanes = poolLaneBands(pool).map(band => ({
985
+ ...band,
986
+ id: minter.mint('', band.lane.id),
987
+ }));
988
+ return {
989
+ pool,
990
+ id,
991
+ participantId,
992
+ name: labelOf(pool.name),
993
+ laneSetId: lanes.length > 0
994
+ ? minter.mint('LaneSet', participantId ?? id)
995
+ : undefined,
996
+ lanes,
997
+ payload: carriedOf(pool),
998
+ selfIsProcess: carriedBpmnElement(pool) === 'process',
999
+ };
1000
+ });
1001
+ const hasCollaboration = processes.some(process => process.participantId !== undefined);
1002
+ /**
1003
+ * The participant-less process.
1004
+ *
1005
+ * Minted on first use where there ARE pools — an empty extra process in a
1006
+ * collaboration is a participant a reader will look for on the canvas and not
1007
+ * find. Minted eagerly where there are none, because then it is not an extra
1008
+ * anything: it is the process, and a `definitions` with no process at all is
1009
+ * a document about nothing (a process with zero flow elements is legal, spec
1010
+ * `tProcess`; a board that is genuinely empty exports as exactly that).
1011
+ *
1012
+ * ## What it cannot fix, stated rather than hidden
1013
+ *
1014
+ * Inside a collaboration this process has no `participant`, so the plane has
1015
+ * no shape to draw it in and bpmn-js imports its flow objects without
1016
+ * rendering them. That is a real limit and it is deliberate: the alternatives
1017
+ * are to invent a pool the author never drew, or to drop the elements
1018
+ * outright, and both of them are the export saying something the board does
1019
+ * not. The elements are in the file, correctly, for any tool that reads the
1020
+ * model; the fix on the canvas is to draw them in a pool.
1021
+ *
1022
+ * Artifacts do NOT come here when there is a collaboration — they have a
1023
+ * legal home on the collaboration itself, and they are drawn.
1024
+ */
1025
+ // …unless one is already there: a pool an import minted for a bare `process`
1026
+ // IS the participant-less process, and minting a second one beside it would
1027
+ // write a `definitions` with two processes where the file had one.
1028
+ let orphanProcess = processes.findIndex(process => process.pool !== null && process.participantId === undefined);
1029
+ const orphanProcessIndex = () => {
1030
+ if (orphanProcess < 0) {
1031
+ orphanProcess = processes.length;
1032
+ processes.push({
1033
+ pool: null,
1034
+ id: minter.mint('Process', hasCollaboration ? 'unassigned' : 'board'),
1035
+ name: '',
1036
+ lanes: [],
1037
+ selfIsProcess: false,
1038
+ });
1039
+ }
1040
+ return orphanProcess;
1041
+ };
1042
+ if (!hasCollaboration)
1043
+ orphanProcessIndex();
1044
+ /* ── Nodes ───────────────────────────────────────────────────────── */
1045
+ const planned = [];
1046
+ const byModelId = new Map();
1047
+ for (const model of board.nodes) {
1048
+ const mapping = BPMN_XML_OF_KIND[model.kind];
1049
+ // A kind the pack does not know: impossible by the type, and a document
1050
+ // written by a newer build could still carry one. Silence beats a crash.
1051
+ if (!mapping)
1052
+ continue;
1053
+ const bound = model.elementBound;
1054
+ const pool = bpmnPoolOf(pools, bound);
1055
+ // In a pool: that pool's process. Outside every pool: an ARTIFACT goes on
1056
+ // the collaboration, where it is both legal and drawable; anything else
1057
+ // goes to the participant-less process, which on a poolless board is
1058
+ // simply THE process.
1059
+ const scope = pool !== null
1060
+ ? processes.findIndex(entry => entry.pool === pool)
1061
+ : hasCollaboration && mapping.slot === 'artifact'
1062
+ ? COLLABORATION
1063
+ : orphanProcessIndex();
1064
+ const name = labelOf(model.text);
1065
+ const node = {
1066
+ model,
1067
+ mapping,
1068
+ id: minter.given(carriedBpmnId(model), '', model.id),
1069
+ name,
1070
+ bound,
1071
+ scope,
1072
+ lane: pool ? bpmnLaneOf(pool, bound) : null,
1073
+ payload: carriedOf(model),
1074
+ };
1075
+ // A data object needs the `dataObject` its reference points at, and a
1076
+ // labelled group needs somewhere for its label to live. Both are minted
1077
+ // from the id this artefact SETTLED on rather than from its surface id, so
1078
+ // that an artefact whose id came out of a file drags the same satellites
1079
+ // whichever export writes it — see {@link IdMinter.given}.
1080
+ if (model.kind === 'dataObject') {
1081
+ node.dataObjectId = minter.mint('DataObject', node.id);
1082
+ }
1083
+ if (model.kind === 'group' && name) {
1084
+ node.categoryId = minter.mint('Category', node.id);
1085
+ node.categoryValueId = minter.mint('CategoryValue', node.id);
1086
+ }
1087
+ planned.push(node);
1088
+ byModelId.set(model.id, node);
1089
+ }
1090
+ /* ── Edges ───────────────────────────────────────────────────────── */
1091
+ const edges = [];
1092
+ /** Typed arrows the format had no way to write down. See below for each. */
1093
+ let unwritableEdges = 0;
1094
+ /** Message flows dropped for want of a collaboration. */
1095
+ let droppedMessageFlows = 0;
1096
+ for (const connector of board.connectors) {
1097
+ const element = EDGE_ELEMENT[String(connector.role ?? '')];
1098
+ // A NEUTRAL connector states nothing (`docs/adr/0010`): not a flow. NOT
1099
+ // counted as a loss — there was nothing to lose, which is the whole point
1100
+ // of the neutral state.
1101
+ if (!element)
1102
+ continue;
1103
+ const ends = endsOf(connector);
1104
+ // A free end. `sourceRef` and `targetRef` are required on every flow, so
1105
+ // there is no such thing as half an arrow in this format.
1106
+ if (!ends) {
1107
+ unwritableEdges++;
1108
+ continue;
1109
+ }
1110
+ const source = byModelId.get(ends.source);
1111
+ const target = byModelId.get(ends.target);
1112
+ // An end attached to something that is not a BPMN artefact — a sticky note,
1113
+ // a plain rectangle — has no id in this document to point at.
1114
+ if (!source || !target) {
1115
+ unwritableEdges++;
1116
+ continue;
1117
+ }
1118
+ // A message flow belongs to the collaboration, and there is no
1119
+ // collaboration without a pool. On a poolless board it has nowhere in the
1120
+ // interchange format to go, so it is dropped rather than demoted to a
1121
+ // sequence flow, which would say something else entirely.
1122
+ if (element === 'messageFlow' && !hasCollaboration) {
1123
+ droppedMessageFlows++;
1124
+ continue;
1125
+ }
1126
+ edges.push({
1127
+ model: connector,
1128
+ element,
1129
+ id: minter.given(carriedBpmnId(connector), 'Flow', connector.id),
1130
+ name: labelOf(connector.text),
1131
+ source,
1132
+ target,
1133
+ scope: edgeScope(element, source, target, {
1134
+ hasCollaboration,
1135
+ orphanProcessIndex,
1136
+ }),
1137
+ waypoints: waypointsOf(connector, source, target),
1138
+ payload: carriedOf(connector),
1139
+ });
1140
+ }
1141
+ /* ── The plane origin ────────────────────────────────────────────── */
1142
+ // Spec §12.3: DI coordinates are relative to the plane's origin, and "the
1143
+ // union of all the nested elements' bounds is deemed to be located at the
1144
+ // plane's origin point" — which a canvas that lets a user drag left of zero
1145
+ // routinely violates. Everything is translated so the top-left of the whole
1146
+ // drawing sits at (0, 0); the shape of the picture is untouched, and a tool
1147
+ // that clamps at zero no longer folds half the process onto its own edge.
1148
+ let minX = Number.POSITIVE_INFINITY;
1149
+ let minY = Number.POSITIVE_INFINITY;
1150
+ const observe = (x, y) => {
1151
+ if (Number.isFinite(x) && x < minX)
1152
+ minX = x;
1153
+ if (Number.isFinite(y) && y < minY)
1154
+ minY = y;
1155
+ };
1156
+ for (const process of processes) {
1157
+ if (!process.pool)
1158
+ continue;
1159
+ const bound = process.pool.elementBound;
1160
+ observe(bound.x, bound.y);
1161
+ }
1162
+ for (const node of planned)
1163
+ observe(node.bound.x, node.bound.y);
1164
+ for (const edge of edges) {
1165
+ for (const [x, y] of edge.waypoints)
1166
+ observe(x, y);
1167
+ }
1168
+ const dx = Number.isFinite(minX) ? -minX : 0;
1169
+ const dy = Number.isFinite(minY) ? -minY : 0;
1170
+ /* ── What the file gave us back ──────────────────────────────────── */
1171
+ /**
1172
+ * The carried half of this export: what has been written back already, and
1173
+ * what was refused. See {@link Carried} for the two invariants it holds.
1174
+ *
1175
+ * One per call, consulted by every re-emission site in the order the document
1176
+ * is built — which is why it is created here and threaded down rather than
1177
+ * applied at each site independently: a pool duplicated by a copy-paste puts
1178
+ * the SAME carried element in two different processes, and no site can see
1179
+ * that on its own.
1180
+ */
1181
+ const carried = new Carried();
1182
+ /**
1183
+ * The document's own residue, gathered from every pool rather than from one.
1184
+ *
1185
+ * D6 has an import file `definitions`- and `collaboration`-scope matter on
1186
+ * the FIRST pool, but "first" is the reader's document order and this writer
1187
+ * is handed the board's — and a pool can be copy-pasted, deleted, or drawn
1188
+ * before the imported one. So every pool is asked, and {@link Carried.keep}
1189
+ * is what keeps a duplicated payload from writing the file's roots twice.
1190
+ */
1191
+ const poolPayloads = processes
1192
+ .filter(process => process.pool !== null)
1193
+ .map(process => process.payload);
1194
+ /**
1195
+ * One document-scope bag of attributes, merged across the pools that carry
1196
+ * it, and the conflicts that merge resolved.
1197
+ *
1198
+ * Computed ONCE per scope rather than on each read, because the merge is
1199
+ * last-wins and a silent last-wins across two pools imported from two
1200
+ * different files is one of them being rebound with nobody told. Last-wins
1201
+ * stays — there is one attribute of each name on `definitions` and something
1202
+ * has to be written — and the disagreement goes in the report.
1203
+ */
1204
+ const documentAttrsOf = (scope) => {
1205
+ const value = {};
1206
+ const disagreed = [];
1207
+ for (const payload of poolPayloads) {
1208
+ for (const [name, carriedValue] of Object.entries(carried.attrs(payload, scope))) {
1209
+ const seen = value[name];
1210
+ if (seen !== undefined && seen !== carriedValue)
1211
+ disagreed.push(name);
1212
+ value[name] = carriedValue;
1213
+ }
1214
+ }
1215
+ return { value, disagreed };
1216
+ };
1217
+ const definitionsAttrs = documentAttrsOf(BPMN_SCOPE.definitions);
1218
+ const collaborationAttrs = documentAttrsOf(BPMN_SCOPE.collaboration);
1219
+ const disagreeingDeclarations = [
1220
+ ...new Set([
1221
+ ...definitionsAttrs.disagreed,
1222
+ ...collaborationAttrs.disagreed,
1223
+ ]),
1224
+ ];
1225
+ /**
1226
+ * A carried declaration that would contradict one this writer makes.
1227
+ *
1228
+ * The reader carries a namespace declaration whenever the (prefix, URI) pair
1229
+ * is not one `export.ts` writes for itself — which correctly keeps
1230
+ * `xmlns:bpmn2`, and which also keeps a file that binds one of THIS
1231
+ * library's four prefixes to something else (`xmlns:dc` as Dublin Core, say).
1232
+ * Writing that back would rebind the prefix every `dc:Bounds` in this
1233
+ * document is written under, so it is refused: the declaration stays in the
1234
+ * document, out of the file, and the person exporting is told — including
1235
+ * what it means for the fragments that were written under it.
1236
+ */
1237
+ const contradictingDeclarations = Object.keys(definitionsAttrs.value).filter(name => name in BPMN_OWN_DECLARATIONS);
1238
+ const documentChildren = (scope) => carried.keep(poolPayloads.flatMap(payload => carriedFragments(payload?.children, scope)), true);
1239
+ /* ── The semantic half ───────────────────────────────────────────── */
1240
+ const roots = [];
1241
+ // Categories first: a `group` points at a `categoryValue`, and a root element
1242
+ // declared after the thing that references it reads badly even though QName
1243
+ // resolution does not care.
1244
+ for (const node of planned) {
1245
+ if (!node.categoryId || !node.categoryValueId)
1246
+ continue;
1247
+ roots.push(el('bpmn:category', { id: node.categoryId }, [
1248
+ el('bpmn:categoryValue', {
1249
+ id: node.categoryValueId,
1250
+ value: node.name,
1251
+ }),
1252
+ ]));
1253
+ }
1254
+ const collaborationId = hasCollaboration
1255
+ ? minter.mint('Collaboration', 'board')
1256
+ : undefined;
1257
+ if (collaborationId) {
1258
+ const children = [];
1259
+ // What an import carried off the collaboration, sorted into the slots
1260
+ // `tCollaboration` allows each of them in: `documentation* →
1261
+ // extensionElements? → choreography* → participant* → messageFlow* →
1262
+ // artifact* → conversationNode* → …`. The scope says which ELEMENT a
1263
+ // fragment came out of; the XSD is what says where in it.
1264
+ const shared = bySlot(documentChildren(BPMN_SCOPE.collaboration), collaborationSlotOf);
1265
+ children.push(...slot(shared, 'head'));
1266
+ // `participant*` strictly before `messageFlow*` (tCollaboration's sequence).
1267
+ for (const process of processes) {
1268
+ if (!process.participantId)
1269
+ continue;
1270
+ children.push(el('bpmn:participant', {
1271
+ id: process.participantId,
1272
+ name: process.name || undefined,
1273
+ processRef: process.id,
1274
+ // The participant's own foreign attributes — unless this pool
1275
+ // stands for a bare `process`, whose `@self` belongs on the process
1276
+ // tag and is applied there instead.
1277
+ ...(process.selfIsProcess
1278
+ ? {}
1279
+ : carried.attrs(process.payload, BPMN_SCOPE.self)),
1280
+ }, process.selfIsProcess
1281
+ ? []
1282
+ : carried.keep(carriedFragments(process.payload?.children, BPMN_SCOPE.self))));
1283
+ }
1284
+ for (const edge of edges) {
1285
+ if (edge.element !== 'messageFlow')
1286
+ continue;
1287
+ children.push(semanticEdge(edge, carried));
1288
+ }
1289
+ children.push(...slot(shared, 'messageFlow'));
1290
+ // `artifact*` last, and only what fell outside every pool: an annotation
1291
+ // drawn ON a pool is that process's, and belongs with the work it is about.
1292
+ children.push(...artifacts(COLLABORATION, planned, edges, carried));
1293
+ children.push(...slot(shared, 'artifact'));
1294
+ children.push(...slot(shared, 'tail'));
1295
+ roots.push(el('bpmn:collaboration', {
1296
+ id: collaborationId,
1297
+ name: options.name || undefined,
1298
+ ...collaborationAttrs.value,
1299
+ }, children));
1300
+ }
1301
+ for (const [index, process] of processes.entries()) {
1302
+ roots.push(el('bpmn:process', {
1303
+ id: process.id,
1304
+ // The lone process of a poolless board is the whole document, so it
1305
+ // takes the board's name; a pooled one is named by its participant.
1306
+ name: collaborationId ? undefined : options.name || undefined,
1307
+ isExecutable: 'false',
1308
+ // The process's own foreign attributes, and — for the pool an import
1309
+ // minted for a bare `process` — its `@self` ones too, because that
1310
+ // pool IS the process. Applied LAST on purpose: `isExecutable` is
1311
+ // carried by the reader precisely when the file said something other
1312
+ // than the `false` written just above, and giving it back is the
1313
+ // model downgrade this half of the round trip repairs.
1314
+ ...(process.selfIsProcess
1315
+ ? carried.attrs(process.payload, BPMN_SCOPE.self, BPMN_SCOPE.process)
1316
+ : carried.attrs(process.payload, BPMN_SCOPE.process)),
1317
+ }, processChildren(index, process, planned, edges, carried)));
1318
+ }
1319
+ /* ── The DI half ─────────────────────────────────────────────────── */
1320
+ const planeElements = [];
1321
+ for (const process of processes) {
1322
+ if (!process.pool)
1323
+ continue;
1324
+ const bound = process.pool.elementBound;
1325
+ // A pool that stands for a bare `process` has no participant, and a
1326
+ // participant is the only thing a plane can draw a pool AS — so it gets no
1327
+ // shape of its own. Its lanes still do: they are `DiagramElement`s like any
1328
+ // other, and a laneSet nothing draws is the gap the recette found.
1329
+ if (process.participantId) {
1330
+ planeElements.push(el('bpmndi:BPMNShape', {
1331
+ id: minter.mint('Shape', process.participantId),
1332
+ bpmnElement: process.participantId,
1333
+ // `isHorizontal` is meaningful on pools and lanes ONLY (§12.3.2), and
1334
+ // a pool here always runs left to right: the plot is cut into
1335
+ // horizontal bands, which is what a horizontal pool means.
1336
+ isHorizontal: 'true',
1337
+ ...carried.attrs(process.payload, BPMN_SCOPE.shape),
1338
+ },
1339
+ // `tBPMNShape` is `Bounds → BPMNLabel?`, so anything carried off this
1340
+ // shape — a label, a vendor's own DI child — goes after the bounds.
1341
+ [
1342
+ el('dc:Bounds', boundsAttrs(bound, dx, dy)),
1343
+ ...carried.keep(carriedFragments(process.payload?.di, BPMN_SCOPE.shape)),
1344
+ ]));
1345
+ }
1346
+ // …and one shape per LANE, immediately after its own pool.
1347
+ //
1348
+ // A `laneSet` with no DI is the gap the live recette found: bpmn.io read
1349
+ // the lanes, listed their members, and drew a pool with no subdivisions at
1350
+ // all, because a lane is a DiagramElement like any other and a tool draws
1351
+ // what the plane describes. `isHorizontal` says which way the band runs and
1352
+ // is meaningful on exactly two things — a pool and a lane (§12.3.2).
1353
+ for (const lane of process.lanes) {
1354
+ planeElements.push(el('bpmndi:BPMNShape', {
1355
+ id: minter.mint('Shape', lane.id),
1356
+ bpmnElement: lane.id,
1357
+ isHorizontal: 'true',
1358
+ }, [el('dc:Bounds', boundsAttrs(lane.bound, dx, dy))]));
1359
+ }
1360
+ }
1361
+ for (const node of planned) {
1362
+ const attrs = {
1363
+ id: minter.mint('Shape', node.id),
1364
+ bpmnElement: node.id,
1365
+ };
1366
+ // The pack draws the COLLAPSED sub-process only — a task-sized box with a
1367
+ // `+` — and `isExpanded="false"` is how DI says exactly that. Without it a
1368
+ // tool is free to draw an expanded container the author never made.
1369
+ if (node.model.kind === 'subProcess' ||
1370
+ node.model.kind === 'callActivity') {
1371
+ attrs.isExpanded = 'false';
1372
+ }
1373
+ // Meaningful on the exclusive gateway alone (§12.3.2): it is what makes the
1374
+ // X appear rather than an empty diamond.
1375
+ if (node.model.kind === 'gatewayExclusive') {
1376
+ attrs.isMarkerVisible = 'true';
1377
+ }
1378
+ Object.assign(attrs, carried.attrs(node.payload, BPMN_SCOPE.shape));
1379
+ planeElements.push(el('bpmndi:BPMNShape', attrs, [
1380
+ el('dc:Bounds', boundsAttrs(node.bound, dx, dy)),
1381
+ ...carried.keep(carriedFragments(node.payload?.di, BPMN_SCOPE.shape)),
1382
+ ]));
1383
+ }
1384
+ for (const edge of edges) {
1385
+ planeElements.push(el('bpmndi:BPMNEdge', {
1386
+ id: minter.mint('Edge', edge.id),
1387
+ bpmnElement: edge.id,
1388
+ ...carried.attrs(edge.payload, BPMN_SCOPE.shape),
1389
+ },
1390
+ // `tBPMNEdge` is `waypoint+ → BPMNLabel?`: the routing first, whatever
1391
+ // was carried off the edge after it.
1392
+ [
1393
+ ...edge.waypoints.map(([x, y]) => el('di:waypoint', { x: num(x + dx), y: num(y + dy) })),
1394
+ ...carried.keep(carriedFragments(edge.payload?.di, BPMN_SCOPE.shape)),
1395
+ ]));
1396
+ }
1397
+ /**
1398
+ * The diagram elements of everything this board does not draw.
1399
+ *
1400
+ * A carried boundary event has a `BPMNShape`, its error path has a
1401
+ * `BPMNEdge`, and an orphan shape naming an element the file never declared
1402
+ * has neither an element nor a home — all three were kept under the ID they
1403
+ * name (D2), and all three go back on the plane, whole, after everything
1404
+ * Labre drew. Last rather than interleaved because a `BPMNPlane` holds an
1405
+ * unordered `DiagramElement*` and because it keeps the shapes this exporter
1406
+ * mints in exactly the order the reader indexes them in: the fixed point
1407
+ * depends on that order and not on this one.
1408
+ *
1409
+ * ## They keep the FILE's coordinates, and the plane may have moved
1410
+ *
1411
+ * The one place where "verbatim" and "in the right place" are not the same
1412
+ * sentence. A carried fragment is given back character for character — that
1413
+ * is what makes the payload a fixed point, and rewriting the numbers inside
1414
+ * one would mean the second import stored something the first never saw — so
1415
+ * a carried shape keeps the `dc:Bounds` the source file wrote, while
1416
+ * everything Labre draws is translated to the plane origin (§12.3). Where the
1417
+ * two differ, the carried shape lands `(dx, dy)` away from where it belongs.
1418
+ * It is in the loss table and the warning below says so out loud; it is not
1419
+ * silent, and nothing is lost — only displaced.
1420
+ */
1421
+ const carriedPlane = carried.keep([
1422
+ ...processes.flatMap(process => carriedPlaneDi(process.payload)),
1423
+ ...planned.flatMap(node => carriedPlaneDi(node.payload)),
1424
+ ...edges.flatMap(edge => carriedPlaneDi(edge.payload)),
1425
+ ], true);
1426
+ planeElements.push(...carriedPlane);
1427
+ const diagram = el('bpmndi:BPMNDiagram', { id: minter.mint('Diagram', 'board'), name: options.name || undefined }, [
1428
+ el('bpmndi:BPMNPlane', {
1429
+ id: minter.mint('Plane', 'board'),
1430
+ // The collaboration when there is one — a plane pointing at a process
1431
+ // draws the flow and none of the pools.
1432
+ bpmnElement: collaborationId ?? processes[0]?.id,
1433
+ }, planeElements),
1434
+ ]);
1435
+ // `tDefinitions` is `import* → extension* → rootElement* → BPMNDiagram* →
1436
+ // relationship*`. An `<import>` never arrives here — D5 quarantines it — so
1437
+ // the carried residue splits three ways round the diagram.
1438
+ const document = bySlot(documentChildren(BPMN_SCOPE.definitions), definitionsSlotOf);
1439
+ const definitions = el('bpmn:definitions', {
1440
+ 'xmlns:bpmn': BPMN_NS.model,
1441
+ 'xmlns:bpmndi': BPMN_NS.bpmndi,
1442
+ 'xmlns:di': BPMN_NS.di,
1443
+ 'xmlns:dc': BPMN_NS.dc,
1444
+ id: minter.mint('Definitions', 'board'),
1445
+ targetNamespace: TARGET_NAMESPACE,
1446
+ exporter: EXPORTER,
1447
+ // The file's OTHER namespace declarations, and its foreign attributes.
1448
+ // A carried `camunda:property` fragment is meaningless without the
1449
+ // `xmlns:camunda` it was written under, and a carried
1450
+ // `bpmn2:boundaryEvent` is just as meaningless without `xmlns:bpmn2` —
1451
+ // same namespace as the `xmlns:bpmn` above, different prefix, and a
1452
+ // fragment stored verbatim needs the prefix it was stored under.
1453
+ ...Object.fromEntries(Object.entries(definitionsAttrs.value).filter(([name]) => !(name in BPMN_OWN_DECLARATIONS))),
1454
+ },
1455
+ // `rootElement*` strictly before `BPMNDiagram*` (tDefinitions' sequence),
1456
+ // and the carried roots before the ones minted here for the same reason
1457
+ // `category` comes first: a root that is referenced reads better declared
1458
+ // before the thing referencing it, and QName resolution does not care.
1459
+ [
1460
+ ...slot(document, 'extension'),
1461
+ ...slot(document, 'root'),
1462
+ ...roots,
1463
+ diagram,
1464
+ ...slot(document, 'relationship'),
1465
+ ]);
1466
+ /* ── What the format refused to carry ────────────────────────────── */
1467
+ const warnings = [];
1468
+ // Flow objects drawn beside the pools. They are in the file and correct for
1469
+ // any tool that reads the MODEL, and a collaboration plane has no shape to
1470
+ // draw a participant-less process in, so bpmn-js imports them and renders
1471
+ // nothing. Found by the live recette; silent to the user until now.
1472
+ //
1473
+ // `orphanProcess >= 0` is load-bearing and not defensive: unminted it is
1474
+ // `-1`, which is exactly {@link COLLABORATION}, and the artifacts filed there
1475
+ // are the ones that ARE drawn.
1476
+ const undrawn = hasCollaboration && orphanProcess >= 0
1477
+ ? planned.filter(node => node.scope === orphanProcess).length
1478
+ : 0;
1479
+ if (undrawn > 0) {
1480
+ warnings.push(`${undrawn} ${undrawn === 1 ? 'artefact is' : 'artefacts are'} drawn ` +
1481
+ `outside every pool. ${undrawn === 1 ? 'It is' : 'They are'} in the ` +
1482
+ `file, but most BPMN tools will not draw ${undrawn === 1 ? 'it' : 'them'}: ` +
1483
+ `only a pool has a shape to hold ${undrawn === 1 ? 'it' : 'them'}. ` +
1484
+ `Draw ${undrawn === 1 ? 'it' : 'them'} inside a pool to make ` +
1485
+ `${undrawn === 1 ? 'it' : 'them'} visible.`);
1486
+ }
1487
+ if (droppedMessageFlows > 0) {
1488
+ warnings.push(`${droppedMessageFlows} message ${droppedMessageFlows === 1 ? 'flow was' : 'flows were'} ` +
1489
+ `left out: a message flow runs between participants, and this board ` +
1490
+ `has no pool. Draw the pools it runs between, or say "is followed by" ` +
1491
+ `instead.`);
1492
+ }
1493
+ // An id a file gave us that we could not give back (ADR 0012 D3). It happens
1494
+ // when two elements carry the same recorded id — a merged document, an
1495
+ // import run twice — or when what was recorded is not a valid NCName. The
1496
+ // file is correct either way; what the author loses is the continuity of one
1497
+ // name between the document they imported and the one they are exporting.
1498
+ if (minter.substituted > 0) {
1499
+ warnings.push(`${minter.substituted} ${minter.substituted === 1 ? 'element' : 'elements'} ` +
1500
+ `imported from a BPMN file could not keep ` +
1501
+ `${minter.substituted === 1 ? 'its' : 'their'} original id: ` +
1502
+ `${minter.substituted === 1 ? 'it was' : 'they were'} already taken in ` +
1503
+ `this file. A new id was written instead; nothing else changed.`);
1504
+ }
1505
+ // A carried diagram element keeps the source file's own coordinates, and the
1506
+ // rest of the drawing has just been translated to the plane origin (§12.3).
1507
+ // The two agree only while the translation is a no-op — which it is for a
1508
+ // board exported straight back out of the import that made it, and is not
1509
+ // once anything has been dragged. Nothing is lost; something is displaced,
1510
+ // and the person who clicked Export is the one entitled to hear about it.
1511
+ if (carriedPlane.length > 0 && (dx !== 0 || dy !== 0)) {
1512
+ const count = carriedPlane.length;
1513
+ warnings.push(`${count} ${count === 1 ? 'shape' : 'shapes'} imported from a BPMN file ` +
1514
+ `${count === 1 ? 'is' : 'are'} kept exactly as the file drew ` +
1515
+ `${count === 1 ? 'it' : 'them'}, and the rest of this drawing has ` +
1516
+ `moved since. ${count === 1 ? 'It' : 'They'} will open beside the ` +
1517
+ `process rather than inside it. Nothing was lost: ` +
1518
+ `${count === 1 ? 'it is' : 'they are'} in the file, at the position ` +
1519
+ `the original gave ${count === 1 ? 'it' : 'them'}.`);
1520
+ }
1521
+ if (contradictingDeclarations.length > 0) {
1522
+ const one = contradictingDeclarations.length === 1;
1523
+ warnings.push(`This board came from a BPMN file that used ` +
1524
+ `${contradictingDeclarations.join(' and ')} for something other than ` +
1525
+ `what BPMN means by ${one ? 'it' : 'them'}. ` +
1526
+ `Labre writes its own, so the file's ` +
1527
+ `${one ? 'declaration was' : 'declarations were'} ` +
1528
+ `left out rather than allowed to redefine the diagram's own namespaces. ` +
1529
+ `${one ? 'It is' : 'They are'} still in the document. ` +
1530
+ // The half a reader would otherwise have to work out: the DECLARATION
1531
+ // is what was dropped, and the matter written under it was not — so it
1532
+ // is now read under Labre's binding of the same prefix, which means
1533
+ // something else. That is the larger of the two changes and it was the
1534
+ // silent one.
1535
+ `Anything the file wrote under ` +
1536
+ `${one ? 'that prefix' : 'those prefixes'} is still in the export and ` +
1537
+ `will now be read under Labre's meaning of ` +
1538
+ `${one ? 'it' : 'them'}, which is not the meaning the original had.`);
1539
+ }
1540
+ // Two pools, two source files, one prefix bound two ways. Last one wins —
1541
+ // there is one attribute of each name on `definitions` and something has to
1542
+ // be written — but a rebinding nobody was told about is how a fragment comes
1543
+ // to mean something else with no trace of when.
1544
+ if (disagreeingDeclarations.length > 0) {
1545
+ warnings.push(`Two pools on this board disagree about ` +
1546
+ `${disagreeingDeclarations.join(' and ')}: they came from BPMN files ` +
1547
+ `that gave the same name two different values. The last was written ` +
1548
+ `and the other left out, so matter carried from the first file is now ` +
1549
+ `read under the second's meaning. Both are still in the document.`);
1550
+ }
1551
+ // A carried element claiming an id another has already written back. The
1552
+ // duplicate-by-paste case is silent on purpose — one thing carried twice is
1553
+ // written once and nothing is lost — so this fires only for two DIFFERENT
1554
+ // fragments claiming one id, which is a file that cannot hold both.
1555
+ const conflicting = [...new Set(carried.conflictingIds)];
1556
+ if (conflicting.length > 0) {
1557
+ const one = conflicting.length === 1;
1558
+ warnings.push(`${conflicting.length} ${one ? 'element' : 'elements'} imported from a ` +
1559
+ `BPMN file could not be written back: ` +
1560
+ `${conflicting.map(id => `"${id}"`).join(', ')} ` +
1561
+ `${one ? 'names an id' : 'name ids'} another imported element had ` +
1562
+ `already claimed, and a BPMN id must be unique across a document. The ` +
1563
+ `first was kept. ${one ? 'The other is' : 'The others are'} still in ` +
1564
+ `the document; ${one ? 'it is' : 'they are'} not in this file.`);
1565
+ }
1566
+ // An attribute NAME that is not a name. It cannot be written without
1567
+ // unbalancing the document — the serializer interpolates a name and escapes
1568
+ // only a value — so it is dropped rather than allowed to corrupt the file.
1569
+ const refusedNames = [...new Set(carried.refusedNames)];
1570
+ if (refusedNames.length > 0) {
1571
+ const one = refusedNames.length === 1;
1572
+ warnings.push(`${refusedNames.length} carried ${one ? 'attribute' : 'attributes'} ` +
1573
+ `could not be written back, because ` +
1574
+ `${one ? 'its name is not' : 'their names are not'} a valid XML name. ` +
1575
+ `${one ? 'It is' : 'They are'} still in the document. This is a sign ` +
1576
+ `the payload was edited by something other than a BPMN import.`);
1577
+ }
1578
+ if (unwritableEdges > 0) {
1579
+ warnings.push(`${unwritableEdges} ${unwritableEdges === 1 ? 'arrow was' : 'arrows were'} ` +
1580
+ `left out: BPMN requires both ends of a flow to be named, and ` +
1581
+ `${unwritableEdges === 1 ? 'this one has' : 'these have'} an end that ` +
1582
+ `is loose or attached to something that is not a BPMN artefact.`);
1583
+ }
1584
+ return {
1585
+ text: `<?xml version="1.0" encoding="UTF-8"?>\n${serializeElement(definitions, '')}\n`,
1586
+ warnings,
1587
+ };
1588
+ }
1589
+ function boundsAttrs(bound, dx, dy) {
1590
+ return {
1591
+ x: num(bound.x + dx),
1592
+ y: num(bound.y + dy),
1593
+ width: num(bound.w),
1594
+ height: num(bound.h),
1595
+ };
1596
+ }
1597
+ /**
1598
+ * One process's children, in the order `tProcess` requires them:
1599
+ * `laneSet* → flowElement* → artifact*`.
1600
+ *
1601
+ * The two halves of "flow element" are kept apart in the loop below only
1602
+ * because a LANE may reference the first and not the second: `flowNodeRef` is
1603
+ * an IDREF to a flow NODE, and a `dataObjectReference` is a flow element that
1604
+ * is not a flow node. They serialize into the same slot all the same.
1605
+ */
1606
+ function processChildren(index, process, planned, edges, carried) {
1607
+ const mine = planned.filter(node => node.scope === index);
1608
+ const children = [];
1609
+ /**
1610
+ * What an import carried out of this process, back in its own slot.
1611
+ *
1612
+ * The scope records which ELEMENT a fragment was a child of and not which
1613
+ * slot of it, so placement is the writer's problem — see
1614
+ * {@link processSlotOf}, which derives it from the same `tProcess` sequence
1615
+ * the three blocks below are already written in. `@self` joins `@process`
1616
+ * for the pool an import minted for a bare `process`, because that pool is
1617
+ * the process (D6).
1618
+ */
1619
+ const mySlots = bySlot(carried.keep(process.selfIsProcess
1620
+ ? carriedFragments(process.payload?.children, BPMN_SCOPE.self, BPMN_SCOPE.process)
1621
+ : carriedFragments(process.payload?.children, BPMN_SCOPE.process)), processSlotOf);
1622
+ // `documentation`, `extensionElements`, `auditing`, `property`… — everything
1623
+ // `tProcess` puts BEFORE its lane sets, which is where the sequence starts.
1624
+ children.push(...slot(mySlots, 'head'));
1625
+ /**
1626
+ * `laneSet` — FLAT, and only when the pool actually PAINTS lanes.
1627
+ *
1628
+ * The rows come from `poolLaneBands`, so the lanes written here are exactly
1629
+ * the lanes the plane draws a `BPMNShape` for: a row a typo made unusable is
1630
+ * absent from both rather than present in one as a lane with no shape and no
1631
+ * members.
1632
+ *
1633
+ * No `childLaneSet` is ever written, because the pack draws no nested lane:
1634
+ * a pool's `lanes` prop is one list of bands over one plot, and there is no
1635
+ * gesture that puts a lane inside a lane. The element exists in the format
1636
+ * and is deliberately unused; the day nested lanes ship, this is where they
1637
+ * land.
1638
+ */
1639
+ if (process.laneSetId && process.lanes.length > 0) {
1640
+ children.push(el('bpmn:laneSet', {
1641
+ id: process.laneSetId,
1642
+ ...carried.attrs(process.payload, BPMN_SCOPE.laneSet),
1643
+ }, [
1644
+ // `tLaneSet` is `documentation* → extensionElements? → lane*`.
1645
+ ...carried.keep(carriedFragments(process.payload?.children, BPMN_SCOPE.laneSet)),
1646
+ ...process.lanes.map(band => el('bpmn:lane', {
1647
+ id: band.id,
1648
+ name: band.lane.name || undefined,
1649
+ // A lane's SCOPE is the id the file called it, which is what
1650
+ // the pool stores on the band — never the id minted just above,
1651
+ // which a collision could have moved.
1652
+ ...carried.attrs(process.payload, band.lane.id),
1653
+ }, [
1654
+ // `tLane` is `documentation* → extensionElements? →
1655
+ // partitionElement? → flowNodeRef* → childLaneSet?`, so what
1656
+ // was carried off the lane goes before the references. The
1657
+ // `childLaneSet` never arrives: D5 case 3 quarantines it.
1658
+ ...carried.keep(carriedFragments(process.payload?.children, band.lane.id)),
1659
+ ...mine
1660
+ .filter(node => node.mapping.slot === 'flowNode' &&
1661
+ node.lane?.id === band.lane.id)
1662
+ // `flowNodeRef` is an ELEMENT whose text is the IDREF, never
1663
+ // an attribute — the one place in the format where a
1664
+ // reference is spelled that way.
1665
+ .map(node => textEl('bpmn:flowNodeRef', node.id)),
1666
+ ])),
1667
+ ]));
1668
+ }
1669
+ /* flowElement* — the flow nodes, the data references, the sequence flows. */
1670
+ for (const node of mine) {
1671
+ if (node.mapping.slot === 'artifact')
1672
+ continue;
1673
+ children.push(semanticNode(node, carried));
1674
+ // The `dataObject` a `dataObjectReference` points at: a flow element of
1675
+ // this same process, written beside the reference. The spec splits the two
1676
+ // on purpose — the OBJECT carries the item definition, the REFERENCE
1677
+ // carries the state and is what DI draws — and a reference whose
1678
+ // `dataObjectRef` resolves to nothing is the one thing bpmn.io's linter
1679
+ // complains about on an otherwise clean file.
1680
+ if (node.dataObjectId) {
1681
+ children.push(el('bpmn:dataObject', {
1682
+ id: node.dataObjectId,
1683
+ name: node.name || undefined,
1684
+ }));
1685
+ }
1686
+ }
1687
+ for (const edge of edges) {
1688
+ if (edge.scope !== index || edge.element !== 'sequenceFlow')
1689
+ continue;
1690
+ children.push(semanticEdge(edge, carried));
1691
+ }
1692
+ // Carried flow elements — the Analytic vocabulary, the flows onto it — in the
1693
+ // same slot as the ones above, because that is the slot they came out of.
1694
+ children.push(...slot(mySlots, 'flowElement'));
1695
+ /* artifact* — annotations, groups, associations. Last, per the XSD. */
1696
+ children.push(...artifacts(index, planned, edges, carried));
1697
+ children.push(...slot(mySlots, 'artifact'));
1698
+ /* …and `resourceRole*` and its neighbours, which follow the artifacts. */
1699
+ children.push(...slot(mySlots, 'tail'));
1700
+ return children;
1701
+ }
1702
+ /**
1703
+ * The `artifact*` tail of one scope — a process, or the collaboration.
1704
+ *
1705
+ * Shared between the two because `tProcess` and `tCollaboration` both end on
1706
+ * the same `artifact*` slot with the same members, and the only thing that
1707
+ * differs is which scope is being asked about. Writing it twice is how the two
1708
+ * would come to disagree.
1709
+ */
1710
+ function artifacts(scope, planned, edges, carried) {
1711
+ const out = [];
1712
+ for (const node of planned) {
1713
+ if (node.scope !== scope || node.mapping.slot !== 'artifact')
1714
+ continue;
1715
+ out.push(semanticNode(node, carried));
1716
+ }
1717
+ for (const edge of edges) {
1718
+ if (edge.scope !== scope || edge.element !== 'association')
1719
+ continue;
1720
+ out.push(semanticEdge(edge, carried));
1721
+ }
1722
+ return out;
1723
+ }
1724
+ /**
1725
+ * One flow, as its semantic element, carrying whatever an import kept on it.
1726
+ *
1727
+ * The three kinds share a writer because they share a shape — an id, two ends,
1728
+ * and everything the format says about a flow that Labre does not model (a
1729
+ * `conditionExpression`, a `default`, a vendor's `extensionElements`). What
1730
+ * differs is one attribute each way, and writing them apart is how the three
1731
+ * would come to disagree about the carried half.
1732
+ *
1733
+ * The carried attributes go on LAST, which is what lets an
1734
+ * `associationDirection` the file actually stated win over the `None` this
1735
+ * exporter writes for a role that declares no direction (`docs/adr/0010`).
1736
+ */
1737
+ function semanticEdge(edge, carried) {
1738
+ const attrs = {
1739
+ id: edge.id,
1740
+ // `association` has no `name` in this exporter and never had one.
1741
+ ...(edge.element === 'association' ? {} : { name: edge.name || undefined }),
1742
+ sourceRef: edge.source.id,
1743
+ targetRef: edge.target.id,
1744
+ // "This note is about that task" reads the same from either end, so the
1745
+ // association claims no direction.
1746
+ ...(edge.element === 'association' ? { associationDirection: 'None' } : {}),
1747
+ ...carried.attrs(edge.payload, BPMN_SCOPE.self),
1748
+ };
1749
+ return el(`bpmn:${edge.element}`, attrs,
1750
+ // A flow has no child this exporter writes, so there is nothing for the
1751
+ // carried ones to be ordered against.
1752
+ carried.keep(carriedFragments(edge.payload?.children, BPMN_SCOPE.self)));
1753
+ }
1754
+ /**
1755
+ * One artefact, as its semantic element (plus whatever it drags along).
1756
+ *
1757
+ * ## Where the carried half goes, and why it is one rule
1758
+ *
1759
+ * Everything an import kept off this element goes in FIRST, before the single
1760
+ * child this exporter ever writes — and that is XSD-correct for both of the
1761
+ * two cases rather than a convenience. `tTextAnnotation` is
1762
+ * `documentation* → extensionElements? → text?`, so the annotation's own text
1763
+ * is genuinely last. `tCatchEvent` / `tThrowEvent` reach `eventDefinition*`
1764
+ * only after `documentation*`, `extensionElements?`, `auditing?`,
1765
+ * `monitoring?`, `categoryValueRef*`, `incoming*`, `outgoing*`, `property*`,
1766
+ * `dataOutput*`, `dataOutputAssociation*` and `outputSet?` — which is to say,
1767
+ * after every child of an event an import can have carried. One rule covers
1768
+ * both, which is the only way it stays true.
1769
+ */
1770
+ function semanticNode(node, written) {
1771
+ const { mapping, name } = node;
1772
+ const carried = written.keep(carriedFragments(node.payload?.children, BPMN_SCOPE.self));
1773
+ const foreign = written.attrs(node.payload, BPMN_SCOPE.self);
1774
+ if (mapping.element === 'textAnnotation') {
1775
+ return el('bpmn:textAnnotation', { id: node.id, textFormat: 'text/plain', ...foreign }, [...carried, textEl('bpmn:text', name)]);
1776
+ }
1777
+ if (mapping.element === 'group') {
1778
+ // No `name` attribute exists on `group` — the label is the categoryValue's.
1779
+ return el('bpmn:group', {
1780
+ id: node.id,
1781
+ categoryValueRef: node.categoryValueId,
1782
+ ...foreign,
1783
+ }, carried);
1784
+ }
1785
+ if (mapping.element === 'dataObjectReference') {
1786
+ return el('bpmn:dataObjectReference', {
1787
+ id: node.id,
1788
+ name: name || undefined,
1789
+ dataObjectRef: node.dataObjectId,
1790
+ ...foreign,
1791
+ }, carried);
1792
+ }
1793
+ const attrs = { id: node.id, name: name || undefined, ...foreign };
1794
+ const children = [...carried];
1795
+ if (mapping.eventDefinition) {
1796
+ // Last child of the event, which is where `tCatchEvent` / `tThrowEvent` put
1797
+ // it. No child of its own is required: a `timerEventDefinition` with no
1798
+ // `timeDate` is valid, and the pack does not ask the author for one.
1799
+ children.push(el(`bpmn:${mapping.eventDefinition}`, {}));
1800
+ }
1801
+ return el(`bpmn:${mapping.element}`, attrs, children);
1802
+ }