@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* @param {
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!(state.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
state.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* @param {
|
|
127
|
-
* @
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
normalized.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
normalized.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* @
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (doc.documentElement
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
node
|
|
172
|
-
node.
|
|
173
|
-
node.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* @param {Node[]|null|undefined} paragraphNodes
|
|
222
|
-
* @
|
|
223
|
-
*/
|
|
224
|
-
export function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* @param {
|
|
238
|
-
* @
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|