@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Numbering allocator: the single place that creates, remaps, continues, merges and validates
3
+ * numbering definitions (`w:abstractNum` / `w:num`).
4
+ *
5
+ * Requirements (docs/plans: canonical list operations WP-2, revision fidelity WP-5a):
6
+ * - R1: tracked-deleted source paragraphs never change `numPr` (Reject All must restore the source binding).
7
+ * - R2: the standalone runner and the `openDocx` facade reach numbering merge through this module only
8
+ * (`mergeNumberingParts` / `mergeNumberingXmlBySchemaOrder`); neither keeps a merge of its own.
9
+ * - R3: reuse of the source list is allocator policy (`allocateNumberingForReplacement`), not a heuristic
10
+ * scattered at a call site.
11
+ *
12
+ * Ids are always reserved through the document-scoped state (`createDynamicNumberingIdState`,
13
+ * `reserveNextNumberingIdPair`), so a newly allocated id never collides with a definition the document owns.
14
+ * The old `num` / `abstractNum` are never deleted or overwritten: Reject All and other paragraphs may still
15
+ * reference them. Current properties are read only through `core/current-properties.js`.
16
+ */
17
+
18
+ import { parseOoxml, serializeOoxml } from '../engine/oxml-engine.js';
19
+ import { WORD_MAIN_NS } from '../core/paragraph-targeting.js';
20
+ import {
21
+ getCurrentNumIdElements,
22
+ getCurrentParagraphProperties,
23
+ getHistoricalParagraphProperties
24
+ } from '../core/current-properties.js';
25
+ import {
26
+ getElementId,
27
+ hasXmlParseError,
28
+ isDirectWordChild,
29
+ isTrackedDeletedParagraph,
30
+ remapNumberingPayloadForDocument,
31
+ reserveNextNumberingIdPair
32
+ } from './numbering-helpers.js';
33
+
34
+ const MAX_LEVEL = 8;
35
+ // CT_Lvl child order (ECMA-376 17.9.6).
36
+ const LVL_CHILD_ORDER = [
37
+ 'start', 'numFmt', 'lvlRestart', 'pStyle', 'isLgl', 'suff', 'lvlText', 'lvlPicBulletId', 'legacy', 'lvlJc', 'pPr', 'rPr'
38
+ ];
39
+
40
+ const wordChildren = (node, localName) =>
41
+ Array.from(node?.childNodes || []).filter(child => isDirectWordChild(child, localName));
42
+ const descendants = (node, localName) => Array.from(node?.getElementsByTagNameNS?.('*', localName) || []);
43
+ const valOf = element => {
44
+ const raw = element?.getAttribute?.('w:val') ?? element?.getAttribute?.('val');
45
+ const parsed = Number.parseInt(String(raw ?? ''), 10);
46
+ return Number.isFinite(parsed) ? parsed : null;
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------------------------------------
50
+ // Canonical form
51
+ // ---------------------------------------------------------------------------------------------------------
52
+
53
+ function canonicalNode(node, ignoreIds) {
54
+ if (node.nodeType === 3 || node.nodeType === 4) {
55
+ const text = String(node.nodeValue || '').trim();
56
+ return text ? JSON.stringify(text) : '';
57
+ }
58
+ if (node.nodeType !== 1) return '';
59
+ const attrs = Array.from(node.attributes || [])
60
+ .filter(attr => !/^xmlns(:|$)/.test(attr.name))
61
+ .filter(attr => !(ignoreIds && /^(w:)?(abstractNumId|numId)$/.test(attr.name)))
62
+ .map(attr => `${attr.localName || attr.name}=${attr.value}`)
63
+ .sort()
64
+ .join(',');
65
+ if (ignoreIds && node.localName === 'abstractNumId' && node.parentNode?.localName === 'num') {
66
+ return `<abstractNumId ${attrs.replace(/val=[^,]*/, 'val=*')}/>`;
67
+ }
68
+ const children = Array.from(node.childNodes || []).map(child => canonicalNode(child, ignoreIds)).join('');
69
+ return `<${node.localName} ${attrs}>${children}</>`;
70
+ }
71
+
72
+ /**
73
+ * Canonical string of the definition a `numId` resolves to (the `w:num` plus its `w:abstractNum`), with the
74
+ * ids themselves elided so definitions can be compared across independently allocated documents.
75
+ * Returns null when the numId does not resolve.
76
+ *
77
+ * @param {string|Document} numbering
78
+ * @param {string|number} numId
79
+ */
80
+ export function canonicalNumberingDefinition(numbering, numId) {
81
+ const doc = typeof numbering === 'string' ? parseOoxml(numbering) : numbering;
82
+ if (!doc || hasXmlParseError(doc)) return null;
83
+ const num = descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
84
+ if (!num) return null;
85
+ const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
86
+ const abstract = descendants(doc, 'abstractNum')
87
+ .find(candidate => getElementId(candidate, ['w:abstractNumId', 'abstractNumId']) === abstractId);
88
+ return `${canonicalNode(num, true)}|${abstract ? canonicalNode(abstract, true) : 'missing-abstract'}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------------------------------------
92
+ // Create new definitions
93
+ // ---------------------------------------------------------------------------------------------------------
94
+
95
+ function findAbstractForNum(numberingDoc, numId) {
96
+ const num = descendants(numberingDoc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
97
+ const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
98
+ if (abstractId == null) return null;
99
+ return descendants(numberingDoc, 'abstractNum')
100
+ .find(candidate => getElementId(candidate, ['w:abstractNumId', 'abstractNumId']) === abstractId) || null;
101
+ }
102
+
103
+ function setLvlChildVal(doc, lvl, localName, value) {
104
+ let child = wordChildren(lvl, localName)[0];
105
+ if (!child) {
106
+ child = doc.createElementNS(WORD_MAIN_NS, `w:${localName}`);
107
+ const rank = LVL_CHILD_ORDER.indexOf(localName);
108
+ const anchor = Array.from(lvl.childNodes || []).find(candidate =>
109
+ candidate.nodeType === 1 && LVL_CHILD_ORDER.indexOf(candidate.localName) > rank);
110
+ if (anchor) lvl.insertBefore(child, anchor);
111
+ else lvl.appendChild(child);
112
+ }
113
+ child.setAttribute('w:val', String(value));
114
+ }
115
+
116
+ function defaultLevel(doc, ilvl) {
117
+ const lvl = doc.createElementNS(WORD_MAIN_NS, 'w:lvl');
118
+ lvl.setAttribute('w:ilvl', String(ilvl));
119
+ setLvlChildVal(doc, lvl, 'start', 1);
120
+ setLvlChildVal(doc, lvl, 'numFmt', 'decimal');
121
+ setLvlChildVal(doc, lvl, 'lvlText', `%${ilvl + 1}.`);
122
+ setLvlChildVal(doc, lvl, 'lvlJc', 'left');
123
+ const pPr = doc.createElementNS(WORD_MAIN_NS, 'w:pPr');
124
+ const ind = doc.createElementNS(WORD_MAIN_NS, 'w:ind');
125
+ ind.setAttribute('w:left', String(720 * (ilvl + 1)));
126
+ ind.setAttribute('w:hanging', '360');
127
+ pPr.appendChild(ind);
128
+ lvl.appendChild(pPr);
129
+ return lvl;
130
+ }
131
+
132
+ /**
133
+ * Copies the level geometry (`w:lvlJc`, `w:pPr` indentation, `w:rPr` fonts, `w:suff`) of a source `w:lvl` onto a
134
+ * target `w:lvl`, replacing whatever geometry the target had.
135
+ *
136
+ * @param {Element} sourceLvl
137
+ * @param {Element} targetLvl
138
+ */
139
+ export function copyLevelGeometry(sourceLvl, targetLvl) {
140
+ const doc = targetLvl.ownerDocument;
141
+ for (const name of ['suff', 'lvlJc', 'pPr', 'rPr']) {
142
+ for (const existing of wordChildren(targetLvl, name)) targetLvl.removeChild(existing);
143
+ const source = wordChildren(sourceLvl, name)[0];
144
+ if (!source) continue;
145
+ const clone = doc.importNode(source, true);
146
+ const rank = LVL_CHILD_ORDER.indexOf(name);
147
+ const anchor = Array.from(targetLvl.childNodes || []).find(candidate =>
148
+ candidate.nodeType === 1 && LVL_CHILD_ORDER.indexOf(candidate.localName) > rank);
149
+ if (anchor) targetLvl.insertBefore(clone, anchor);
150
+ else targetLvl.appendChild(clone);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Adds `w:lvlOverride/w:startOverride` entries to a `w:num`. Per-instance restart values live on the `w:num`,
156
+ * never on the shared `w:abstractNum`.
157
+ *
158
+ * @param {Element} numElement
159
+ * @param {Record<number|string, number>} startOverrides - ilvl -> start value
160
+ */
161
+ export function applyStartOverridesToNum(numElement, startOverrides) {
162
+ const doc = numElement.ownerDocument;
163
+ const entries = Object.entries(startOverrides || {})
164
+ .map(([ilvl, start]) => [Number(ilvl), Number(start)])
165
+ .filter(([ilvl, start]) => Number.isInteger(ilvl) && ilvl >= 0 && ilvl <= MAX_LEVEL && Number.isInteger(start) && start >= 0)
166
+ .sort((a, b) => a[0] - b[0]);
167
+ for (const [ilvl, start] of entries) {
168
+ for (const existing of wordChildren(numElement, 'lvlOverride')) {
169
+ if (valOfIlvl(existing) === ilvl) numElement.removeChild(existing);
170
+ }
171
+ const override = doc.createElementNS(WORD_MAIN_NS, 'w:lvlOverride');
172
+ override.setAttribute('w:ilvl', String(ilvl));
173
+ const startOverride = doc.createElementNS(WORD_MAIN_NS, 'w:startOverride');
174
+ startOverride.setAttribute('w:val', String(start));
175
+ override.appendChild(startOverride);
176
+ numElement.appendChild(override);
177
+ }
178
+ }
179
+
180
+ function valOfIlvl(element) {
181
+ return getElementId(element, ['w:ilvl', 'ilvl']);
182
+ }
183
+
184
+ /**
185
+ * Creates a NEW `w:abstractNum` and `w:num` with fresh ids reserved from the document-scoped state.
186
+ *
187
+ * Levels not redefined keep the source level untouched; redefined levels (`numFmt`, `lvlText`, `start`) keep the
188
+ * source level's geometry (indentation, fonts, justification). Style links, `w:nsid` and `w:tmpl` are not copied,
189
+ * so the new definition never aliases a style or another list's identity.
190
+ *
191
+ * @param {{
192
+ * numberingIdState: object,
193
+ * sourceNumberingXml?: string|null,
194
+ * sourceNumId?: number|string|null,
195
+ * levels?: Array<{ ilvl: number, numFmt?: string, lvlText?: string, start?: number, dropChildren?: string[], dropRunFonts?: boolean }>,
196
+ * startOverrides?: Record<number|string, number>
197
+ * }} params
198
+ * @returns {{ numId: number, abstractNumId: number, numberingXml: string }|null} `null` when no ids can be reserved
199
+ */
200
+ export function allocateNumberingDefinition(params) {
201
+ const { numberingIdState, sourceNumberingXml, sourceNumId, levels = [], startOverrides } = params || {};
202
+ const pair = reserveNextNumberingIdPair(numberingIdState);
203
+ if (!pair) return null;
204
+
205
+ const sourceDoc = String(sourceNumberingXml || '').trim() ? parseOoxml(sourceNumberingXml) : null;
206
+ const sourceAbstract = sourceDoc && !hasXmlParseError(sourceDoc) && sourceNumId != null
207
+ ? findAbstractForNum(sourceDoc, sourceNumId)
208
+ : null;
209
+ const sourceLevels = new Map(descendants(sourceAbstract, 'lvl')
210
+ .map(lvl => [valOfIlvl(lvl), lvl]).filter(([ilvl]) => ilvl != null));
211
+
212
+ const doc = parseOoxml(`<w:numbering xmlns:w="${WORD_MAIN_NS}"/>`);
213
+ const root = doc.documentElement;
214
+ const abstract = doc.createElementNS(WORD_MAIN_NS, 'w:abstractNum');
215
+ abstract.setAttribute('w:abstractNumId', String(pair.abstractNumId));
216
+ const multiLevelType = doc.createElementNS(WORD_MAIN_NS, 'w:multiLevelType');
217
+ multiLevelType.setAttribute('w:val', wordChildren(sourceAbstract, 'multiLevelType')[0]?.getAttribute('w:val') || 'multilevel');
218
+ abstract.appendChild(multiLevelType);
219
+
220
+ const redefined = new Map(levels.filter(level => Number.isInteger(level?.ilvl)).map(level => [level.ilvl, level]));
221
+ const maxLevel = Math.max(MAX_LEVEL, ...sourceLevels.keys(), ...redefined.keys());
222
+ for (let ilvl = 0; ilvl <= maxLevel; ilvl += 1) {
223
+ const source = sourceLevels.get(ilvl);
224
+ const lvl = source ? doc.importNode(source, true) : defaultLevel(doc, ilvl);
225
+ for (const pStyle of wordChildren(lvl, 'pStyle')) lvl.removeChild(pStyle);
226
+ lvl.removeAttribute('w:tplc');
227
+ lvl.removeAttribute('w:tentative');
228
+ const redefinition = redefined.get(ilvl);
229
+ if (redefinition) {
230
+ if (redefinition.numFmt != null) setLvlChildVal(doc, lvl, 'numFmt', redefinition.numFmt);
231
+ if (redefinition.lvlText != null) setLvlChildVal(doc, lvl, 'lvlText', redefinition.lvlText);
232
+ if (redefinition.start != null) setLvlChildVal(doc, lvl, 'start', redefinition.start);
233
+ // A format change must not keep children that override the new format (w:isLgl forces arabic numbers,
234
+ // w:lvlPicBulletId a picture bullet) or the bullet's symbol font (digits in Symbol/Wingdings).
235
+ for (const name of redefinition.dropChildren || []) {
236
+ for (const child of wordChildren(lvl, name)) lvl.removeChild(child);
237
+ }
238
+ if (redefinition.dropRunFonts) {
239
+ for (const rPr of wordChildren(lvl, 'rPr')) {
240
+ for (const fonts of wordChildren(rPr, 'rFonts')) rPr.removeChild(fonts);
241
+ if (!Array.from(rPr.childNodes || []).some(child => child.nodeType === 1)) lvl.removeChild(rPr);
242
+ }
243
+ }
244
+ }
245
+ abstract.appendChild(lvl);
246
+ }
247
+
248
+ const num = doc.createElementNS(WORD_MAIN_NS, 'w:num');
249
+ num.setAttribute('w:numId', String(pair.numId));
250
+ const abstractRef = doc.createElementNS(WORD_MAIN_NS, 'w:abstractNumId');
251
+ abstractRef.setAttribute('w:val', String(pair.abstractNumId));
252
+ num.appendChild(abstractRef);
253
+ applyStartOverridesToNum(num, startOverrides);
254
+
255
+ root.appendChild(abstract);
256
+ root.appendChild(num);
257
+ return { numId: pair.numId, abstractNumId: pair.abstractNumId, numberingXml: serializeOoxml(doc) };
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------------------------------------
261
+ // Replacement policy (R1, R3)
262
+ // ---------------------------------------------------------------------------------------------------------
263
+
264
+ function levelFormats(abstractNum) {
265
+ const formats = new Map();
266
+ for (const lvl of descendants(abstractNum, 'lvl')) {
267
+ const ilvl = valOfIlvl(lvl);
268
+ const fmt = descendants(lvl, 'numFmt')[0]?.getAttribute('w:val') || null;
269
+ if (ilvl != null && fmt) formats.set(ilvl, fmt);
270
+ }
271
+ return formats;
272
+ }
273
+
274
+ function normalizeListFormat(format) {
275
+ return format === 'bullet' || format === 'circle' || format === 'square' ? 'bullet' : format;
276
+ }
277
+
278
+ function currentBindings(node) {
279
+ return getCurrentNumIdElements(node).map(numIdElement => {
280
+ const ilvlElement = wordChildren(numIdElement.parentNode, 'ilvl')[0];
281
+ return { numIdElement, numId: valOf(numIdElement), ilvl: ilvlElement ? (valOf(ilvlElement) ?? 0) : 0 };
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Policy: a same-kind list replacement continues the single list the source paragraphs already use. When every
287
+ * new list paragraph's generated level has the same format as that list's level, the new paragraphs bind to the
288
+ * existing `numId` and the generated payload is dropped. Anything else (format change, several source lists,
289
+ * unknown source definitions) allocates. R1: tracked-deleted paragraphs (retained source content) are never
290
+ * rebound.
291
+ */
292
+ function continueSourceList(numberingXml, replacementNodes, sourceParagraphs, numberingIdState) {
293
+ const sourceXml = numberingIdState?.sourceNumberingXml;
294
+ if (!numberingXml || !String(sourceXml || '').trim() || !Array.isArray(replacementNodes)) return null;
295
+
296
+ const sourceNumIds = new Set();
297
+ for (const paragraph of sourceParagraphs || []) {
298
+ for (const { numId } of currentBindings(paragraph)) {
299
+ if (numId != null && numId !== 0) sourceNumIds.add(numId);
300
+ }
301
+ }
302
+ if (sourceNumIds.size !== 1) return null;
303
+ const [sourceNumId] = sourceNumIds;
304
+
305
+ const sourceDoc = parseOoxml(sourceXml);
306
+ const payloadDoc = parseOoxml(numberingXml);
307
+ if (hasXmlParseError(sourceDoc) || hasXmlParseError(payloadDoc)) return null;
308
+ const sourceAbstract = findAbstractForNum(sourceDoc, sourceNumId);
309
+ if (!sourceAbstract) return null;
310
+ const sourceFormats = levelFormats(sourceAbstract);
311
+
312
+ const bindings = [];
313
+ for (const node of replacementNodes) {
314
+ if (isTrackedDeletedParagraph(node)) continue;
315
+ bindings.push(...currentBindings(node));
316
+ }
317
+ if (bindings.length === 0) return null;
318
+
319
+ const payloadFormats = new Map();
320
+ for (const { numId, ilvl } of bindings) {
321
+ if (numId == null) return null;
322
+ if (!payloadFormats.has(numId)) {
323
+ const abstract = findAbstractForNum(payloadDoc, numId);
324
+ if (!abstract) return null;
325
+ payloadFormats.set(numId, levelFormats(abstract));
326
+ }
327
+ const generated = payloadFormats.get(numId).get(ilvl);
328
+ const existing = sourceFormats.get(ilvl);
329
+ if (!generated || !existing || normalizeListFormat(generated) !== normalizeListFormat(existing)) return null;
330
+ }
331
+
332
+ const clonedNodes = replacementNodes.map(node => (node?.cloneNode ? node.cloneNode(true) : node));
333
+ for (const node of clonedNodes) {
334
+ if (isTrackedDeletedParagraph(node)) continue;
335
+ for (const numIdElement of getCurrentNumIdElements(node)) numIdElement.setAttribute('w:val', String(sourceNumId));
336
+ }
337
+ return { numId: sourceNumId, replacementNodes: clonedNodes };
338
+ }
339
+
340
+ /**
341
+ * Resolves the numbering of generated replacement paragraphs against the document.
342
+ *
343
+ * Policy order: (1) `continue-source` when `allowContinueSource` and the replacement is the same kind of list as
344
+ * the single source list; (2) `allocate`: remap the generated definitions onto fresh ids from the document-scoped
345
+ * state; (3) `passthrough` when there is nothing to allocate against. Tracked-deleted source paragraphs keep their
346
+ * `numPr` in every case (R1).
347
+ *
348
+ * @param {{
349
+ * numberingXml: string|null,
350
+ * replacementNodes: Node[],
351
+ * sourceParagraphs?: Node[],
352
+ * numberingIdState?: object|null,
353
+ * allowContinueSource?: boolean
354
+ * }} params
355
+ * @returns {{ policy: 'continue-source'|'allocate'|'passthrough', numberingXml: string|null, replacementNodes: Node[], numId?: number }}
356
+ */
357
+ export function allocateNumberingForReplacement(params) {
358
+ const { numberingXml, replacementNodes, sourceParagraphs, numberingIdState, allowContinueSource = true } = params || {};
359
+ if (numberingXml && numberingIdState && allowContinueSource) {
360
+ const continued = continueSourceList(numberingXml, replacementNodes, sourceParagraphs, numberingIdState);
361
+ if (continued) {
362
+ return { policy: 'continue-source', numberingXml: null, replacementNodes: continued.replacementNodes, numId: continued.numId };
363
+ }
364
+ }
365
+ if (numberingXml && numberingIdState) {
366
+ const remapped = remapNumberingPayloadForDocument(numberingXml, replacementNodes, numberingIdState);
367
+ return { policy: 'allocate', numberingXml: remapped.numberingXml, replacementNodes: remapped.replacementNodes };
368
+ }
369
+ return { policy: 'passthrough', numberingXml: numberingXml ?? null, replacementNodes };
370
+ }
371
+
372
+ /** Same key `list-format convert-plain` records, so redline conversions and list-format share one sequence. */
373
+ function batchSequenceKey(numFmt, ilvl, lvlText) {
374
+ const terminator = String(lvlText || '').replace(/%\d+/g, '').trim() || '.';
375
+ return `list-format:${numFmt}:${ilvl}:${numFmt === 'bullet' ? '' : terminator}`;
376
+ }
377
+
378
+ /**
379
+ * Describes the single list a generated payload creates: every new (not tracked-deleted) list paragraph binds one
380
+ * `numId` at one level. Returns null for nested, multi-list or unresolvable payloads (those never join a sequence).
381
+ */
382
+ function describeGeneratedList(numberingXml, replacementNodes) {
383
+ const bindings = [];
384
+ for (const node of replacementNodes || []) {
385
+ if (isTrackedDeletedParagraph(node)) continue;
386
+ bindings.push(...currentBindings(node));
387
+ }
388
+ if (bindings.length === 0) return null;
389
+ const { numId, ilvl } = bindings[0];
390
+ if (numId == null || numId === 0 || bindings.some(binding => binding.numId !== numId || binding.ilvl !== ilvl)) return null;
391
+ const doc = parseOoxml(numberingXml);
392
+ if (hasXmlParseError(doc)) return null;
393
+ const num = descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === Number(numId));
394
+ const abstract = findAbstractForNum(doc, numId);
395
+ const lvl = descendants(abstract, 'lvl').find(candidate => valOfIlvl(candidate) === ilvl);
396
+ const numFmt = descendants(lvl, 'numFmt')[0]?.getAttribute('w:val');
397
+ if (!num || !lvl || !numFmt) return null;
398
+ const lvlText = descendants(lvl, 'lvlText')[0]?.getAttribute('w:val') || '';
399
+ const override = wordChildren(num, 'lvlOverride').find(candidate => valOfIlvl(candidate) === ilvl);
400
+ const start = valOf(wordChildren(override, 'startOverride')[0]) ?? valOf(descendants(lvl, 'start')[0]) ?? 1;
401
+ const isBullet = normalizeListFormat(numFmt) === 'bullet';
402
+ return {
403
+ numId,
404
+ ilvl,
405
+ count: bindings.length,
406
+ isBullet,
407
+ start,
408
+ key: batchSequenceKey(isBullet ? 'bullet' : numFmt, ilvl, lvlText)
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Batch sequence: separate operations of one batch that generate items of the same kind, format and level continue
414
+ * ONE numbering instance while their values continue (`A.` then `B.`; bullets always). A gap or a different format
415
+ * allocates a new instance. The previous entry is reused only while its numId is still bound in the document
416
+ * (`isNumIdLive`), so a refused or rolled-back operation cannot poison the sequence. The sequence is the one
417
+ * `list-format convert-plain` uses (same keys), so ids still come from the document-scoped allocator: a continued
418
+ * list reuses an id that was already allocated, and a new list keeps the id the caller already reserved.
419
+ * Returns `{ numId, replacementNodes }` rebound to the previous instance (the caller drops the generated payload),
420
+ * or null when the caller keeps its own allocation (which this call has recorded as the new sequence entry).
421
+ */
422
+ export function continueBatchListSequence({ numberingXml, replacementNodes, sequence, isNumIdLive }) {
423
+ if (!(sequence instanceof Map) || !numberingXml) return null;
424
+ const described = describeGeneratedList(numberingXml, replacementNodes);
425
+ if (!described) return null;
426
+ const previous = sequence.get(described.key) || null;
427
+ const live = previous && previous.numId != null && (typeof isNumIdLive !== 'function' || isNumIdLive(previous.numId));
428
+ const continues = live && (described.isBullet || previous.nextStartAt === described.start);
429
+ const nextStartAt = described.isBullet ? null : described.start + described.count;
430
+ if (!continues) {
431
+ sequence.set(described.key, { numId: String(described.numId), nextStartAt });
432
+ return null;
433
+ }
434
+ const clonedNodes = replacementNodes.map(node => (node?.cloneNode ? node.cloneNode(true) : node));
435
+ for (const node of clonedNodes) {
436
+ if (isTrackedDeletedParagraph(node)) continue;
437
+ for (const numIdElement of getCurrentNumIdElements(node)) numIdElement.setAttribute('w:val', String(previous.numId));
438
+ }
439
+ sequence.set(described.key, { numId: String(previous.numId), nextStartAt });
440
+ return { numId: previous.numId, replacementNodes: clonedNodes };
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------------------------------------
444
+ // Merge (R2): the only numbering merge in the package
445
+ // ---------------------------------------------------------------------------------------------------------
446
+
447
+ function insertNumberingNodeInSchemaOrder(root, node, kind) {
448
+ const directChildren = Array.from(root.childNodes || []).filter(
449
+ child => child && child.nodeType === 1 && child.namespaceURI === WORD_MAIN_NS
450
+ );
451
+ const anchor = kind === 'abstract'
452
+ ? directChildren.find(child => child.localName === 'num' || child.localName === 'numIdMacAtCleanup')
453
+ : directChildren.find(child => child.localName === 'numIdMacAtCleanup');
454
+ if (anchor) root.insertBefore(node, anchor);
455
+ else root.appendChild(node);
456
+ }
457
+
458
+ /**
459
+ * Merges incoming numbering definitions into an existing numbering part, preserving schema order
460
+ * (`abstractNum*` before `num*`). Definitions are added by id; an incoming id that already exists is kept as is
461
+ * when its canonical content is equal, and is reported through `onConflict` (never overwritten) when it differs,
462
+ * because the paragraphs written with the incoming id would silently bind to someone else's list.
463
+ *
464
+ * Every caller (standalone runner consumers, `openDocx`, `ensureNumberingArtifactsInZip`) merges through here.
465
+ *
466
+ * @param {string} existingNumberingXml
467
+ * @param {string} incomingNumberingXml
468
+ * @param {{ onConflict?: (conflict: { kind: 'abstractNum'|'num', id: number }) => void }} [options]
469
+ * @returns {string}
470
+ */
471
+ export function mergeNumberingXmlBySchemaOrder(existingNumberingXml, incomingNumberingXml, options = {}) {
472
+ const existingText = String(existingNumberingXml || '');
473
+ const incomingText = String(incomingNumberingXml || '');
474
+ if (!incomingText.trim()) return existingText;
475
+ if (!existingText.trim()) return incomingText;
476
+
477
+ try {
478
+ const existingDoc = parseOoxml(existingText);
479
+ const incomingDoc = parseOoxml(incomingText);
480
+ if (hasXmlParseError(existingDoc) || hasXmlParseError(incomingDoc)) return existingText;
481
+ const existingRoot = existingDoc.documentElement;
482
+ const incomingRoot = incomingDoc.documentElement;
483
+ if (!existingRoot || !incomingRoot) return existingText;
484
+
485
+ for (const [kind, localName, attr] of [['abstract', 'abstractNum', 'abstractNumId'], ['num', 'num', 'numId']]) {
486
+ const existingById = new Map(wordChildren(existingRoot, localName)
487
+ .map(node => [getElementId(node, [`w:${attr}`, attr]), node]).filter(([id]) => id != null));
488
+ for (const incomingNode of wordChildren(incomingRoot, localName)) {
489
+ const id = getElementId(incomingNode, [`w:${attr}`, attr]);
490
+ if (id == null) continue;
491
+ const present = existingById.get(id);
492
+ if (present) {
493
+ if (typeof options?.onConflict === 'function' && canonicalNode(present, false) !== canonicalNode(incomingNode, false)) {
494
+ options.onConflict({ kind: localName, id });
495
+ }
496
+ continue;
497
+ }
498
+ const imported = existingDoc.importNode(incomingNode, true);
499
+ insertNumberingNodeInSchemaOrder(existingRoot, imported, kind);
500
+ existingById.set(id, imported);
501
+ }
502
+ }
503
+ return serializeOoxml(existingDoc);
504
+ } catch {
505
+ return existingText;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Merges every incoming numbering payload into the existing part, in order. Same arguments for every caller.
511
+ *
512
+ * @param {string|null|undefined} existingNumberingXml
513
+ * @param {string|string[]|null|undefined} incomingParts
514
+ * @param {Parameters<typeof mergeNumberingXmlBySchemaOrder>[2]} [options]
515
+ * @returns {string}
516
+ */
517
+ export function mergeNumberingParts(existingNumberingXml, incomingParts, options = {}) {
518
+ let merged = String(existingNumberingXml || '');
519
+ for (const part of (Array.isArray(incomingParts) ? incomingParts : [incomingParts]).filter(Boolean)) {
520
+ merged = mergeNumberingXmlBySchemaOrder(merged, part, options);
521
+ }
522
+ return merged;
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------------------------------------
526
+ // Validation
527
+ // ---------------------------------------------------------------------------------------------------------
528
+
529
+ /**
530
+ * Checks that a direct paragraph binding `{ numId, level }` resolves in the document's numbering: the `w:num`
531
+ * exists (in the source part or among ids the current batch allocated), it has an `abstractNum`, and the level
532
+ * is defined by that abstract or by the `w:num`'s own `w:lvlOverride/w:lvl`. A binding is a reference, so this
533
+ * never allocates.
534
+ *
535
+ * @param {{ sourceNumberingXml?: string|null, usedNumIds?: Set<number> }|null|undefined} numberingIdState
536
+ * @param {string|number} numId
537
+ * @param {number} [level]
538
+ * @returns {{ ok: true } | { ok: false, reason: 'unknown-num'|'missing-abstract'|'undefined-level', message: string }}
539
+ */
540
+ export function checkNumberingBinding(numberingIdState, numId, level = 0) {
541
+ const id = Number(numId);
542
+ const sourceXml = String(numberingIdState?.sourceNumberingXml || '');
543
+ const doc = sourceXml.trim() ? parseOoxml(sourceXml) : null;
544
+ const sourceNum = doc && !hasXmlParseError(doc)
545
+ ? descendants(doc, 'num').find(candidate => getElementId(candidate, ['w:numId', 'numId']) === id)
546
+ : null;
547
+ if (!sourceNum) {
548
+ // Ids allocated earlier in this batch exist only as pending numbering parts.
549
+ if (numberingIdState?.usedNumIds instanceof Set && numberingIdState.usedNumIds.has(id)) return { ok: true };
550
+ return {
551
+ ok: false,
552
+ reason: 'unknown-num',
553
+ message: `numbering.numId ${id} does not exist in the document's numbering definitions.`
554
+ };
555
+ }
556
+ const abstract = findAbstractForNum(doc, id);
557
+ if (!abstract) {
558
+ return { ok: false, reason: 'missing-abstract', message: `numId ${id} points at an abstractNum that does not exist.` };
559
+ }
560
+ const definesLevel = container => wordChildren(container, 'lvl')
561
+ .some(lvl => Number.parseInt(lvl.getAttribute('w:ilvl') ?? lvl.getAttribute('ilvl') ?? '0', 10) === level);
562
+ const overrideDefines = wordChildren(sourceNum, 'lvlOverride').some(override => definesLevel(override));
563
+ if (!definesLevel(abstract) && !overrideDefines) {
564
+ return { ok: false, reason: 'undefined-level', message: `numId ${id} does not define list level ${level}.` };
565
+ }
566
+ return { ok: true };
567
+ }
568
+
569
+ /**
570
+ * Numbering integrity: every body `w:numId` (current and `pPrChange` snapshot) resolves to a `w:num`, every
571
+ * `w:num` resolves to an `abstractNum`, every `w:ilvl` in use exists in that abstract (or in the `w:num`'s own
572
+ * `w:lvlOverride/w:lvl`), and numbering ids are unique. `numId` 0 means "no list" and is always valid.
573
+ *
574
+ * @param {string|Document} documentXml
575
+ * @param {string|Document|null|undefined} numberingXml
576
+ * @returns {Array<{ code: string, severity: 'error', message: string, numId?: number, abstractNumId?: number, ilvl?: number }>}
577
+ */
578
+ export function validateNumberingIntegrity(documentXml, numberingXml) {
579
+ const issues = [];
580
+ const seen = new Set();
581
+ const report = (code, message, extra = {}) => {
582
+ const key = `${code}:${extra.numId ?? ''}:${extra.abstractNumId ?? ''}:${extra.ilvl ?? ''}`;
583
+ if (seen.has(key)) return;
584
+ seen.add(key);
585
+ issues.push({ code, severity: 'error', message, ...extra });
586
+ };
587
+
588
+ const bodyDoc = typeof documentXml === 'string' ? parseOoxml(documentXml) : documentXml;
589
+ if (!bodyDoc || hasXmlParseError(bodyDoc)) return issues;
590
+ const numberingDoc = typeof numberingXml === 'string'
591
+ ? (numberingXml.trim() ? parseOoxml(numberingXml) : null)
592
+ : (numberingXml || null);
593
+ const numberingOk = numberingDoc && !hasXmlParseError(numberingDoc);
594
+
595
+ const abstractById = new Map();
596
+ const numById = new Map();
597
+ if (numberingOk) {
598
+ for (const [localName, attr, map] of [['abstractNum', 'abstractNumId', abstractById], ['num', 'numId', numById]]) {
599
+ for (const node of descendants(numberingDoc, localName)) {
600
+ const id = getElementId(node, [`w:${attr}`, attr]);
601
+ if (id == null) continue;
602
+ if (map.has(id)) report('NUMBERING_DUPLICATE_ID', `Numbering part defines ${localName} ${id} more than once.`, { [attr]: id });
603
+ else map.set(id, node);
604
+ }
605
+ }
606
+ for (const [numId, num] of numById) {
607
+ const abstractId = valOf(wordChildren(num, 'abstractNumId')[0]);
608
+ if (abstractId == null) {
609
+ report('NUMBERING_ABSTRACT_UNDEFINED', `w:num ${numId} has no w:abstractNumId.`, { numId });
610
+ } else if (!abstractById.has(abstractId)) {
611
+ report('NUMBERING_ABSTRACT_UNDEFINED', `w:num ${numId} references missing w:abstractNum ${abstractId}.`, { numId, abstractNumId: abstractId });
612
+ }
613
+ }
614
+ }
615
+
616
+ const levelDefined = (numId, ilvl) => {
617
+ const num = numById.get(numId);
618
+ const abstract = abstractById.get(valOf(wordChildren(num, 'abstractNumId')[0]));
619
+ if (!abstract) return true; // already reported as an undefined abstractNum
620
+ if (descendants(abstract, 'lvl').some(lvl => valOfIlvl(lvl) === ilvl)) return true;
621
+ return wordChildren(num, 'lvlOverride').some(override =>
622
+ valOfIlvl(override) === ilvl && wordChildren(override, 'lvl').length > 0);
623
+ };
624
+
625
+ for (const paragraph of Array.from(bodyDoc.getElementsByTagNameNS(WORD_MAIN_NS, 'p'))) {
626
+ const bindings = [getCurrentParagraphProperties(paragraph).numPr, getHistoricalParagraphProperties(paragraph)?.numPr];
627
+ for (const numPr of bindings) {
628
+ if (!numPr?.numIdElement) continue;
629
+ const numId = valOf(numPr.numIdElement);
630
+ if (numId == null || numId === 0) continue;
631
+ if (!numberingOk || !numById.has(numId)) {
632
+ report('NUMBERING_NUM_UNDEFINED', `w:numId ${numId} is used in the document but no w:num defines it.`, { numId });
633
+ continue;
634
+ }
635
+ const ilvl = numPr.ilvlElement ? valOf(numPr.ilvlElement) : null;
636
+ if (ilvl != null && !levelDefined(numId, ilvl)) {
637
+ report('NUMBERING_LEVEL_UNDEFINED', `w:ilvl ${ilvl} is used with w:numId ${numId} but its abstractNum does not define that level.`, { numId, ilvl });
638
+ }
639
+ }
640
+ }
641
+ return issues;
642
+ }