@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,631 +1,627 @@
1
- /**
2
- * Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
3
- */
4
-
5
- import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
- import { warn as logWarning } from '../adapters/logger.js';
7
- import { commentParaId } from './comment-thread-parts.js';
8
- import { correctedContentTypeFor, getPartSpec } from './package-parts.js';
9
-
10
- const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
11
- const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
12
- const NS_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
13
- const NUMBERING = getPartSpec('numbering');
14
- const COMMENTS = getPartSpec('comments');
15
- const COMMENTS_EXTENDED = getPartSpec('commentsExtended');
16
- const NUMBERING_REL_TYPE = NUMBERING.relType;
17
- const NUMBERING_CONTENT_TYPE = NUMBERING.contentType;
18
- const COMMENTS_REL_TYPE = COMMENTS.relType;
19
- const COMMENTS_CONTENT_TYPE = COMMENTS.contentType;
20
- const COMMENTS_EXTENDED_REL_TYPE = COMMENTS_EXTENDED.relType;
21
- // Word's own value. The old 'application/vnd.ms-word.commentsExtended+xml' label is a registered legacy value.
22
- const COMMENTS_EXTENDED_CONTENT_TYPE = COMMENTS_EXTENDED.contentType;
23
-
24
- const DOCUMENT_PATH = getPartSpec('document').path;
25
- const NUMBERING_PATH = NUMBERING.path;
26
- const COMMENTS_PATH = COMMENTS.path;
27
- const COMMENTS_EXTENDED_PATH = COMMENTS_EXTENDED.path;
28
- const CONTENT_TYPES_PATH = '[Content_Types].xml';
29
- const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
30
-
31
- export function parseXmlStrictStandalone(xmlText, label = 'xml') {
32
- const parsed = parseOoxmlSafe(xmlText, 'application/xml');
33
- if (parsed.error || !parsed.doc) {
34
- const error = new Error(`[XML parse error] ${label}: ${parsed.error?.message || 'Unknown'}`);
35
- error.code = 'PARSE_ERROR';
36
- throw error;
37
- }
38
- return parsed.doc;
39
- }
40
-
41
- function isSectionPropertiesElement(node) {
42
- return !!node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'sectPr';
43
- }
44
-
45
- export function getBodyElementFromDocument(xmlDoc) {
46
- return xmlDoc.getElementsByTagNameNS('*', 'body')[0] || null;
47
- }
48
-
49
- function getDirectSectionProperties(body) {
50
- for (const child of Array.from(body.childNodes || [])) {
51
- if (isSectionPropertiesElement(child)) return child;
52
- }
53
- return null;
54
- }
55
-
56
- export function insertBodyElementBeforeSectPr(body, element) {
57
- const sectPr = getDirectSectionProperties(body);
58
- if (sectPr) {
59
- body.insertBefore(element, sectPr);
60
- } else {
61
- body.appendChild(element);
62
- }
63
- }
64
-
65
- export function normalizeBodySectionOrderStandalone(xmlDoc) {
66
- const body = getBodyElementFromDocument(xmlDoc);
67
- if (!body) return;
68
- const sectPr = getDirectSectionProperties(body);
69
- if (!sectPr) return;
70
- let cursor = sectPr.nextSibling;
71
- while (cursor) {
72
- const next = cursor.nextSibling;
73
- if (cursor.nodeType === 1) {
74
- body.insertBefore(cursor, sectPr);
75
- }
76
- cursor = next;
77
- }
78
- }
79
-
80
- /**
81
- * Flattens nested table-cell paragraphs (`w:tc > w:p > w:p`) by promoting
82
- * inner paragraphs to direct `w:tc` children.
83
- *
84
- * @param {Document} xmlDoc
85
- * @param {{ onInfo?: (message: string) => void }} [options]
86
- * @returns {number} number of nested paragraphs fixed
87
- */
88
- export function sanitizeNestedParagraphsInTables(xmlDoc, options = {}) {
89
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
90
- const tcs = xmlDoc.getElementsByTagNameNS(NS_W, 'tc');
91
- let fixed = 0;
92
- for (const tc of Array.from(tcs)) {
93
- const outerParagraphs = Array.from(tc.childNodes || []).filter(
94
- node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
95
- );
96
- for (const outerParagraph of outerParagraphs) {
97
- const innerParagraphs = Array.from(outerParagraph.childNodes || []).filter(
98
- node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
99
- );
100
- for (const innerParagraph of innerParagraphs) {
101
- tc.insertBefore(innerParagraph, outerParagraph);
102
- fixed += 1;
103
- }
104
- }
105
- }
106
- if (fixed > 0) {
107
- onInfo(`[Sanitize] Fixed ${fixed} nested w:p element(s) in table cells`);
108
- }
109
- return fixed;
110
- }
111
-
112
- export function getPackagePartName(partElement) {
113
- return partElement.getAttribute('pkg:name') || partElement.getAttribute('name') || '';
114
- }
115
-
116
- function extractFromPackageXml(packageXml) {
117
- const serializer = createSerializer();
118
- const pkgDoc = parseXmlStrictStandalone(packageXml, 'package OOXML');
119
- const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
120
- const documentPart = parts.find(part => getPackagePartName(part) === '/word/document.xml');
121
- if (!documentPart) {
122
- throw new Error('Package output missing /word/document.xml part');
123
- }
124
-
125
- const xmlData = documentPart.getElementsByTagNameNS('*', 'xmlData')[0];
126
- if (!xmlData) {
127
- throw new Error('Package document part missing pkg:xmlData');
128
- }
129
- const documentNode = Array.from(xmlData.childNodes || []).find(node => node.nodeType === 1);
130
- if (!documentNode) {
131
- throw new Error('Package document part missing XML payload');
132
- }
133
-
134
- const body = documentNode.getElementsByTagNameNS('*', 'body')[0];
135
- const replacementNodes = body
136
- ? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
137
- : [documentNode];
138
-
139
- const numberingPart = parts.find(part => getPackagePartName(part) === '/word/numbering.xml');
140
- let numberingXml = null;
141
- if (numberingPart) {
142
- const numberingXmlData = numberingPart.getElementsByTagNameNS('*', 'xmlData')[0];
143
- const numberingNode = numberingXmlData
144
- ? Array.from(numberingXmlData.childNodes || []).find(node => node.nodeType === 1)
145
- : null;
146
- if (numberingNode) {
147
- numberingXml = serializer.serializeToString(numberingNode);
148
- }
149
- }
150
-
151
- return {
152
- replacementNodes,
153
- numberingXml,
154
- sourceType: 'package'
155
- };
156
- }
157
-
158
- /**
159
- * Extracts replacement nodes and optional numbering payload from engine output.
160
- *
161
- * @param {string} outputOxml
162
- * @returns {{ replacementNodes: Element[], numberingXml: string|null, sourceType: 'package'|'document'|'fragment' }}
163
- */
164
- export function extractReplacementNodesFromOoxml(outputOxml) {
165
- if (typeof outputOxml !== 'string' || !outputOxml.trim()) {
166
- return {
167
- replacementNodes: [],
168
- numberingXml: null,
169
- sourceType: 'fragment',
170
- status: 'error',
171
- error: { code: 'PARSE_ERROR', message: 'Reconciliation engine returned no OOXML payload for this operation' }
172
- };
173
- }
174
-
175
- try {
176
- if (outputOxml.includes('<pkg:package')) {
177
- return extractFromPackageXml(outputOxml);
178
- }
179
-
180
- if (outputOxml.includes('<w:document')) {
181
- const doc = parseXmlStrictStandalone(outputOxml, 'document OOXML');
182
- const body = doc.getElementsByTagNameNS('*', 'body')[0];
183
- const replacementNodes = body
184
- ? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
185
- : Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
186
- return { replacementNodes, numberingXml: null, sourceType: 'document' };
187
- }
188
-
189
- const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
190
- const fragmentDoc = parseXmlStrictStandalone(wrapped, 'OOXML fragment');
191
- const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
192
- return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
193
- } catch (caught) {
194
- return {
195
- replacementNodes: [],
196
- numberingXml: null,
197
- sourceType: 'fragment',
198
- status: 'error',
199
- error: { code: 'PARSE_ERROR', message: caught?.message || 'Could not parse OOXML payload.' }
200
- };
201
- }
202
- }
203
-
204
- function upsertContentTypeOverride(ctDoc, partName, contentType) {
205
- const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
206
- const existingOverride = overrides.find(
207
- override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
208
- );
209
- if (existingOverride) {
210
- if ((existingOverride.getAttribute('ContentType') || '') === contentType) return false;
211
- existingOverride.setAttribute('ContentType', contentType);
212
- return true;
213
- }
214
-
215
- const override = ctDoc.createElementNS(NS_CT, 'Override');
216
- override.setAttribute('PartName', partName);
217
- override.setAttribute('ContentType', contentType);
218
- ctDoc.documentElement.appendChild(override);
219
- return true;
220
- }
221
-
222
- function upsertDocumentRelationship(relsDoc, relType, target, options = {}) {
223
- const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
224
- const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
225
- const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
226
- if (hasRel) return false;
227
-
228
- let maxId = 0;
229
- for (const rel of rels) {
230
- const idValue = rel.getAttribute('Id') || '';
231
- const idNum = Number.parseInt(idValue.replace(/^rId/i, ''), 10);
232
- if (Number.isFinite(idNum)) {
233
- maxId = Math.max(maxId, idNum);
234
- }
235
- }
236
-
237
- const newId = `rId${maxId + 1}`;
238
- const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
239
- rel.setAttribute('Id', newId);
240
- rel.setAttribute('Type', relType);
241
- rel.setAttribute('Target', target);
242
- relsRoot.appendChild(rel);
243
- if (options?._receiptCollector) {
244
- options._receiptCollector.recordRelationship(newId);
245
- } else if (options?._documentOperationSession?.receiptCollector) {
246
- options._documentOperationSession.receiptCollector.recordRelationship(newId);
247
- }
248
- return true;
249
- }
250
-
251
- async function readZipText(zip, filePath) {
252
- const entry = zip.file(filePath);
253
- if (!entry) return null;
254
- return entry.async('string');
255
- }
256
-
257
- /**
258
- * Ensures numbering part + package metadata exist and merges numbering payloads.
259
- *
260
- * @param {any} zip
261
- * @param {string|string[]|null|undefined} numberingXmlList
262
- * @param {{
263
- * mergeNumberingXml?: ((existingXml: string, incomingXml: string) => string),
264
- * onInfo?: (message: string) => void
265
- * }} [options]
266
- */
267
- export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, options = {}) {
268
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
269
- const mergeNumberingXml = typeof options?.mergeNumberingXml === 'function'
270
- ? options.mergeNumberingXml
271
- : null;
272
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : logWarning;
273
- const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
274
- if (incomingPayloads.length === 0) return;
275
-
276
- const existing = await readZipText(zip, NUMBERING_PATH);
277
- if (existing && !mergeNumberingXml) {
278
- onWarn('[Deprecation] Replacing an existing numbering.xml without mergeNumberingXml is deprecated and will throw in the next major version.');
279
- }
280
- let mergedNumberingXml = existing || null;
281
- for (const incomingNumberingXml of incomingPayloads) {
282
- if (!mergedNumberingXml) {
283
- mergedNumberingXml = incomingNumberingXml;
284
- continue;
285
- }
286
- mergedNumberingXml = mergeNumberingXml
287
- ? mergeNumberingXml(mergedNumberingXml, incomingNumberingXml)
288
- : incomingNumberingXml;
289
- }
290
-
291
- if (!existing) {
292
- onInfo('[Demo] Adding numbering.xml');
293
- } else {
294
- onInfo('[Demo] Merging numbering.xml payload(s) into existing numbering definitions');
295
- }
296
- zip.file(NUMBERING_PATH, mergedNumberingXml);
297
-
298
- const serializer = createSerializer();
299
-
300
- const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
301
- if (ctText) {
302
- const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
303
- if (upsertContentTypeOverride(ctDoc, '/word/numbering.xml', NUMBERING_CONTENT_TYPE)) {
304
- zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
305
- }
306
- }
307
-
308
- const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
309
- if (relsText) {
310
- const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
311
- if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml', options)) {
312
- zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
313
- }
314
- }
315
- }
316
-
317
- /**
318
- * Ensures comments part + package metadata exist and merges incoming comments.
319
- *
320
- * @param {any} zip
321
- * @param {string|null|undefined} commentsXml
322
- * @param {{ onInfo?: (message: string) => void }} [options]
323
- */
324
- export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
325
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
326
- if (!commentsXml) return;
327
-
328
- const serializer = createSerializer();
329
- const existingText = await readZipText(zip, COMMENTS_PATH);
330
- if (!existingText || options.replaceExisting === true) {
331
- onInfo('[Demo] Adding comments.xml');
332
- zip.file(COMMENTS_PATH, commentsXml);
333
- } else {
334
- const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
335
- const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
336
- const existingRoot = existingDoc.documentElement;
337
- const existingIds = new Set(
338
- Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
339
- .map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
340
- .filter(Boolean)
341
- );
342
- for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
343
- const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
344
- if (id && existingIds.has(id)) {
345
- throw new Error(`Duplicate comment id: ${id}`);
346
- }
347
- existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
348
- }
349
- zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
350
- }
351
-
352
- const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
353
- if (ctText) {
354
- const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
355
- if (upsertContentTypeOverride(ctDoc, '/word/comments.xml', COMMENTS_CONTENT_TYPE)) {
356
- zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
357
- }
358
- }
359
-
360
- const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
361
- if (relsText) {
362
- const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
363
- if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml', options)) {
364
- zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
365
- }
366
- }
367
- }
368
-
369
- /** Ensures the modern Word comment-threading part and package metadata exist. */
370
- export async function ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml, options = {}) {
371
- if (!commentsExtendedXml) return;
372
- parseXmlStrictStandalone(commentsExtendedXml, 'word/commentsExtended.xml');
373
- zip.file(COMMENTS_EXTENDED_PATH, commentsExtendedXml);
374
- const serializer = createSerializer();
375
- const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
376
- if (ctText) {
377
- const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
378
- if (upsertContentTypeOverride(ctDoc, '/word/commentsExtended.xml', COMMENTS_EXTENDED_CONTENT_TYPE)) {
379
- zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
380
- }
381
- }
382
- const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
383
- if (relsText) {
384
- const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
385
- if (upsertDocumentRelationship(relsDoc, COMMENTS_EXTENDED_REL_TYPE, 'commentsExtended.xml', options)) {
386
- zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
387
- }
388
- }
389
- }
390
-
391
- /**
392
- * Rewrites content-type overrides for known parts whose type is wrong (for example the
393
- * `application/vnd.ms-word.commentsExtended+xml` value written by earlier versions of
394
- * this package). Does not rewrite [Content_Types].xml when there is nothing to repair.
395
- *
396
- * @param {any} zip
397
- * @returns {Promise<Array<{ partName: string, from: string, to: string }>>}
398
- */
399
- export async function repairKnownContentTypes(zip) {
400
- const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
401
- if (!ctText) return [];
402
- const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
403
- const repairs = [];
404
- for (const override of Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))) {
405
- const partName = override.getAttribute('PartName') || '';
406
- const actual = override.getAttribute('ContentType') || '';
407
- const expected = correctedContentTypeFor(partName, actual);
408
- if (!expected) continue;
409
- override.setAttribute('ContentType', expected);
410
- repairs.push({ partName, from: actual, to: expected });
411
- }
412
- if (repairs.length > 0) zip.file(CONTENT_TYPES_PATH, createSerializer().serializeToString(ctDoc));
413
- return repairs;
414
- }
415
-
416
- /**
417
- * Validates core package integrity for document/comments/numbering artifacts.
418
- *
419
- * @param {any} zip
420
- */
421
- export async function validateDocxPackage(zip) {
422
- const documentXml = await readZipText(zip, DOCUMENT_PATH);
423
- if (!documentXml) {
424
- throw new Error('Validation failed: missing word/document.xml');
425
- }
426
-
427
- const documentDoc = parseXmlStrictStandalone(documentXml, DOCUMENT_PATH);
428
- normalizeBodySectionOrderStandalone(documentDoc);
429
- const body = getBodyElementFromDocument(documentDoc);
430
- if (!body) {
431
- throw new Error('Validation failed: word/document.xml has no w:body');
432
- }
433
-
434
- const directBodyElements = Array.from(body.childNodes || []).filter(node => node.nodeType === 1);
435
- const sectPrIndexes = directBodyElements
436
- .map((node, index) => ({ node, index }))
437
- .filter(entry => isSectionPropertiesElement(entry.node))
438
- .map(entry => entry.index);
439
-
440
- if (sectPrIndexes.length > 1) {
441
- throw new Error('Validation failed: multiple body-level w:sectPr');
442
- }
443
- if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== directBodyElements.length - 1) {
444
- throw new Error('Validation failed: w:sectPr not last');
445
- }
446
-
447
- const tcs = documentDoc.getElementsByTagNameNS(NS_W, 'tc');
448
- for (const tc of Array.from(tcs)) {
449
- for (const child of Array.from(tc.childNodes || []).filter(node => node.nodeType === 1)) {
450
- if (child.namespaceURI === NS_W && child.localName === 'p') {
451
- const hasNestedParagraph = Array.from(child.childNodes || []).some(
452
- node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
453
- );
454
- if (hasNestedParagraph) {
455
- throw new Error('Validation failed: nested w:p');
456
- }
457
- }
458
- }
459
- }
460
-
461
- const hasNumberingUsage = documentDoc.getElementsByTagNameNS(NS_W, 'numPr').length > 0;
462
- const hasCommentUsage =
463
- documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeStart').length > 0
464
- || documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeEnd').length > 0
465
- || documentDoc.getElementsByTagNameNS(NS_W, 'commentReference').length > 0;
466
-
467
- const numberingXml = await readZipText(zip, NUMBERING_PATH);
468
- const commentsXml = await readZipText(zip, COMMENTS_PATH);
469
- const commentsExtendedXml = await readZipText(zip, COMMENTS_EXTENDED_PATH);
470
- if (commentsExtendedXml && !commentsXml) {
471
- throw new Error('Validation failed: commentsExtended part exists but comments part is missing');
472
- }
473
-
474
- if (numberingXml) {
475
- parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
476
- } else if (hasNumberingUsage) {
477
- throw new Error('Validation failed: numbering used but part missing');
478
- }
479
-
480
- if (commentsXml) {
481
- const commentsDoc = parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
482
- const idsFor = (doc, localName) => Array.from(doc.getElementsByTagNameNS(NS_W, localName))
483
- .map(node => node.getAttribute('w:id') || node.getAttribute('id'))
484
- .filter(id => id !== '');
485
- const starts = new Set(idsFor(documentDoc, 'commentRangeStart'));
486
- const ends = new Set(idsFor(documentDoc, 'commentRangeEnd'));
487
- const references = new Set(idsFor(documentDoc, 'commentReference'));
488
- const definitionIds = idsFor(commentsDoc, 'comment');
489
- const definitions = new Set(definitionIds);
490
- const sorted = ids => Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
491
- const difference = (left, right) => sorted(new Set(Array.from(left).filter(id => !right.has(id))));
492
-
493
- const duplicateDefinitions = sorted(new Set(definitionIds.filter((id, index) => definitionIds.indexOf(id) !== index)));
494
- if (duplicateDefinitions.length > 0) {
495
- throw new Error(`Validation failed: duplicate comment definitions for id(s): ${duplicateDefinitions.join(', ')}`);
496
- }
497
- const startsWithoutEnds = difference(starts, ends);
498
- const endsWithoutStarts = difference(ends, starts);
499
- if (startsWithoutEnds.length > 0 || endsWithoutStarts.length > 0) {
500
- const parts = [];
501
- if (startsWithoutEnds.length > 0) parts.push(`start without end: ${startsWithoutEnds.join(', ')}`);
502
- if (endsWithoutStarts.length > 0) parts.push(`end without start: ${endsWithoutStarts.join(', ')}`);
503
- throw new Error(`Validation failed: unbalanced comment range marker(s) (${parts.join('; ')})`);
504
- }
505
- const rangesWithoutReferences = difference(new Set([...starts, ...ends]), references);
506
- if (rangesWithoutReferences.length > 0) {
507
- throw new Error(`Validation failed: comment range has no reference for id(s): ${rangesWithoutReferences.join(', ')}`);
508
- }
509
- const usagesWithoutDefinitions = difference(new Set([...starts, ...ends, ...references]), definitions);
510
- if (usagesWithoutDefinitions.length > 0) {
511
- throw new Error(`Validation failed: comment usage has no definition for id(s): ${usagesWithoutDefinitions.join(', ')}`);
512
- }
513
- const replyCommentIds = new Set();
514
- if (commentsExtendedXml) {
515
- const extendedDoc = parseXmlStrictStandalone(commentsExtendedXml, COMMENTS_EXTENDED_PATH);
516
- const paraIdToCommentId = new Map();
517
- for (const comment of Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'))) {
518
- const id = comment.getAttribute('w:id') || comment.getAttribute('id');
519
- const paraId = commentParaId(comment);
520
- if (paraId) paraIdToCommentId.set(paraId, id);
521
- }
522
- const knownParaIds = new Set(paraIdToCommentId.keys());
523
- const extendedParaIds = new Set();
524
- for (const entry of Array.from(extendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
525
- const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId');
526
- const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent');
527
- if (!paraId || !knownParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: commentsExtended entry has no matching comment paragraph: ${paraId || '(missing)'}`);
528
- if (extendedParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: duplicate commentsExtended entry for paraId: ${paraId}`);
529
- extendedParaIds.add(paraId.toUpperCase());
530
- if (parentParaId) {
531
- if (!knownParaIds.has(parentParaId.toUpperCase())) throw new Error(`Validation failed: commentsExtended parent paragraph was not found: ${parentParaId}`);
532
- replyCommentIds.add(paraIdToCommentId.get(paraId.toUpperCase()));
533
- }
534
- }
535
- }
536
- const definitionsWithoutReferences = difference(new Set([...definitions].filter(id => !replyCommentIds.has(id))), references);
537
- if (definitionsWithoutReferences.length > 0) {
538
- throw new Error(`Validation failed: comment definition has no document reference for id(s): ${definitionsWithoutReferences.join(', ')}`);
539
- }
540
- } else if (hasCommentUsage) {
541
- throw new Error('Validation failed: comments used but part missing');
542
- }
543
-
544
- const ctXml = await readZipText(zip, CONTENT_TYPES_PATH);
545
- if (!ctXml) {
546
- throw new Error(`Validation failed: missing ${CONTENT_TYPES_PATH}`);
547
- }
548
- const ctDoc = parseXmlStrictStandalone(ctXml, CONTENT_TYPES_PATH);
549
-
550
- const relsXml = await readZipText(zip, DOCUMENT_RELS_PATH);
551
- if (!relsXml) {
552
- throw new Error(`Validation failed: missing ${DOCUMENT_RELS_PATH}`);
553
- }
554
- const relsDoc = parseXmlStrictStandalone(relsXml, DOCUMENT_RELS_PATH);
555
-
556
- if (numberingXml) {
557
- const hasNumberingContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
558
- (override.getAttribute('PartName') || '').toLowerCase() === '/word/numbering.xml'
559
- && (override.getAttribute('ContentType') || '') === NUMBERING_CONTENT_TYPE
560
- );
561
- const hasNumberingRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
562
- (rel.getAttribute('Type') || '') === NUMBERING_REL_TYPE
563
- );
564
- if (!hasNumberingContentType) {
565
- throw new Error('Validation failed: numbering CT override missing');
566
- }
567
- if (!hasNumberingRel) {
568
- throw new Error('Validation failed: numbering rel missing');
569
- }
570
- }
571
-
572
- if (commentsXml) {
573
- const hasCommentsContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
574
- (override.getAttribute('PartName') || '').toLowerCase() === '/word/comments.xml'
575
- && (override.getAttribute('ContentType') || '') === COMMENTS_CONTENT_TYPE
576
- );
577
- const hasCommentsRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
578
- (rel.getAttribute('Type') || '') === COMMENTS_REL_TYPE
579
- );
580
- if (!hasCommentsContentType) {
581
- throw new Error('Validation failed: comments CT override missing');
582
- }
583
- if (!hasCommentsRel) {
584
- throw new Error('Validation failed: comments rel missing');
585
- }
586
- }
587
-
588
- // Header/footer wiring: every w:headerReference / w:footerReference must resolve through the document
589
- // relationships to a part that exists, is well-formed, and is labeled with Word's content type.
590
- {
591
- const relationshipById = new Map();
592
- for (const rel of Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship'))) {
593
- relationshipById.set(rel.getAttribute('Id') || '', { type: rel.getAttribute('Type') || '', target: rel.getAttribute('Target') || '' });
594
- }
595
- const headerSpec = getPartSpec('header');
596
- const footerSpec = getPartSpec('footer');
597
- const overrideTypeByPart = new Map(Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))
598
- .map(override => [(override.getAttribute('PartName') || '').toLowerCase(), override.getAttribute('ContentType') || '']));
599
- for (const reference of [
600
- ...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'headerReference')),
601
- ...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'footerReference'))
602
- ]) {
603
- const relId = reference.getAttributeNS?.('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id') || reference.getAttribute('r:id') || '';
604
- const spec = reference.localName === 'headerReference' ? headerSpec : footerSpec;
605
- const relationship = relationshipById.get(relId);
606
- if (!relationship) throw new Error(`Validation failed: ${reference.localName} ${relId || '(no r:id)'} has no document relationship`);
607
- if (relationship.type !== spec.relType) throw new Error(`Validation failed: ${reference.localName} ${relId} points at a relationship of the wrong type (${relationship.type})`);
608
- const partName = relationship.target.startsWith('/') ? relationship.target : `/word/${relationship.target}`;
609
- const partXml = await readZipText(zip, partName.slice(1));
610
- if (!partXml) throw new Error(`Validation failed: ${reference.localName} ${relId} targets a missing part: ${partName}`);
611
- parseXmlStrictStandalone(partXml, partName.slice(1));
612
- const contentType = overrideTypeByPart.get(partName.toLowerCase());
613
- if (contentType !== spec.contentType) throw new Error(`Validation failed: ${partName} content type is ${contentType ? `'${contentType}'` : 'missing'}; Word expects '${spec.contentType}'`);
614
- }
615
- }
616
-
617
- if (commentsExtendedXml) {
618
- const extendedOverride = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).find(override =>
619
- (override.getAttribute('PartName') || '').toLowerCase() === '/word/commentsextended.xml'
620
- );
621
- if (extendedOverride && extendedOverride.getAttribute('ContentType') !== COMMENTS_EXTENDED_CONTENT_TYPE) {
622
- throw new Error(`Validation failed: commentsExtended CT override has wrong content type '${extendedOverride.getAttribute('ContentType')}' (Word expects '${COMMENTS_EXTENDED_CONTENT_TYPE}')`);
623
- }
624
- const hasContentType = !!extendedOverride;
625
- const hasRelationship = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
626
- (rel.getAttribute('Type') || '') === COMMENTS_EXTENDED_REL_TYPE
627
- );
628
- if (!hasContentType) throw new Error('Validation failed: commentsExtended CT override missing');
629
- if (!hasRelationship) throw new Error('Validation failed: commentsExtended rel missing');
630
- }
631
- }
1
+ /**
2
+ * Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
3
+ */
4
+
5
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
+ import { commentParaId } from './comment-thread-parts.js';
7
+ import { correctedContentTypeFor, getPartSpec } from './package-parts.js';
8
+ import { mergeNumberingParts } from './numbering-allocation.js';
9
+
10
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
11
+ const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
12
+ const NS_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
13
+ const NUMBERING = getPartSpec('numbering');
14
+ const COMMENTS = getPartSpec('comments');
15
+ const COMMENTS_EXTENDED = getPartSpec('commentsExtended');
16
+ const NUMBERING_REL_TYPE = NUMBERING.relType;
17
+ const NUMBERING_CONTENT_TYPE = NUMBERING.contentType;
18
+ const COMMENTS_REL_TYPE = COMMENTS.relType;
19
+ const COMMENTS_CONTENT_TYPE = COMMENTS.contentType;
20
+ const COMMENTS_EXTENDED_REL_TYPE = COMMENTS_EXTENDED.relType;
21
+ // Word's own value. The old 'application/vnd.ms-word.commentsExtended+xml' label is a registered legacy value.
22
+ const COMMENTS_EXTENDED_CONTENT_TYPE = COMMENTS_EXTENDED.contentType;
23
+
24
+ const DOCUMENT_PATH = getPartSpec('document').path;
25
+ const NUMBERING_PATH = NUMBERING.path;
26
+ const COMMENTS_PATH = COMMENTS.path;
27
+ const COMMENTS_EXTENDED_PATH = COMMENTS_EXTENDED.path;
28
+ const CONTENT_TYPES_PATH = '[Content_Types].xml';
29
+ const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
30
+
31
+ export function parseXmlStrictStandalone(xmlText, label = 'xml') {
32
+ const parsed = parseOoxmlSafe(xmlText, 'application/xml');
33
+ if (parsed.error || !parsed.doc) {
34
+ const error = new Error(`[XML parse error] ${label}: ${parsed.error?.message || 'Unknown'}`);
35
+ error.code = 'PARSE_ERROR';
36
+ throw error;
37
+ }
38
+ return parsed.doc;
39
+ }
40
+
41
+ function isSectionPropertiesElement(node) {
42
+ return !!node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'sectPr';
43
+ }
44
+
45
+ export function getBodyElementFromDocument(xmlDoc) {
46
+ return xmlDoc.getElementsByTagNameNS('*', 'body')[0] || null;
47
+ }
48
+
49
+ function getDirectSectionProperties(body) {
50
+ for (const child of Array.from(body.childNodes || [])) {
51
+ if (isSectionPropertiesElement(child)) return child;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function insertBodyElementBeforeSectPr(body, element) {
57
+ const sectPr = getDirectSectionProperties(body);
58
+ if (sectPr) {
59
+ body.insertBefore(element, sectPr);
60
+ } else {
61
+ body.appendChild(element);
62
+ }
63
+ }
64
+
65
+ export function normalizeBodySectionOrderStandalone(xmlDoc) {
66
+ const body = getBodyElementFromDocument(xmlDoc);
67
+ if (!body) return;
68
+ const sectPr = getDirectSectionProperties(body);
69
+ if (!sectPr) return;
70
+ let cursor = sectPr.nextSibling;
71
+ while (cursor) {
72
+ const next = cursor.nextSibling;
73
+ if (cursor.nodeType === 1) {
74
+ body.insertBefore(cursor, sectPr);
75
+ }
76
+ cursor = next;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Flattens nested table-cell paragraphs (`w:tc > w:p > w:p`) by promoting
82
+ * inner paragraphs to direct `w:tc` children.
83
+ *
84
+ * @param {Document} xmlDoc
85
+ * @param {{ onInfo?: (message: string) => void }} [options]
86
+ * @returns {number} number of nested paragraphs fixed
87
+ */
88
+ export function sanitizeNestedParagraphsInTables(xmlDoc, options = {}) {
89
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
90
+ const tcs = xmlDoc.getElementsByTagNameNS(NS_W, 'tc');
91
+ let fixed = 0;
92
+ for (const tc of Array.from(tcs)) {
93
+ const outerParagraphs = Array.from(tc.childNodes || []).filter(
94
+ node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
95
+ );
96
+ for (const outerParagraph of outerParagraphs) {
97
+ const innerParagraphs = Array.from(outerParagraph.childNodes || []).filter(
98
+ node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
99
+ );
100
+ for (const innerParagraph of innerParagraphs) {
101
+ tc.insertBefore(innerParagraph, outerParagraph);
102
+ fixed += 1;
103
+ }
104
+ }
105
+ }
106
+ if (fixed > 0) {
107
+ onInfo(`[Sanitize] Fixed ${fixed} nested w:p element(s) in table cells`);
108
+ }
109
+ return fixed;
110
+ }
111
+
112
+ export function getPackagePartName(partElement) {
113
+ return partElement.getAttribute('pkg:name') || partElement.getAttribute('name') || '';
114
+ }
115
+
116
+ function extractFromPackageXml(packageXml) {
117
+ const serializer = createSerializer();
118
+ const pkgDoc = parseXmlStrictStandalone(packageXml, 'package OOXML');
119
+ const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
120
+ const documentPart = parts.find(part => getPackagePartName(part) === '/word/document.xml');
121
+ if (!documentPart) {
122
+ throw new Error('Package output missing /word/document.xml part');
123
+ }
124
+
125
+ const xmlData = documentPart.getElementsByTagNameNS('*', 'xmlData')[0];
126
+ if (!xmlData) {
127
+ throw new Error('Package document part missing pkg:xmlData');
128
+ }
129
+ const documentNode = Array.from(xmlData.childNodes || []).find(node => node.nodeType === 1);
130
+ if (!documentNode) {
131
+ throw new Error('Package document part missing XML payload');
132
+ }
133
+
134
+ const body = documentNode.getElementsByTagNameNS('*', 'body')[0];
135
+ const replacementNodes = body
136
+ ? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
137
+ : [documentNode];
138
+
139
+ const numberingPart = parts.find(part => getPackagePartName(part) === '/word/numbering.xml');
140
+ let numberingXml = null;
141
+ if (numberingPart) {
142
+ const numberingXmlData = numberingPart.getElementsByTagNameNS('*', 'xmlData')[0];
143
+ const numberingNode = numberingXmlData
144
+ ? Array.from(numberingXmlData.childNodes || []).find(node => node.nodeType === 1)
145
+ : null;
146
+ if (numberingNode) {
147
+ numberingXml = serializer.serializeToString(numberingNode);
148
+ }
149
+ }
150
+
151
+ return {
152
+ replacementNodes,
153
+ numberingXml,
154
+ sourceType: 'package'
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Extracts replacement nodes and optional numbering payload from engine output.
160
+ *
161
+ * @param {string} outputOxml
162
+ * @returns {{ replacementNodes: Element[], numberingXml: string|null, sourceType: 'package'|'document'|'fragment' }}
163
+ */
164
+ export function extractReplacementNodesFromOoxml(outputOxml) {
165
+ if (typeof outputOxml !== 'string' || !outputOxml.trim()) {
166
+ return {
167
+ replacementNodes: [],
168
+ numberingXml: null,
169
+ sourceType: 'fragment',
170
+ status: 'error',
171
+ error: { code: 'PARSE_ERROR', message: 'Reconciliation engine returned no OOXML payload for this operation' }
172
+ };
173
+ }
174
+
175
+ try {
176
+ if (outputOxml.includes('<pkg:package')) {
177
+ return extractFromPackageXml(outputOxml);
178
+ }
179
+
180
+ if (outputOxml.includes('<w:document')) {
181
+ const doc = parseXmlStrictStandalone(outputOxml, 'document OOXML');
182
+ const body = doc.getElementsByTagNameNS('*', 'body')[0];
183
+ const replacementNodes = body
184
+ ? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
185
+ : Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
186
+ return { replacementNodes, numberingXml: null, sourceType: 'document' };
187
+ }
188
+
189
+ const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
190
+ const fragmentDoc = parseXmlStrictStandalone(wrapped, 'OOXML fragment');
191
+ const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
192
+ return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
193
+ } catch (caught) {
194
+ return {
195
+ replacementNodes: [],
196
+ numberingXml: null,
197
+ sourceType: 'fragment',
198
+ status: 'error',
199
+ error: { code: 'PARSE_ERROR', message: caught?.message || 'Could not parse OOXML payload.' }
200
+ };
201
+ }
202
+ }
203
+
204
+ function upsertContentTypeOverride(ctDoc, partName, contentType) {
205
+ const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
206
+ const existingOverride = overrides.find(
207
+ override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
208
+ );
209
+ if (existingOverride) {
210
+ if ((existingOverride.getAttribute('ContentType') || '') === contentType) return false;
211
+ existingOverride.setAttribute('ContentType', contentType);
212
+ return true;
213
+ }
214
+
215
+ const override = ctDoc.createElementNS(NS_CT, 'Override');
216
+ override.setAttribute('PartName', partName);
217
+ override.setAttribute('ContentType', contentType);
218
+ ctDoc.documentElement.appendChild(override);
219
+ return true;
220
+ }
221
+
222
+ function upsertDocumentRelationship(relsDoc, relType, target, options = {}) {
223
+ const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
224
+ const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
225
+ const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
226
+ if (hasRel) return false;
227
+
228
+ let maxId = 0;
229
+ for (const rel of rels) {
230
+ const idValue = rel.getAttribute('Id') || '';
231
+ const idNum = Number.parseInt(idValue.replace(/^rId/i, ''), 10);
232
+ if (Number.isFinite(idNum)) {
233
+ maxId = Math.max(maxId, idNum);
234
+ }
235
+ }
236
+
237
+ const newId = `rId${maxId + 1}`;
238
+ const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
239
+ rel.setAttribute('Id', newId);
240
+ rel.setAttribute('Type', relType);
241
+ rel.setAttribute('Target', target);
242
+ relsRoot.appendChild(rel);
243
+ if (options?._receiptCollector) {
244
+ options._receiptCollector.recordRelationship(newId);
245
+ } else if (options?._documentOperationSession?.receiptCollector) {
246
+ options._documentOperationSession.receiptCollector.recordRelationship(newId);
247
+ }
248
+ return true;
249
+ }
250
+
251
+ async function readZipText(zip, filePath) {
252
+ const entry = zip.file(filePath);
253
+ if (!entry) return null;
254
+ return entry.async('string');
255
+ }
256
+
257
+ /**
258
+ * Ensures numbering part + package metadata exist and merges numbering payloads.
259
+ *
260
+ * Merging is always done by `mergeNumberingParts` (services/numbering-allocation.js), the single numbering merge:
261
+ * both the standalone runner's consumers and the `openDocx` facade call this function with the same arguments.
262
+ * An incoming id that already exists with different content would silently bind new paragraphs to someone
263
+ * else's list, so it fails closed with `NUMBERING_ID_CONFLICT`.
264
+ *
265
+ * @param {any} zip
266
+ * @param {string|string[]|null|undefined} numberingXmlList
267
+ * @param {{ onInfo?: (message: string) => void }} [options] A legacy `mergeNumberingXml` option is ignored.
268
+ */
269
+ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, options = {}) {
270
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
271
+ const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
272
+ if (incomingPayloads.length === 0) return;
273
+
274
+ const existing = await readZipText(zip, NUMBERING_PATH);
275
+ const conflicts = [];
276
+ const mergedNumberingXml = mergeNumberingParts(existing, incomingPayloads, {
277
+ onConflict: conflict => conflicts.push(conflict)
278
+ });
279
+ if (conflicts.length > 0) {
280
+ const list = conflicts.map(conflict => `${conflict.kind} ${conflict.id}`).join(', ');
281
+ throw Object.assign(
282
+ new Error(`Numbering id conflict: ${list} already exist with different content. Generated list numbering must be allocated from the document's own numbering state (pass the current numbering part to the operation runner).`),
283
+ { code: 'NUMBERING_ID_CONFLICT', conflicts }
284
+ );
285
+ }
286
+
287
+ if (!existing) {
288
+ onInfo('[Demo] Adding numbering.xml');
289
+ } else {
290
+ onInfo('[Demo] Merging numbering.xml payload(s) into existing numbering definitions');
291
+ }
292
+ zip.file(NUMBERING_PATH, mergedNumberingXml);
293
+
294
+ const serializer = createSerializer();
295
+
296
+ const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
297
+ if (ctText) {
298
+ const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
299
+ if (upsertContentTypeOverride(ctDoc, '/word/numbering.xml', NUMBERING_CONTENT_TYPE)) {
300
+ zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
301
+ }
302
+ }
303
+
304
+ const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
305
+ if (relsText) {
306
+ const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
307
+ if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml', options)) {
308
+ zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Ensures comments part + package metadata exist and merges incoming comments.
315
+ *
316
+ * @param {any} zip
317
+ * @param {string|null|undefined} commentsXml
318
+ * @param {{ onInfo?: (message: string) => void }} [options]
319
+ */
320
+ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
321
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
322
+ if (!commentsXml) return;
323
+
324
+ const serializer = createSerializer();
325
+ const existingText = await readZipText(zip, COMMENTS_PATH);
326
+ if (!existingText || options.replaceExisting === true) {
327
+ onInfo('[Demo] Adding comments.xml');
328
+ zip.file(COMMENTS_PATH, commentsXml);
329
+ } else {
330
+ const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
331
+ const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
332
+ const existingRoot = existingDoc.documentElement;
333
+ const existingIds = new Set(
334
+ Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
335
+ .map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
336
+ .filter(Boolean)
337
+ );
338
+ for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
339
+ const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
340
+ if (id && existingIds.has(id)) {
341
+ throw new Error(`Duplicate comment id: ${id}`);
342
+ }
343
+ existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
344
+ }
345
+ zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
346
+ }
347
+
348
+ const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
349
+ if (ctText) {
350
+ const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
351
+ if (upsertContentTypeOverride(ctDoc, '/word/comments.xml', COMMENTS_CONTENT_TYPE)) {
352
+ zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
353
+ }
354
+ }
355
+
356
+ const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
357
+ if (relsText) {
358
+ const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
359
+ if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml', options)) {
360
+ zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
361
+ }
362
+ }
363
+ }
364
+
365
+ /** Ensures the modern Word comment-threading part and package metadata exist. */
366
+ export async function ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml, options = {}) {
367
+ if (!commentsExtendedXml) return;
368
+ parseXmlStrictStandalone(commentsExtendedXml, 'word/commentsExtended.xml');
369
+ zip.file(COMMENTS_EXTENDED_PATH, commentsExtendedXml);
370
+ const serializer = createSerializer();
371
+ const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
372
+ if (ctText) {
373
+ const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
374
+ if (upsertContentTypeOverride(ctDoc, '/word/commentsExtended.xml', COMMENTS_EXTENDED_CONTENT_TYPE)) {
375
+ zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
376
+ }
377
+ }
378
+ const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
379
+ if (relsText) {
380
+ const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
381
+ if (upsertDocumentRelationship(relsDoc, COMMENTS_EXTENDED_REL_TYPE, 'commentsExtended.xml', options)) {
382
+ zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Rewrites content-type overrides for known parts whose type is wrong (for example the
389
+ * `application/vnd.ms-word.commentsExtended+xml` value written by earlier versions of
390
+ * this package). Does not rewrite [Content_Types].xml when there is nothing to repair.
391
+ *
392
+ * @param {any} zip
393
+ * @returns {Promise<Array<{ partName: string, from: string, to: string }>>}
394
+ */
395
+ export async function repairKnownContentTypes(zip) {
396
+ const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
397
+ if (!ctText) return [];
398
+ const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
399
+ const repairs = [];
400
+ for (const override of Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))) {
401
+ const partName = override.getAttribute('PartName') || '';
402
+ const actual = override.getAttribute('ContentType') || '';
403
+ const expected = correctedContentTypeFor(partName, actual);
404
+ if (!expected) continue;
405
+ override.setAttribute('ContentType', expected);
406
+ repairs.push({ partName, from: actual, to: expected });
407
+ }
408
+ if (repairs.length > 0) zip.file(CONTENT_TYPES_PATH, createSerializer().serializeToString(ctDoc));
409
+ return repairs;
410
+ }
411
+
412
+ /**
413
+ * Validates core package integrity for document/comments/numbering artifacts.
414
+ *
415
+ * @param {any} zip
416
+ */
417
+ export async function validateDocxPackage(zip) {
418
+ const documentXml = await readZipText(zip, DOCUMENT_PATH);
419
+ if (!documentXml) {
420
+ throw new Error('Validation failed: missing word/document.xml');
421
+ }
422
+
423
+ const documentDoc = parseXmlStrictStandalone(documentXml, DOCUMENT_PATH);
424
+ normalizeBodySectionOrderStandalone(documentDoc);
425
+ const body = getBodyElementFromDocument(documentDoc);
426
+ if (!body) {
427
+ throw new Error('Validation failed: word/document.xml has no w:body');
428
+ }
429
+
430
+ const directBodyElements = Array.from(body.childNodes || []).filter(node => node.nodeType === 1);
431
+ const sectPrIndexes = directBodyElements
432
+ .map((node, index) => ({ node, index }))
433
+ .filter(entry => isSectionPropertiesElement(entry.node))
434
+ .map(entry => entry.index);
435
+
436
+ if (sectPrIndexes.length > 1) {
437
+ throw new Error('Validation failed: multiple body-level w:sectPr');
438
+ }
439
+ if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== directBodyElements.length - 1) {
440
+ throw new Error('Validation failed: w:sectPr not last');
441
+ }
442
+
443
+ const tcs = documentDoc.getElementsByTagNameNS(NS_W, 'tc');
444
+ for (const tc of Array.from(tcs)) {
445
+ for (const child of Array.from(tc.childNodes || []).filter(node => node.nodeType === 1)) {
446
+ if (child.namespaceURI === NS_W && child.localName === 'p') {
447
+ const hasNestedParagraph = Array.from(child.childNodes || []).some(
448
+ node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
449
+ );
450
+ if (hasNestedParagraph) {
451
+ throw new Error('Validation failed: nested w:p');
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ const hasNumberingUsage = documentDoc.getElementsByTagNameNS(NS_W, 'numPr').length > 0;
458
+ const hasCommentUsage =
459
+ documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeStart').length > 0
460
+ || documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeEnd').length > 0
461
+ || documentDoc.getElementsByTagNameNS(NS_W, 'commentReference').length > 0;
462
+
463
+ const numberingXml = await readZipText(zip, NUMBERING_PATH);
464
+ const commentsXml = await readZipText(zip, COMMENTS_PATH);
465
+ const commentsExtendedXml = await readZipText(zip, COMMENTS_EXTENDED_PATH);
466
+ if (commentsExtendedXml && !commentsXml) {
467
+ throw new Error('Validation failed: commentsExtended part exists but comments part is missing');
468
+ }
469
+
470
+ if (numberingXml) {
471
+ parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
472
+ } else if (hasNumberingUsage) {
473
+ throw new Error('Validation failed: numbering used but part missing');
474
+ }
475
+
476
+ if (commentsXml) {
477
+ const commentsDoc = parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
478
+ const idsFor = (doc, localName) => Array.from(doc.getElementsByTagNameNS(NS_W, localName))
479
+ .map(node => node.getAttribute('w:id') || node.getAttribute('id'))
480
+ .filter(id => id !== '');
481
+ const starts = new Set(idsFor(documentDoc, 'commentRangeStart'));
482
+ const ends = new Set(idsFor(documentDoc, 'commentRangeEnd'));
483
+ const references = new Set(idsFor(documentDoc, 'commentReference'));
484
+ const definitionIds = idsFor(commentsDoc, 'comment');
485
+ const definitions = new Set(definitionIds);
486
+ const sorted = ids => Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
487
+ const difference = (left, right) => sorted(new Set(Array.from(left).filter(id => !right.has(id))));
488
+
489
+ const duplicateDefinitions = sorted(new Set(definitionIds.filter((id, index) => definitionIds.indexOf(id) !== index)));
490
+ if (duplicateDefinitions.length > 0) {
491
+ throw new Error(`Validation failed: duplicate comment definitions for id(s): ${duplicateDefinitions.join(', ')}`);
492
+ }
493
+ const startsWithoutEnds = difference(starts, ends);
494
+ const endsWithoutStarts = difference(ends, starts);
495
+ if (startsWithoutEnds.length > 0 || endsWithoutStarts.length > 0) {
496
+ const parts = [];
497
+ if (startsWithoutEnds.length > 0) parts.push(`start without end: ${startsWithoutEnds.join(', ')}`);
498
+ if (endsWithoutStarts.length > 0) parts.push(`end without start: ${endsWithoutStarts.join(', ')}`);
499
+ throw new Error(`Validation failed: unbalanced comment range marker(s) (${parts.join('; ')})`);
500
+ }
501
+ const rangesWithoutReferences = difference(new Set([...starts, ...ends]), references);
502
+ if (rangesWithoutReferences.length > 0) {
503
+ throw new Error(`Validation failed: comment range has no reference for id(s): ${rangesWithoutReferences.join(', ')}`);
504
+ }
505
+ const usagesWithoutDefinitions = difference(new Set([...starts, ...ends, ...references]), definitions);
506
+ if (usagesWithoutDefinitions.length > 0) {
507
+ throw new Error(`Validation failed: comment usage has no definition for id(s): ${usagesWithoutDefinitions.join(', ')}`);
508
+ }
509
+ const replyCommentIds = new Set();
510
+ if (commentsExtendedXml) {
511
+ const extendedDoc = parseXmlStrictStandalone(commentsExtendedXml, COMMENTS_EXTENDED_PATH);
512
+ const paraIdToCommentId = new Map();
513
+ for (const comment of Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'))) {
514
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
515
+ const paraId = commentParaId(comment);
516
+ if (paraId) paraIdToCommentId.set(paraId, id);
517
+ }
518
+ const knownParaIds = new Set(paraIdToCommentId.keys());
519
+ const extendedParaIds = new Set();
520
+ for (const entry of Array.from(extendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
521
+ const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId');
522
+ const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent');
523
+ if (!paraId || !knownParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: commentsExtended entry has no matching comment paragraph: ${paraId || '(missing)'}`);
524
+ if (extendedParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: duplicate commentsExtended entry for paraId: ${paraId}`);
525
+ extendedParaIds.add(paraId.toUpperCase());
526
+ if (parentParaId) {
527
+ if (!knownParaIds.has(parentParaId.toUpperCase())) throw new Error(`Validation failed: commentsExtended parent paragraph was not found: ${parentParaId}`);
528
+ replyCommentIds.add(paraIdToCommentId.get(paraId.toUpperCase()));
529
+ }
530
+ }
531
+ }
532
+ const definitionsWithoutReferences = difference(new Set([...definitions].filter(id => !replyCommentIds.has(id))), references);
533
+ if (definitionsWithoutReferences.length > 0) {
534
+ throw new Error(`Validation failed: comment definition has no document reference for id(s): ${definitionsWithoutReferences.join(', ')}`);
535
+ }
536
+ } else if (hasCommentUsage) {
537
+ throw new Error('Validation failed: comments used but part missing');
538
+ }
539
+
540
+ const ctXml = await readZipText(zip, CONTENT_TYPES_PATH);
541
+ if (!ctXml) {
542
+ throw new Error(`Validation failed: missing ${CONTENT_TYPES_PATH}`);
543
+ }
544
+ const ctDoc = parseXmlStrictStandalone(ctXml, CONTENT_TYPES_PATH);
545
+
546
+ const relsXml = await readZipText(zip, DOCUMENT_RELS_PATH);
547
+ if (!relsXml) {
548
+ throw new Error(`Validation failed: missing ${DOCUMENT_RELS_PATH}`);
549
+ }
550
+ const relsDoc = parseXmlStrictStandalone(relsXml, DOCUMENT_RELS_PATH);
551
+
552
+ if (numberingXml) {
553
+ const hasNumberingContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
554
+ (override.getAttribute('PartName') || '').toLowerCase() === '/word/numbering.xml'
555
+ && (override.getAttribute('ContentType') || '') === NUMBERING_CONTENT_TYPE
556
+ );
557
+ const hasNumberingRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
558
+ (rel.getAttribute('Type') || '') === NUMBERING_REL_TYPE
559
+ );
560
+ if (!hasNumberingContentType) {
561
+ throw new Error('Validation failed: numbering CT override missing');
562
+ }
563
+ if (!hasNumberingRel) {
564
+ throw new Error('Validation failed: numbering rel missing');
565
+ }
566
+ }
567
+
568
+ if (commentsXml) {
569
+ const hasCommentsContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
570
+ (override.getAttribute('PartName') || '').toLowerCase() === '/word/comments.xml'
571
+ && (override.getAttribute('ContentType') || '') === COMMENTS_CONTENT_TYPE
572
+ );
573
+ const hasCommentsRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
574
+ (rel.getAttribute('Type') || '') === COMMENTS_REL_TYPE
575
+ );
576
+ if (!hasCommentsContentType) {
577
+ throw new Error('Validation failed: comments CT override missing');
578
+ }
579
+ if (!hasCommentsRel) {
580
+ throw new Error('Validation failed: comments rel missing');
581
+ }
582
+ }
583
+
584
+ // Header/footer wiring: every w:headerReference / w:footerReference must resolve through the document
585
+ // relationships to a part that exists, is well-formed, and is labeled with Word's content type.
586
+ {
587
+ const relationshipById = new Map();
588
+ for (const rel of Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship'))) {
589
+ relationshipById.set(rel.getAttribute('Id') || '', { type: rel.getAttribute('Type') || '', target: rel.getAttribute('Target') || '' });
590
+ }
591
+ const headerSpec = getPartSpec('header');
592
+ const footerSpec = getPartSpec('footer');
593
+ const overrideTypeByPart = new Map(Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))
594
+ .map(override => [(override.getAttribute('PartName') || '').toLowerCase(), override.getAttribute('ContentType') || '']));
595
+ for (const reference of [
596
+ ...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'headerReference')),
597
+ ...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'footerReference'))
598
+ ]) {
599
+ const relId = reference.getAttributeNS?.('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id') || reference.getAttribute('r:id') || '';
600
+ const spec = reference.localName === 'headerReference' ? headerSpec : footerSpec;
601
+ const relationship = relationshipById.get(relId);
602
+ if (!relationship) throw new Error(`Validation failed: ${reference.localName} ${relId || '(no r:id)'} has no document relationship`);
603
+ if (relationship.type !== spec.relType) throw new Error(`Validation failed: ${reference.localName} ${relId} points at a relationship of the wrong type (${relationship.type})`);
604
+ const partName = relationship.target.startsWith('/') ? relationship.target : `/word/${relationship.target}`;
605
+ const partXml = await readZipText(zip, partName.slice(1));
606
+ if (!partXml) throw new Error(`Validation failed: ${reference.localName} ${relId} targets a missing part: ${partName}`);
607
+ parseXmlStrictStandalone(partXml, partName.slice(1));
608
+ const contentType = overrideTypeByPart.get(partName.toLowerCase());
609
+ if (contentType !== spec.contentType) throw new Error(`Validation failed: ${partName} content type is ${contentType ? `'${contentType}'` : 'missing'}; Word expects '${spec.contentType}'`);
610
+ }
611
+ }
612
+
613
+ if (commentsExtendedXml) {
614
+ const extendedOverride = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).find(override =>
615
+ (override.getAttribute('PartName') || '').toLowerCase() === '/word/commentsextended.xml'
616
+ );
617
+ if (extendedOverride && extendedOverride.getAttribute('ContentType') !== COMMENTS_EXTENDED_CONTENT_TYPE) {
618
+ throw new Error(`Validation failed: commentsExtended CT override has wrong content type '${extendedOverride.getAttribute('ContentType')}' (Word expects '${COMMENTS_EXTENDED_CONTENT_TYPE}')`);
619
+ }
620
+ const hasContentType = !!extendedOverride;
621
+ const hasRelationship = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
622
+ (rel.getAttribute('Type') || '') === COMMENTS_EXTENDED_REL_TYPE
623
+ );
624
+ if (!hasContentType) throw new Error('Validation failed: commentsExtended CT override missing');
625
+ if (!hasRelationship) throw new Error('Validation failed: commentsExtended rel missing');
626
+ }
627
+ }