@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
@@ -1,524 +1,354 @@
1
- import { parseOoxml, serializeOoxml } from '../engine/oxml-engine.js';
2
- import { WORD_MAIN_NS } from '../core/paragraph-targeting.js';
3
-
4
- function parseIntegerAttribute(element, names) {
5
- if (!element || !Array.isArray(names)) return null;
6
- for (const name of names) {
7
- const raw = element.getAttribute(name);
8
- if (raw == null || raw === '') continue;
9
- const parsed = Number.parseInt(String(raw), 10);
10
- if (Number.isFinite(parsed)) return parsed;
11
- }
12
- return null;
13
- }
14
-
15
- function nextAvailableId(startId, occupiedIds, maxPreferred = null) {
16
- let candidate = Number.isInteger(startId) && startId > 0 ? startId : 1;
17
- const occupied = occupiedIds instanceof Set ? occupiedIds : new Set();
18
-
19
- while (occupied.has(candidate)) {
20
- candidate += 1;
21
- }
22
-
23
- if (Number.isInteger(maxPreferred) && maxPreferred > 0 && candidate > maxPreferred) {
24
- for (let probe = 1; probe <= maxPreferred; probe += 1) {
25
- if (!occupied.has(probe)) return probe;
26
- }
27
- }
28
-
29
- return candidate;
30
- }
31
-
32
- /**
33
- * Builds a dynamic numbering-id state from existing numbering XML.
34
- *
35
- * This avoids hardcoded ID floors and keeps IDs deterministic relative to the
36
- * current document numbering definitions.
37
- *
38
- * @param {string} numberingXml - Existing `word/numbering.xml` content
39
- * @param {{
40
- * minId?: number,
41
- * maxPreferred?: number
42
- * }} [options={}] - Optional ID preferences
43
- * @returns {{
44
- * nextNumId: number,
45
- * nextAbstractNumId: number,
46
- * usedNumIds: Set<number>,
47
- * usedAbstractNumIds: Set<number>,
48
- * minId: number,
49
- * maxPreferred: number
50
- * }}
51
- */
52
- export function createDynamicNumberingIdState(numberingXml, options = {}) {
53
- const minId = Number.isInteger(options?.minId) && options.minId > 0 ? options.minId : 1;
54
- const maxPreferred = Number.isInteger(options?.maxPreferred) && options.maxPreferred >= minId
55
- ? options.maxPreferred
56
- : 32767;
57
-
58
- const usedNumIds = new Set();
59
- const usedAbstractNumIds = new Set();
60
-
61
- if (String(numberingXml || '').trim()) {
62
- try {
63
- const numberingDoc = parseOoxml(numberingXml);
64
- const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
65
- const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
66
-
67
- for (const abstractNum of abstractNums) {
68
- const id = parseIntegerAttribute(abstractNum, ['w:abstractNumId', 'abstractNumId']);
69
- if (id != null) usedAbstractNumIds.add(id);
70
- }
71
- for (const num of nums) {
72
- const id = parseIntegerAttribute(num, ['w:numId', 'numId']);
73
- if (id != null) usedNumIds.add(id);
74
- }
75
- } catch {
76
- // Ignore malformed numbering XML and fall back to empty sets.
77
- }
78
- }
79
-
80
- const maxUsedNumId = usedNumIds.size > 0 ? Math.max(...usedNumIds) : 0;
81
- const maxUsedAbstractNumId = usedAbstractNumIds.size > 0 ? Math.max(...usedAbstractNumIds) : 0;
82
- const baseNumId = Math.max(minId, maxUsedNumId + 1);
83
- const baseAbstractNumId = Math.max(minId, maxUsedAbstractNumId + 1);
84
-
85
- return {
86
- // Kept so list edits can recognise which existing definition a source list uses and continue it.
87
- sourceNumberingXml: String(numberingXml || ''),
88
- nextNumId: nextAvailableId(baseNumId, usedNumIds, maxPreferred),
89
- nextAbstractNumId: nextAvailableId(baseAbstractNumId, usedAbstractNumIds, maxPreferred),
90
- usedNumIds,
91
- usedAbstractNumIds,
92
- minId,
93
- maxPreferred
94
- };
95
- }
96
-
97
- function normalizeNumberingIdState(state) {
98
- if (!state || typeof state !== 'object') return null;
99
-
100
- if (!(state.usedNumIds instanceof Set)) state.usedNumIds = new Set();
101
- if (!(state.usedAbstractNumIds instanceof Set)) state.usedAbstractNumIds = new Set();
102
-
103
- if (!Number.isInteger(state.minId) || state.minId < 1) {
104
- state.minId = 1;
105
- }
106
- if (!Number.isInteger(state.maxPreferred) || state.maxPreferred < state.minId) {
107
- state.maxPreferred = 32767;
108
- }
109
-
110
- if (!Number.isInteger(state.nextNumId) || state.nextNumId < state.minId) {
111
- state.nextNumId = state.minId;
112
- }
113
- if (!Number.isInteger(state.nextAbstractNumId) || state.nextAbstractNumId < state.minId) {
114
- state.nextAbstractNumId = state.minId;
115
- }
116
-
117
- state.nextNumId = nextAvailableId(state.nextNumId, state.usedNumIds, state.maxPreferred);
118
- state.nextAbstractNumId = nextAvailableId(state.nextAbstractNumId, state.usedAbstractNumIds, state.maxPreferred);
119
- return state;
120
- }
121
-
122
- /**
123
- * Reserves the next available ID from a mutable numbering-id state.
124
- *
125
- * @param {ReturnType<typeof createDynamicNumberingIdState>} state
126
- * @param {'num'|'abstract'} [kind='num']
127
- * @returns {number|null}
128
- */
129
- export function reserveNextNumberingId(state, kind = 'num') {
130
- const normalized = normalizeNumberingIdState(state);
131
- if (!normalized) return null;
132
-
133
- const useAbstract = kind === 'abstract';
134
- const id = useAbstract ? normalized.nextAbstractNumId : normalized.nextNumId;
135
- if (!Number.isInteger(id) || id < 1) return null;
136
-
137
- if (useAbstract) {
138
- normalized.usedAbstractNumIds.add(id);
139
- normalized.nextAbstractNumId = nextAvailableId(id + 1, normalized.usedAbstractNumIds, normalized.maxPreferred);
140
- } else {
141
- normalized.usedNumIds.add(id);
142
- normalized.nextNumId = nextAvailableId(id + 1, normalized.usedNumIds, normalized.maxPreferred);
143
- }
144
-
145
- return id;
146
- }
147
-
148
- /**
149
- * Reserves the next available numbering IDs on a mutable numbering-id state.
150
- *
151
- * @param {ReturnType<typeof createDynamicNumberingIdState>} state
152
- * @returns {{ numId: number, abstractNumId: number } | null}
153
- */
154
- export function reserveNextNumberingIdPair(state) {
155
- const numId = reserveNextNumberingId(state, 'num');
156
- const abstractNumId = reserveNextNumberingId(state, 'abstract');
157
- if (numId == null || abstractNumId == null) return null;
158
-
159
- return { numId, abstractNumId };
160
- }
161
-
162
- function hasXmlParseError(doc) {
163
- if (!doc || !doc.documentElement) return true;
164
- if (doc.documentElement.localName === 'parsererror') return true;
165
- return doc.getElementsByTagName('parsererror').length > 0;
166
- }
167
-
168
- function isDirectWordChild(node, localName) {
169
- return !!(
170
- node &&
171
- node.nodeType === 1 &&
172
- node.namespaceURI === WORD_MAIN_NS &&
173
- node.localName === localName
174
- );
175
- }
176
-
177
- function insertNumberingNodeInSchemaOrder(root, node, kind) {
178
- if (!root || !node) return;
179
- const directChildren = Array.from(root.childNodes || []).filter(
180
- child => child && child.nodeType === 1 && child.namespaceURI === WORD_MAIN_NS
181
- );
182
-
183
- let anchor = null;
184
- if (kind === 'abstract') {
185
- anchor = directChildren.find(
186
- child => child.localName === 'num' || child.localName === 'numIdMacAtCleanup'
187
- ) || null;
188
- } else {
189
- anchor = directChildren.find(child => child.localName === 'numIdMacAtCleanup') || null;
190
- }
191
-
192
- if (anchor) root.insertBefore(node, anchor);
193
- else root.appendChild(node);
194
- }
195
-
196
- function getAttributeFirst(element, names) {
197
- for (const name of names || []) {
198
- const value = element?.getAttribute?.(name);
199
- if (value != null && value !== '') return value;
200
- }
201
- return null;
202
- }
203
-
204
- function getElementId(element, names) {
205
- const raw = getAttributeFirst(element, names);
206
- const parsed = Number.parseInt(String(raw || ''), 10);
207
- return Number.isFinite(parsed) ? parsed : null;
208
- }
209
-
210
- function setElementId(element, preferredName, idValue) {
211
- element?.setAttribute?.(preferredName, String(idValue));
212
- }
213
-
214
- function setElementVal(element, value) {
215
- element?.setAttribute?.('w:val', String(value));
216
- }
217
-
218
- /**
219
- * Overwrites all paragraph-level `w:numId` references in a node collection.
220
- *
221
- * @param {Node[]|null|undefined} paragraphNodes
222
- * @param {string|number|null|undefined} targetNumId
223
- */
224
- export function overwriteParagraphNumIds(paragraphNodes, targetNumId) {
225
- if (!Array.isArray(paragraphNodes) || targetNumId == null) return;
226
- for (const node of paragraphNodes) {
227
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
228
- for (const numIdNode of numIdNodes) {
229
- setElementVal(numIdNode, targetNumId);
230
- }
231
- }
232
- }
233
-
234
- /**
235
- * Extracts the first `w:numId` value from a node collection.
236
- *
237
- * @param {Node[]|null|undefined} paragraphNodes
238
- * @returns {string|null}
239
- */
240
- export function extractFirstParagraphNumId(paragraphNodes) {
241
- for (const node of paragraphNodes || []) {
242
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
243
- for (const numIdNode of numIdNodes) {
244
- const numId = getElementId(numIdNode, ['w:val', 'val']);
245
- if (numId != null) return String(numId);
246
- }
247
- }
248
- return null;
249
- }
250
-
251
- /**
252
- * Builds multilevel decimal numbering XML for explicit-start header conversion.
253
- *
254
- * @param {string|number} numId
255
- * @param {string|number} abstractNumId
256
- * @param {number} startAt
257
- * @returns {string}
258
- */
259
- export function buildExplicitDecimalMultilevelNumberingXml(numId, abstractNumId, startAt) {
260
- const safeNumId = String(numId);
261
- const safeAbstractNumId = String(abstractNumId);
262
- const safeStartAt = Number.isInteger(startAt) && startAt > 0 ? startAt : 1;
263
- const levelsXml = Array.from({ length: 9 }, (_, level) => {
264
- const lvlText = Array.from({ length: level + 1 }, (_, i) => `%${i + 1}`).join('.') + '.';
265
- const left = 720 * (level + 1);
266
- return `
267
- <w:lvl w:ilvl="${level}">
268
- <w:start w:val="1"/>
269
- <w:numFmt w:val="decimal"/>
270
- <w:lvlText w:val="${lvlText}"/>
271
- <w:lvlJc w:val="left"/>
272
- <w:pPr><w:ind w:left="${left}" w:hanging="360"/></w:pPr>
273
- </w:lvl>`;
274
- }).join('');
275
- return `
276
- <w:numbering xmlns:w="${WORD_MAIN_NS}">
277
- <w:abstractNum w:abstractNumId="${safeAbstractNumId}">
278
- <w:multiLevelType w:val="multilevel"/>
279
- ${levelsXml}
280
- </w:abstractNum>
281
- <w:num w:numId="${safeNumId}">
282
- <w:abstractNumId w:val="${safeAbstractNumId}"/>
283
- <w:lvlOverride w:ilvl="0">
284
- <w:startOverride w:val="${safeStartAt}"/>
285
- </w:lvlOverride>
286
- </w:num>
287
- </w:numbering>`.trim();
288
- }
289
-
290
- function isTrackedDeletedParagraph(node) {
291
- if (!node || node.nodeType !== 1 || node.localName !== 'p') return false;
292
- const pPr = Array.from(node.childNodes || []).find(child => isDirectWordChild(child, 'pPr'));
293
- const rPr = pPr && Array.from(pPr.childNodes || []).find(child => isDirectWordChild(child, 'rPr'));
294
- return !!rPr && Array.from(rPr.childNodes || []).some(child => isDirectWordChild(child, 'del'));
295
- }
296
-
297
- function paragraphNumIdNodes(node) {
298
- return Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || [])
299
- .filter(numIdNode => numIdNode.parentNode?.localName === 'numPr');
300
- }
301
-
302
- function levelFormats(abstractNum) {
303
- const formats = new Map();
304
- for (const lvl of Array.from(abstractNum?.getElementsByTagNameNS?.('*', 'lvl') || [])) {
305
- const ilvl = getElementId(lvl, ['w:ilvl', 'ilvl']);
306
- const fmt = Array.from(lvl.getElementsByTagNameNS('*', 'numFmt'))[0]?.getAttribute('w:val') || null;
307
- if (ilvl != null && fmt) formats.set(ilvl, fmt);
308
- }
309
- return formats;
310
- }
311
-
312
- function normalizeListFormat(format) {
313
- return format === 'bullet' || format === 'circle' || format === 'square' ? 'bullet' : format;
314
- }
315
-
316
- function abstractNumForNumId(numberingDoc, numId) {
317
- const num = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'))
318
- .find(candidate => getElementId(candidate, ['w:numId', 'numId']) === numId);
319
- const abstractId = num && getElementId(Array.from(num.getElementsByTagNameNS('*', 'abstractNumId'))[0], ['w:val', 'val']);
320
- if (abstractId == null) return null;
321
- return Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'))
322
- .find(candidate => getElementId(candidate, ['w:abstractNumId', 'abstractNumId']) === abstractId) || null;
323
- }
324
-
325
- /**
326
- * Lets a same-kind list replacement continue the list it replaces instead of allocating a new
327
- * numbering definition. When every new list paragraph's generated definition has the same format
328
- * as the single existing list used by the source paragraphs at each level in use, the new
329
- * paragraphs are bound to that existing numId and the generated numbering payload is dropped.
330
- * Format-changing replacements (and anything ambiguous) return null and take the allocation path.
331
- *
332
- * @param {string|null} numberingXml - Generated numbering payload
333
- * @param {Node[]} replacementNodes - Generated paragraphs (tracked-deleted source copies are left alone)
334
- * @param {Node[]} sourceParagraphs - Paragraphs being replaced
335
- * @param {{ sourceNumberingXml?: string }|null} numberingIdState
336
- * @returns {{ numId: number, replacementNodes: Node[] }|null}
337
- */
338
- export function reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, numberingIdState) {
339
- const sourceXml = numberingIdState?.sourceNumberingXml;
340
- if (!numberingXml || !String(sourceXml || '').trim() || !Array.isArray(replacementNodes)) return null;
341
-
342
- const sourceNumIds = new Set();
343
- for (const paragraph of sourceParagraphs || []) {
344
- for (const numIdNode of paragraphNumIdNodes(paragraph)) {
345
- const id = getElementId(numIdNode, ['w:val', 'val']);
346
- if (id != null && id !== 0) sourceNumIds.add(id);
347
- }
348
- }
349
- if (sourceNumIds.size !== 1) return null;
350
- const [sourceNumId] = sourceNumIds;
351
-
352
- const sourceDoc = parseOoxml(sourceXml);
353
- const payloadDoc = parseOoxml(numberingXml);
354
- if (hasXmlParseError(sourceDoc) || hasXmlParseError(payloadDoc)) return null;
355
- const sourceAbstract = abstractNumForNumId(sourceDoc, sourceNumId);
356
- if (!sourceAbstract) return null;
357
- const sourceFormats = levelFormats(sourceAbstract);
358
-
359
- const newParagraphNumIds = [];
360
- for (const node of replacementNodes) {
361
- if (isTrackedDeletedParagraph(node)) continue;
362
- for (const numIdNode of paragraphNumIdNodes(node)) {
363
- const ilvlNode = Array.from(numIdNode.parentNode.childNodes || []).find(child => child.localName === 'ilvl');
364
- newParagraphNumIds.push({
365
- numId: getElementId(numIdNode, ['w:val', 'val']),
366
- ilvl: ilvlNode ? (getElementId(ilvlNode, ['w:val', 'val']) ?? 0) : 0
367
- });
368
- }
369
- }
370
- if (newParagraphNumIds.length === 0) return null;
371
-
372
- const payloadFormats = new Map();
373
- for (const { numId, ilvl } of newParagraphNumIds) {
374
- if (numId == null) return null;
375
- if (!payloadFormats.has(numId)) {
376
- const abstract = abstractNumForNumId(payloadDoc, numId);
377
- if (!abstract) return null;
378
- payloadFormats.set(numId, levelFormats(abstract));
379
- }
380
- const generated = payloadFormats.get(numId).get(ilvl);
381
- const existing = sourceFormats.get(ilvl);
382
- if (!generated || !existing || normalizeListFormat(generated) !== normalizeListFormat(existing)) return null;
383
- }
384
-
385
- const clonedNodes = replacementNodes.map(node => (node?.cloneNode ? node.cloneNode(true) : node));
386
- for (const node of clonedNodes) {
387
- if (isTrackedDeletedParagraph(node)) continue;
388
- for (const numIdNode of paragraphNumIdNodes(node)) setElementVal(numIdNode, sourceNumId);
389
- }
390
- return { numId: sourceNumId, replacementNodes: clonedNodes };
391
- }
392
-
393
- /**
394
- * Remaps incoming numbering payload IDs to document-safe IDs, and updates the
395
- * provided replacement nodes to reference the remapped `w:numId` values.
396
- *
397
- * @param {string} numberingXml
398
- * @param {Node[]} replacementNodes
399
- * @param {ReturnType<typeof createDynamicNumberingIdState>} numberingIdState
400
- * @returns {{ numberingXml: string, replacementNodes: Node[] }}
401
- */
402
- export function remapNumberingPayloadForDocument(numberingXml, replacementNodes, numberingIdState) {
403
- const numberingDoc = parseOoxml(numberingXml);
404
- if (hasXmlParseError(numberingDoc)) {
405
- return {
406
- numberingXml: String(numberingXml || ''),
407
- replacementNodes: Array.isArray(replacementNodes)
408
- ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
409
- : []
410
- };
411
- }
412
-
413
- const abstractNumMap = new Map();
414
- const numIdMap = new Map();
415
-
416
- const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
417
- for (const abstractNum of abstractNums) {
418
- const oldId = getElementId(abstractNum, ['w:abstractNumId', 'abstractNumId']);
419
- if (oldId == null) continue;
420
- const newId = reserveNextNumberingId(numberingIdState, 'abstract');
421
- if (newId == null) continue;
422
- abstractNumMap.set(oldId, newId);
423
- setElementId(abstractNum, 'w:abstractNumId', newId);
424
- }
425
-
426
- const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
427
- for (const num of nums) {
428
- const oldNumId = getElementId(num, ['w:numId', 'numId']);
429
- if (oldNumId == null) continue;
430
- const newNumId = reserveNextNumberingId(numberingIdState, 'num');
431
- if (newNumId == null) continue;
432
- numIdMap.set(oldNumId, newNumId);
433
- setElementId(num, 'w:numId', newNumId);
434
-
435
- const abstractNumIdNode = Array.from(num.getElementsByTagNameNS('*', 'abstractNumId'))[0] || null;
436
- if (abstractNumIdNode) {
437
- const oldAbsRef = getElementId(abstractNumIdNode, ['w:val', 'val']);
438
- if (oldAbsRef != null && abstractNumMap.has(oldAbsRef)) {
439
- setElementVal(abstractNumIdNode, abstractNumMap.get(oldAbsRef));
440
- }
441
- }
442
- }
443
-
444
- const clonedNodes = Array.isArray(replacementNodes)
445
- ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
446
- : [];
447
- for (const node of clonedNodes) {
448
- // A paragraph whose mark is tracked-deleted is retained source content. Its numId refers to the
449
- // document's own definitions, never to this payload, so it must keep pointing at them.
450
- if (isTrackedDeletedParagraph(node)) continue;
451
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
452
- for (const numIdNode of numIdNodes) {
453
- const oldNumRef = getElementId(numIdNode, ['w:val', 'val']);
454
- if (oldNumRef != null && numIdMap.has(oldNumRef)) {
455
- setElementVal(numIdNode, numIdMap.get(oldNumRef));
456
- }
457
- }
458
- }
459
-
460
- return {
461
- numberingXml: serializeOoxml(numberingDoc),
462
- replacementNodes: clonedNodes
463
- };
464
- }
465
-
466
- /**
467
- * Merges incoming numbering definitions into an existing numbering part while
468
- * preserving schema child order (`abstractNum*` before `num*`).
469
- *
470
- * @param {string} existingNumberingXml
471
- * @param {string} incomingNumberingXml
472
- * @returns {string}
473
- */
474
- export function mergeNumberingXmlBySchemaOrder(existingNumberingXml, incomingNumberingXml) {
475
- const existingText = String(existingNumberingXml || '');
476
- const incomingText = String(incomingNumberingXml || '');
477
- if (!incomingText.trim()) return existingText;
478
- if (!existingText.trim()) return incomingText;
479
-
480
- try {
481
- const existingDoc = parseOoxml(existingText);
482
- const incomingDoc = parseOoxml(incomingText);
483
- if (hasXmlParseError(existingDoc) || hasXmlParseError(incomingDoc)) {
484
- return existingText;
485
- }
486
-
487
- const existingRoot = existingDoc.documentElement;
488
- const incomingRoot = incomingDoc.documentElement;
489
- if (!existingRoot || !incomingRoot) return existingText;
490
-
491
- const existingAbstractIds = new Set(
492
- Array.from(existingRoot.childNodes || [])
493
- .filter(node => isDirectWordChild(node, 'abstractNum'))
494
- .map(node => parseIntegerAttribute(node, ['w:abstractNumId', 'abstractNumId']))
495
- .filter(id => id != null)
496
- );
497
- const existingNumIds = new Set(
498
- Array.from(existingRoot.childNodes || [])
499
- .filter(node => isDirectWordChild(node, 'num'))
500
- .map(node => parseIntegerAttribute(node, ['w:numId', 'numId']))
501
- .filter(id => id != null)
502
- );
503
-
504
- for (const incomingNode of Array.from(incomingRoot.childNodes || [])) {
505
- if (!isDirectWordChild(incomingNode, 'abstractNum')) continue;
506
- const incomingId = parseIntegerAttribute(incomingNode, ['w:abstractNumId', 'abstractNumId']);
507
- if (incomingId == null || existingAbstractIds.has(incomingId)) continue;
508
- insertNumberingNodeInSchemaOrder(existingRoot, existingDoc.importNode(incomingNode, true), 'abstract');
509
- existingAbstractIds.add(incomingId);
510
- }
511
-
512
- for (const incomingNode of Array.from(incomingRoot.childNodes || [])) {
513
- if (!isDirectWordChild(incomingNode, 'num')) continue;
514
- const incomingId = parseIntegerAttribute(incomingNode, ['w:numId', 'numId']);
515
- if (incomingId == null || existingNumIds.has(incomingId)) continue;
516
- insertNumberingNodeInSchemaOrder(existingRoot, existingDoc.importNode(incomingNode, true), 'num');
517
- existingNumIds.add(incomingId);
518
- }
519
-
520
- return serializeOoxml(existingDoc);
521
- } catch {
522
- return existingText;
523
- }
524
- }
1
+ import { parseOoxml, serializeOoxml } from '../engine/oxml-engine.js';
2
+ import { WORD_MAIN_NS } from '../core/paragraph-targeting.js';
3
+ import { getAllReferencedNumIdElements, getCurrentNumIdElements } from '../core/current-properties.js';
4
+
5
+ function parseIntegerAttribute(element, names) {
6
+ if (!element || !Array.isArray(names)) return null;
7
+ for (const name of names) {
8
+ const raw = element.getAttribute(name);
9
+ if (raw == null || raw === '') continue;
10
+ const parsed = Number.parseInt(String(raw), 10);
11
+ if (Number.isFinite(parsed)) return parsed;
12
+ }
13
+ return null;
14
+ }
15
+
16
+ function nextAvailableId(startId, occupiedIds, maxPreferred = null) {
17
+ let candidate = Number.isInteger(startId) && startId > 0 ? startId : 1;
18
+ const occupied = occupiedIds instanceof Set ? occupiedIds : new Set();
19
+
20
+ while (occupied.has(candidate)) {
21
+ candidate += 1;
22
+ }
23
+
24
+ if (Number.isInteger(maxPreferred) && maxPreferred > 0 && candidate > maxPreferred) {
25
+ for (let probe = 1; probe <= maxPreferred; probe += 1) {
26
+ if (!occupied.has(probe)) return probe;
27
+ }
28
+ }
29
+
30
+ return candidate;
31
+ }
32
+
33
+ /**
34
+ * Builds a dynamic numbering-id state from existing numbering XML.
35
+ *
36
+ * This avoids hardcoded ID floors and keeps IDs deterministic relative to the
37
+ * current document numbering definitions.
38
+ *
39
+ * @param {string} numberingXml - Existing `word/numbering.xml` content
40
+ * @param {{
41
+ * minId?: number,
42
+ * maxPreferred?: number
43
+ * }} [options={}] - Optional ID preferences
44
+ * @returns {{
45
+ * nextNumId: number,
46
+ * nextAbstractNumId: number,
47
+ * usedNumIds: Set<number>,
48
+ * usedAbstractNumIds: Set<number>,
49
+ * minId: number,
50
+ * maxPreferred: number
51
+ * }}
52
+ */
53
+ export function createDynamicNumberingIdState(numberingXml, options = {}) {
54
+ const minId = Number.isInteger(options?.minId) && options.minId > 0 ? options.minId : 1;
55
+ const maxPreferred = Number.isInteger(options?.maxPreferred) && options.maxPreferred >= minId
56
+ ? options.maxPreferred
57
+ : 32767;
58
+
59
+ const usedNumIds = new Set();
60
+ const usedAbstractNumIds = new Set();
61
+
62
+ if (String(numberingXml || '').trim()) {
63
+ try {
64
+ const numberingDoc = parseOoxml(numberingXml);
65
+ const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
66
+ const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
67
+
68
+ for (const abstractNum of abstractNums) {
69
+ const id = parseIntegerAttribute(abstractNum, ['w:abstractNumId', 'abstractNumId']);
70
+ if (id != null) usedAbstractNumIds.add(id);
71
+ }
72
+ for (const num of nums) {
73
+ const id = parseIntegerAttribute(num, ['w:numId', 'numId']);
74
+ if (id != null) usedNumIds.add(id);
75
+ }
76
+ } catch {
77
+ // Ignore malformed numbering XML and fall back to empty sets.
78
+ }
79
+ }
80
+
81
+ const maxUsedNumId = usedNumIds.size > 0 ? Math.max(...usedNumIds) : 0;
82
+ const maxUsedAbstractNumId = usedAbstractNumIds.size > 0 ? Math.max(...usedAbstractNumIds) : 0;
83
+ const baseNumId = Math.max(minId, maxUsedNumId + 1);
84
+ const baseAbstractNumId = Math.max(minId, maxUsedAbstractNumId + 1);
85
+
86
+ return {
87
+ // Kept so list edits can recognise which existing definition a source list uses and continue it.
88
+ sourceNumberingXml: String(numberingXml || ''),
89
+ nextNumId: nextAvailableId(baseNumId, usedNumIds, maxPreferred),
90
+ nextAbstractNumId: nextAvailableId(baseAbstractNumId, usedAbstractNumIds, maxPreferred),
91
+ usedNumIds,
92
+ usedAbstractNumIds,
93
+ minId,
94
+ maxPreferred
95
+ };
96
+ }
97
+
98
+ function normalizeNumberingIdState(state) {
99
+ if (!state || typeof state !== 'object') return null;
100
+
101
+ if (!(state.usedNumIds instanceof Set)) state.usedNumIds = new Set();
102
+ if (!(state.usedAbstractNumIds instanceof Set)) state.usedAbstractNumIds = new Set();
103
+
104
+ if (!Number.isInteger(state.minId) || state.minId < 1) {
105
+ state.minId = 1;
106
+ }
107
+ if (!Number.isInteger(state.maxPreferred) || state.maxPreferred < state.minId) {
108
+ state.maxPreferred = 32767;
109
+ }
110
+
111
+ if (!Number.isInteger(state.nextNumId) || state.nextNumId < state.minId) {
112
+ state.nextNumId = state.minId;
113
+ }
114
+ if (!Number.isInteger(state.nextAbstractNumId) || state.nextAbstractNumId < state.minId) {
115
+ state.nextAbstractNumId = state.minId;
116
+ }
117
+
118
+ state.nextNumId = nextAvailableId(state.nextNumId, state.usedNumIds, state.maxPreferred);
119
+ state.nextAbstractNumId = nextAvailableId(state.nextAbstractNumId, state.usedAbstractNumIds, state.maxPreferred);
120
+ return state;
121
+ }
122
+
123
+ /**
124
+ * Reserves the next available ID from a mutable numbering-id state.
125
+ *
126
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} state
127
+ * @param {'num'|'abstract'} [kind='num']
128
+ * @returns {number|null}
129
+ */
130
+ export function reserveNextNumberingId(state, kind = 'num') {
131
+ const normalized = normalizeNumberingIdState(state);
132
+ if (!normalized) return null;
133
+
134
+ const useAbstract = kind === 'abstract';
135
+ const id = useAbstract ? normalized.nextAbstractNumId : normalized.nextNumId;
136
+ if (!Number.isInteger(id) || id < 1) return null;
137
+
138
+ if (useAbstract) {
139
+ normalized.usedAbstractNumIds.add(id);
140
+ normalized.nextAbstractNumId = nextAvailableId(id + 1, normalized.usedAbstractNumIds, normalized.maxPreferred);
141
+ } else {
142
+ normalized.usedNumIds.add(id);
143
+ normalized.nextNumId = nextAvailableId(id + 1, normalized.usedNumIds, normalized.maxPreferred);
144
+ }
145
+
146
+ return id;
147
+ }
148
+
149
+ /**
150
+ * Reserves the next available numbering IDs on a mutable numbering-id state.
151
+ *
152
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} state
153
+ * @returns {{ numId: number, abstractNumId: number } | null}
154
+ */
155
+ export function reserveNextNumberingIdPair(state) {
156
+ const numId = reserveNextNumberingId(state, 'num');
157
+ const abstractNumId = reserveNextNumberingId(state, 'abstract');
158
+ if (numId == null || abstractNumId == null) return null;
159
+
160
+ return { numId, abstractNumId };
161
+ }
162
+
163
+ export function hasXmlParseError(doc) {
164
+ if (!doc || !doc.documentElement) return true;
165
+ if (doc.documentElement.localName === 'parsererror') return true;
166
+ return doc.getElementsByTagName('parsererror').length > 0;
167
+ }
168
+
169
+ export function isDirectWordChild(node, localName) {
170
+ return !!(
171
+ node &&
172
+ node.nodeType === 1 &&
173
+ node.namespaceURI === WORD_MAIN_NS &&
174
+ node.localName === localName
175
+ );
176
+ }
177
+
178
+ function getAttributeFirst(element, names) {
179
+ for (const name of names || []) {
180
+ const value = element?.getAttribute?.(name);
181
+ if (value != null && value !== '') return value;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ export function getElementId(element, names) {
187
+ const raw = getAttributeFirst(element, names);
188
+ const parsed = Number.parseInt(String(raw || ''), 10);
189
+ return Number.isFinite(parsed) ? parsed : null;
190
+ }
191
+
192
+ function setElementId(element, preferredName, idValue) {
193
+ element?.setAttribute?.(preferredName, String(idValue));
194
+ }
195
+
196
+ function setElementVal(element, value) {
197
+ element?.setAttribute?.('w:val', String(value));
198
+ }
199
+
200
+ /**
201
+ * Overwrites all paragraph-level `w:numId` references in a node collection.
202
+ *
203
+ * @param {Node[]|null|undefined} paragraphNodes
204
+ * @param {string|number|null|undefined} targetNumId
205
+ */
206
+ export function overwriteParagraphNumIds(paragraphNodes, targetNumId) {
207
+ if (!Array.isArray(paragraphNodes) || targetNumId == null) return;
208
+ for (const node of paragraphNodes) {
209
+ // R1: retained (tracked-deleted) source paragraphs keep the binding Reject All must restore.
210
+ if (isTrackedDeletedParagraph(node)) continue;
211
+ // Current numIds only: a w:pPrChange snapshot keeps its numId so Reject restores the original binding.
212
+ for (const numIdNode of getCurrentNumIdElements(node)) {
213
+ setElementVal(numIdNode, targetNumId);
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Extracts the first `w:numId` value from a node collection.
220
+ *
221
+ * @param {Node[]|null|undefined} paragraphNodes
222
+ * @returns {string|null}
223
+ */
224
+ export function extractFirstParagraphNumId(paragraphNodes) {
225
+ for (const node of paragraphNodes || []) {
226
+ for (const numIdNode of getCurrentNumIdElements(node)) {
227
+ const numId = getElementId(numIdNode, ['w:val', 'val']);
228
+ if (numId != null) return String(numId);
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Builds multilevel decimal numbering XML for explicit-start header conversion.
236
+ *
237
+ * @param {string|number} numId
238
+ * @param {string|number} abstractNumId
239
+ * @param {number} startAt
240
+ * @returns {string}
241
+ */
242
+ export function buildExplicitDecimalMultilevelNumberingXml(numId, abstractNumId, startAt) {
243
+ const safeNumId = String(numId);
244
+ const safeAbstractNumId = String(abstractNumId);
245
+ const safeStartAt = Number.isInteger(startAt) && startAt > 0 ? startAt : 1;
246
+ const levelsXml = Array.from({ length: 9 }, (_, level) => {
247
+ const lvlText = Array.from({ length: level + 1 }, (_, i) => `%${i + 1}`).join('.') + '.';
248
+ const left = 720 * (level + 1);
249
+ return `
250
+ <w:lvl w:ilvl="${level}">
251
+ <w:start w:val="1"/>
252
+ <w:numFmt w:val="decimal"/>
253
+ <w:lvlText w:val="${lvlText}"/>
254
+ <w:lvlJc w:val="left"/>
255
+ <w:pPr><w:ind w:left="${left}" w:hanging="360"/></w:pPr>
256
+ </w:lvl>`;
257
+ }).join('');
258
+ return `
259
+ <w:numbering xmlns:w="${WORD_MAIN_NS}">
260
+ <w:abstractNum w:abstractNumId="${safeAbstractNumId}">
261
+ <w:multiLevelType w:val="multilevel"/>
262
+ ${levelsXml}
263
+ </w:abstractNum>
264
+ <w:num w:numId="${safeNumId}">
265
+ <w:abstractNumId w:val="${safeAbstractNumId}"/>
266
+ <w:lvlOverride w:ilvl="0">
267
+ <w:startOverride w:val="${safeStartAt}"/>
268
+ </w:lvlOverride>
269
+ </w:num>
270
+ </w:numbering>`.trim();
271
+ }
272
+
273
+ /** A paragraph whose mark is tracked-deleted is retained source content (R1: its numPr is never rebound). */
274
+ export function isTrackedDeletedParagraph(node) {
275
+ if (!node || node.nodeType !== 1 || node.localName !== 'p') return false;
276
+ const pPr = Array.from(node.childNodes || []).find(child => isDirectWordChild(child, 'pPr'));
277
+ const rPr = pPr && Array.from(pPr.childNodes || []).find(child => isDirectWordChild(child, 'rPr'));
278
+ return !!rPr && Array.from(rPr.childNodes || []).some(child => isDirectWordChild(child, 'del'));
279
+ }
280
+
281
+ /**
282
+ * Remaps incoming numbering payload IDs to document-safe IDs, and updates the
283
+ * provided replacement nodes to reference the remapped `w:numId` values.
284
+ *
285
+ * @param {string} numberingXml
286
+ * @param {Node[]} replacementNodes
287
+ * @param {ReturnType<typeof createDynamicNumberingIdState>} numberingIdState
288
+ * @returns {{ numberingXml: string, replacementNodes: Node[] }}
289
+ */
290
+ export function remapNumberingPayloadForDocument(numberingXml, replacementNodes, numberingIdState) {
291
+ const numberingDoc = parseOoxml(numberingXml);
292
+ if (hasXmlParseError(numberingDoc)) {
293
+ return {
294
+ numberingXml: String(numberingXml || ''),
295
+ replacementNodes: Array.isArray(replacementNodes)
296
+ ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
297
+ : []
298
+ };
299
+ }
300
+
301
+ const abstractNumMap = new Map();
302
+ const numIdMap = new Map();
303
+
304
+ const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
305
+ for (const abstractNum of abstractNums) {
306
+ const oldId = getElementId(abstractNum, ['w:abstractNumId', 'abstractNumId']);
307
+ if (oldId == null) continue;
308
+ const newId = reserveNextNumberingId(numberingIdState, 'abstract');
309
+ if (newId == null) continue;
310
+ abstractNumMap.set(oldId, newId);
311
+ setElementId(abstractNum, 'w:abstractNumId', newId);
312
+ }
313
+
314
+ const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
315
+ for (const num of nums) {
316
+ const oldNumId = getElementId(num, ['w:numId', 'numId']);
317
+ if (oldNumId == null) continue;
318
+ const newNumId = reserveNextNumberingId(numberingIdState, 'num');
319
+ if (newNumId == null) continue;
320
+ numIdMap.set(oldNumId, newNumId);
321
+ setElementId(num, 'w:numId', newNumId);
322
+
323
+ const abstractNumIdNode = Array.from(num.getElementsByTagNameNS('*', 'abstractNumId'))[0] || null;
324
+ if (abstractNumIdNode) {
325
+ const oldAbsRef = getElementId(abstractNumIdNode, ['w:val', 'val']);
326
+ if (oldAbsRef != null && abstractNumMap.has(oldAbsRef)) {
327
+ setElementVal(abstractNumIdNode, abstractNumMap.get(oldAbsRef));
328
+ }
329
+ }
330
+ }
331
+
332
+ const clonedNodes = Array.isArray(replacementNodes)
333
+ ? replacementNodes.map(node => node?.cloneNode ? node.cloneNode(true) : node)
334
+ : [];
335
+ for (const node of clonedNodes) {
336
+ // A paragraph whose mark is tracked-deleted is retained source content. Its numId refers to the
337
+ // document's own definitions, never to this payload, so it must keep pointing at them.
338
+ if (isTrackedDeletedParagraph(node)) continue;
339
+ // Deliberately historical-inclusive: every numId in the payload (including pPrChange snapshots)
340
+ // refers to the payload's numbering part, so all of them must follow the remap or Reject would
341
+ // restore a binding to a definition that no longer exists.
342
+ for (const numIdNode of getAllReferencedNumIdElements(node)) {
343
+ const oldNumRef = getElementId(numIdNode, ['w:val', 'val']);
344
+ if (oldNumRef != null && numIdMap.has(oldNumRef)) {
345
+ setElementVal(numIdNode, numIdMap.get(oldNumRef));
346
+ }
347
+ }
348
+ }
349
+
350
+ return {
351
+ numberingXml: serializeOoxml(numberingDoc),
352
+ replacementNodes: clonedNodes
353
+ };
354
+ }