@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -62,19 +62,37 @@ function authorMatchesNode(node, filter) {
62
62
  }
63
63
 
64
64
  function parseXmlWithWarnings(oxml, parseFailurePrefix) {
65
- const parsed = parseOoxmlSafe(oxml, 'application/xml');
65
+ let rawOxml = typeof oxml === 'string' ? oxml.replace(/^\uFEFF/, '').trim() : '';
66
+ let isFragmentWrapped = false;
67
+ let parsed = parseOoxmlSafe(rawOxml, 'application/xml');
68
+ if (parsed.error && (parsed.error.message.includes('HierarchyRequestError') || parsed.error.message.includes('Only one element'))) {
69
+ const wrapped = `<w:body xmlns:w="${NS_W}">${rawOxml}</w:body>`;
70
+ const wrappedParsed = parseOoxmlSafe(wrapped, 'application/xml');
71
+ if (!wrappedParsed.error) {
72
+ parsed = wrappedParsed;
73
+ isFragmentWrapped = true;
74
+ }
75
+ }
66
76
  const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
67
77
  if (parsed.error || parseError) {
68
78
  const message = parsed.error?.message || parseError?.textContent || 'parse error';
69
79
  return {
70
80
  xmlDoc: null,
71
81
  serializer: null,
82
+ isFragmentWrapped: false,
72
83
  warning: `${parseFailurePrefix}: ${message}`,
73
84
  warnings: parsed.warnings,
74
85
  error: { code: 'PARSE_ERROR', message }
75
86
  };
76
87
  }
77
- return { xmlDoc: parsed.doc, serializer: createSerializer(), warning: null, warnings: parsed.warnings, error: null };
88
+ return {
89
+ xmlDoc: parsed.doc,
90
+ serializer: createSerializer(),
91
+ isFragmentWrapped,
92
+ warning: null,
93
+ warnings: parsed.warnings,
94
+ error: null
95
+ };
78
96
  }
79
97
 
80
98
  function removeNode(node) {
@@ -124,11 +142,17 @@ function mergeParagraphIntoNextAndRemove(paragraph) {
124
142
  if (!paragraph?.parentNode) return false;
125
143
  const nextParagraph = getNextWordParagraph(paragraph);
126
144
  if (!nextParagraph) {
145
+ if (isWordElement(paragraph.parentNode, 'tc') && getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1) {
146
+ return false;
147
+ }
127
148
  return removeNode(paragraph);
128
149
  }
129
150
 
130
151
  const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
131
- const insertionPoint = nextParagraph.firstChild || null;
152
+ let insertionPoint = nextParagraph.firstChild || null;
153
+ if (isWordElement(insertionPoint, 'pPr')) {
154
+ insertionPoint = insertionPoint.nextSibling || null;
155
+ }
132
156
  for (const child of childrenToMove) {
133
157
  nextParagraph.insertBefore(child, insertionPoint);
134
158
  }
@@ -214,8 +238,12 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
214
238
  }
215
239
  }
216
240
 
241
+ const serializedOxml = parseResult.isFragmentWrapped
242
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
243
+ : serializer.serializeToString(xmlDoc);
244
+
217
245
  return {
218
- oxml: serializer.serializeToString(xmlDoc),
246
+ oxml: serializedOxml,
219
247
  hasChanges: acceptedCount > 0,
220
248
  acceptedCount,
221
249
  warnings
@@ -395,8 +423,12 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
395
423
  }
396
424
  }
397
425
 
426
+ const serializedOxml = parseResult.isFragmentWrapped
427
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
428
+ : serializer.serializeToString(xmlDoc);
429
+
398
430
  return {
399
- oxml: serializer.serializeToString(xmlDoc),
431
+ oxml: serializedOxml,
400
432
  hasChanges: rejectedCount > 0,
401
433
  rejectedCount,
402
434
  warnings
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Revision token framing and calculation for docx-redline-js.
3
+ *
4
+ * Implements unambiguous big-endian binary framing to generate cryptographic
5
+ * revision tokens over package entries or document parts.
6
+ */
7
+
8
+ const textEncoder = new TextEncoder();
9
+
10
+ /**
11
+ * Normalizes an OPC entry or part name to canonical forward-slash form.
12
+ *
13
+ * @param {string} name - Raw entry name
14
+ * @returns {string} Normalized entry name
15
+ */
16
+ export function normalizeOpcEntryName(name) {
17
+ if (typeof name !== 'string') {
18
+ throw new TypeError(`Entry name must be a string, got ${typeof name}`);
19
+ }
20
+ let normalized = name.replace(/\\/g, '/');
21
+ normalized = normalized.replace(/^\/+/, '');
22
+ while (normalized.startsWith('./')) {
23
+ normalized = normalized.slice(2);
24
+ }
25
+ normalized = normalized.replace(/\/+/g, '/');
26
+ if (!normalized) {
27
+ throw new Error(`Invalid empty entry name: "${name}"`);
28
+ }
29
+ return normalized;
30
+ }
31
+
32
+ /**
33
+ * Builds the binary framing buffer for a revision token according to the specification:
34
+ *
35
+ * magic = "docx-redline-revision-token\0"
36
+ * version = uint32be(1)
37
+ * scopeLength + scope
38
+ * entryCount
39
+ * for each entry sorted by normalized name:
40
+ * nameLength + name
41
+ * payloadLength + payload
42
+ *
43
+ * @param {{ scope: string, entries: Array<{ name: string, payload: Uint8Array|string|Buffer }|Array> }} options
44
+ * @returns {{ framing: Uint8Array, scope: string, version: number, coveredParts: string[] }}
45
+ */
46
+ export function buildRevisionTokenFraming({ scope, entries = [] }) {
47
+ if (typeof scope !== 'string' || !scope) {
48
+ throw new TypeError('Revision token scope must be a non-empty string.');
49
+ }
50
+ const magicBytes = textEncoder.encode('docx-redline-revision-token\0');
51
+ const version = 1;
52
+ const scopeBytes = textEncoder.encode(scope);
53
+
54
+ const normalizedEntries = [];
55
+ const seenNames = new Set();
56
+
57
+ const rawList = Array.isArray(entries)
58
+ ? entries
59
+ : (entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {}));
60
+
61
+ for (const item of rawList) {
62
+ if (!item) continue;
63
+ const rawName = Array.isArray(item) ? item[0] : item.name;
64
+ const rawPayload = Array.isArray(item) ? item[1] : (item.payload ?? item.bytes);
65
+
66
+ if (rawName == null) continue;
67
+ const normName = normalizeOpcEntryName(String(rawName));
68
+ if (seenNames.has(normName)) {
69
+ throw new Error(`Duplicate normalized entry path detected: "${normName}"`);
70
+ }
71
+ seenNames.add(normName);
72
+
73
+ let payloadBytes;
74
+ if (typeof rawPayload === 'string') {
75
+ payloadBytes = textEncoder.encode(rawPayload);
76
+ } else if (rawPayload instanceof Uint8Array) {
77
+ payloadBytes = rawPayload;
78
+ } else if (rawPayload && typeof rawPayload.length === 'number') {
79
+ payloadBytes = new Uint8Array(rawPayload);
80
+ } else {
81
+ payloadBytes = new Uint8Array(0);
82
+ }
83
+
84
+ normalizedEntries.push({
85
+ name: normName,
86
+ nameBytes: textEncoder.encode(normName),
87
+ payloadBytes
88
+ });
89
+ }
90
+
91
+ // Sort entries by normalized name in binary codepoint order
92
+ normalizedEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
93
+
94
+ let totalSize = magicBytes.length + 4 + 4 + scopeBytes.length + 4;
95
+ for (const e of normalizedEntries) {
96
+ totalSize += 4 + e.nameBytes.length + 4 + e.payloadBytes.length;
97
+ }
98
+
99
+ const buffer = new Uint8Array(totalSize);
100
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
101
+ let offset = 0;
102
+
103
+ // magic
104
+ buffer.set(magicBytes, offset);
105
+ offset += magicBytes.length;
106
+
107
+ // version uint32be
108
+ view.setUint32(offset, version, false);
109
+ offset += 4;
110
+
111
+ // scopeLength uint32be
112
+ view.setUint32(offset, scopeBytes.length, false);
113
+ offset += 4;
114
+
115
+ // scope
116
+ buffer.set(scopeBytes, offset);
117
+ offset += scopeBytes.length;
118
+
119
+ // entryCount uint32be
120
+ view.setUint32(offset, normalizedEntries.length, false);
121
+ offset += 4;
122
+
123
+ for (const e of normalizedEntries) {
124
+ // nameLength uint32be
125
+ view.setUint32(offset, e.nameBytes.length, false);
126
+ offset += 4;
127
+ buffer.set(e.nameBytes, offset);
128
+ offset += e.nameBytes.length;
129
+
130
+ // payloadLength uint32be
131
+ view.setUint32(offset, e.payloadBytes.length, false);
132
+ offset += 4;
133
+ buffer.set(e.payloadBytes, offset);
134
+ offset += e.payloadBytes.length;
135
+ }
136
+
137
+ return {
138
+ framing: buffer,
139
+ scope,
140
+ version,
141
+ coveredParts: normalizedEntries.map(e => e.name)
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Calculates a SHA-256 revision token asynchronously using Web Crypto or a custom digest function.
147
+ *
148
+ * @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => Promise<string> }} options
149
+ * @returns {Promise<{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }>}
150
+ */
151
+ export async function computeRevisionToken({ scope, entries = [], digestFn = null }) {
152
+ const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
153
+
154
+ let hashHex = '';
155
+ if (typeof digestFn === 'function') {
156
+ hashHex = await digestFn(framing);
157
+ } else if (typeof globalThis.crypto?.subtle?.digest === 'function') {
158
+ const hashBuf = await globalThis.crypto.subtle.digest('SHA-256', framing);
159
+ const hashBytes = new Uint8Array(hashBuf);
160
+ hashHex = Array.from(hashBytes).map(b => b.toString(16).padStart(2, '0')).join('');
161
+ } else {
162
+ throw new Error('No crypto provider available for SHA-256 revision token computation.');
163
+ }
164
+
165
+ return {
166
+ algorithm: 'sha256',
167
+ version,
168
+ scope,
169
+ value: hashHex,
170
+ coveredParts
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Calculates a SHA-256 revision token synchronously using a provided digest function.
176
+ *
177
+ * @param {{ scope: string, entries: Array<any>, digestFn: (bytes: Uint8Array) => string }} options
178
+ * @returns {{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }}
179
+ */
180
+ export function computeRevisionTokenSync({ scope, entries = [], digestFn }) {
181
+ if (typeof digestFn !== 'function') {
182
+ throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn.');
183
+ }
184
+ const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
185
+ const hashHex = digestFn(framing);
186
+ return {
187
+ algorithm: 'sha256',
188
+ version,
189
+ scope,
190
+ value: hashHex,
191
+ coveredParts
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Extracts entry records from document parts descriptor.
197
+ *
198
+ * @param {object} parts - Document parts object
199
+ * @returns {Array<{ name: string, payload: any }>}
200
+ */
201
+ export function extractDocumentPartsEntries(parts) {
202
+ const entries = [];
203
+ if (parts?.documentXml) {
204
+ entries.push({ name: 'word/document.xml', payload: parts.documentXml });
205
+ }
206
+ if (parts?.commentsXml) {
207
+ entries.push({ name: 'word/comments.xml', payload: parts.commentsXml });
208
+ }
209
+ if (parts?.commentsExtendedXml) {
210
+ entries.push({ name: 'word/commentsExtended.xml', payload: parts.commentsExtendedXml });
211
+ }
212
+ if (parts?.numberingXml) {
213
+ entries.push({ name: 'word/numbering.xml', payload: parts.numberingXml });
214
+ }
215
+ if (parts?.stylesXml) {
216
+ entries.push({ name: 'word/styles.xml', payload: parts.stylesXml });
217
+ }
218
+ if (parts?.parts instanceof Map) {
219
+ for (const [name, payload] of parts.parts.entries()) {
220
+ entries.push({ name, payload });
221
+ }
222
+ } else if (parts?.additionalParts && typeof parts.additionalParts === 'object') {
223
+ for (const [name, payload] of Object.entries(parts.additionalParts)) {
224
+ entries.push({ name, payload });
225
+ }
226
+ }
227
+ return entries;
228
+ }
229
+
230
+ /**
231
+ * Computes a revision token for document parts asynchronously.
232
+ *
233
+ * @param {object} parts - Document parts
234
+ * @param {object} [options={}] - Options
235
+ * @returns {Promise<{ algorithm: 'sha256', version: number, scope: 'document-parts', value: string, coveredParts: string[] }>}
236
+ */
237
+ export async function computeDocumentPartsRevisionToken(parts, options = {}) {
238
+ const entries = extractDocumentPartsEntries(parts);
239
+ return computeRevisionToken({
240
+ scope: 'document-parts',
241
+ entries,
242
+ digestFn: options.digestFn
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Validates the structure and syntax of an incoming revision token object.
248
+ *
249
+ * @param {any} token
250
+ * @returns {{ valid: boolean, error?: { code: string, message: string } }}
251
+ */
252
+ export function validateRevisionToken(token) {
253
+ if (!token || typeof token !== 'object') {
254
+ return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: 'Revision token must be an object.' } };
255
+ }
256
+ if (token.algorithm !== 'sha256') {
257
+ return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token algorithm: "${token.algorithm}". Expected "sha256".` } };
258
+ }
259
+ if (token.version !== 1) {
260
+ return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token version: "${token.version}". Expected 1.` } };
261
+ }
262
+ if (token.scope !== 'document-parts' && token.scope !== 'package') {
263
+ return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: `Unsupported revision token scope: "${token.scope}". Expected "document-parts" or "package".` } };
264
+ }
265
+ if (typeof token.value !== 'string' || !/^[0-9a-f]{64}$/i.test(token.value.trim())) {
266
+ return { valid: false, error: { code: 'INVALID_REVISION_TOKEN', message: 'Revision token value must be a 64-character hex string.' } };
267
+ }
268
+ return { valid: true };
269
+ }
270
+
271
+ /**
272
+ * Compares two revision token values using timing-safe byte comparison.
273
+ *
274
+ * @param {string} a
275
+ * @param {string} b
276
+ * @returns {boolean}
277
+ */
278
+ export function areRevisionTokensEqual(a, b) {
279
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
280
+ const aNorm = a.trim().toLowerCase();
281
+ const bNorm = b.trim().toLowerCase();
282
+ if (aNorm.length !== bNorm.length) return false;
283
+ const aBuf = textEncoder.encode(aNorm);
284
+ const bBuf = textEncoder.encode(bNorm);
285
+ let diff = 0;
286
+ for (let i = 0; i < aBuf.length; i++) {
287
+ diff |= aBuf[i] ^ bBuf[i];
288
+ }
289
+ return diff === 0;
290
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
+ import { warn as logWarning } from '../adapters/logger.js';
6
7
 
7
8
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
8
9
  const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
@@ -11,10 +12,13 @@ const NUMBERING_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/200
11
12
  const NUMBERING_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml';
12
13
  const COMMENTS_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments';
13
14
  const COMMENTS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml';
15
+ const COMMENTS_EXTENDED_REL_TYPE = 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended';
16
+ const COMMENTS_EXTENDED_CONTENT_TYPE = 'application/vnd.ms-word.commentsExtended+xml';
14
17
 
15
18
  const DOCUMENT_PATH = 'word/document.xml';
16
19
  const NUMBERING_PATH = 'word/numbering.xml';
17
20
  const COMMENTS_PATH = 'word/comments.xml';
21
+ const COMMENTS_EXTENDED_PATH = 'word/commentsExtended.xml';
18
22
  const CONTENT_TYPES_PATH = '[Content_Types].xml';
19
23
  const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
20
24
 
@@ -193,10 +197,14 @@ export function extractReplacementNodesFromOoxml(outputOxml) {
193
197
 
194
198
  function upsertContentTypeOverride(ctDoc, partName, contentType) {
195
199
  const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
196
- const hasOverride = overrides.some(
200
+ const existingOverride = overrides.find(
197
201
  override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
198
202
  );
199
- if (hasOverride) return false;
203
+ if (existingOverride) {
204
+ if ((existingOverride.getAttribute('ContentType') || '') === contentType) return false;
205
+ existingOverride.setAttribute('ContentType', contentType);
206
+ return true;
207
+ }
200
208
 
201
209
  const override = ctDoc.createElementNS(NS_CT, 'Override');
202
210
  override.setAttribute('PartName', partName);
@@ -205,7 +213,7 @@ function upsertContentTypeOverride(ctDoc, partName, contentType) {
205
213
  return true;
206
214
  }
207
215
 
208
- function upsertDocumentRelationship(relsDoc, relType, target) {
216
+ function upsertDocumentRelationship(relsDoc, relType, target, options = {}) {
209
217
  const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
210
218
  const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
211
219
  const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
@@ -220,11 +228,17 @@ function upsertDocumentRelationship(relsDoc, relType, target) {
220
228
  }
221
229
  }
222
230
 
231
+ const newId = `rId${maxId + 1}`;
223
232
  const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
224
- rel.setAttribute('Id', `rId${maxId + 1}`);
233
+ rel.setAttribute('Id', newId);
225
234
  rel.setAttribute('Type', relType);
226
235
  rel.setAttribute('Target', target);
227
236
  relsRoot.appendChild(rel);
237
+ if (options?._receiptCollector) {
238
+ options._receiptCollector.recordRelationship(newId);
239
+ } else if (options?._documentOperationSession?.receiptCollector) {
240
+ options._documentOperationSession.receiptCollector.recordRelationship(newId);
241
+ }
228
242
  return true;
229
243
  }
230
244
 
@@ -249,10 +263,14 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
249
263
  const mergeNumberingXml = typeof options?.mergeNumberingXml === 'function'
250
264
  ? options.mergeNumberingXml
251
265
  : null;
266
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : logWarning;
252
267
  const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
253
268
  if (incomingPayloads.length === 0) return;
254
269
 
255
270
  const existing = await readZipText(zip, NUMBERING_PATH);
271
+ if (existing && !mergeNumberingXml) {
272
+ onWarn('[Deprecation] Replacing an existing numbering.xml without mergeNumberingXml is deprecated and will throw in the next major version.');
273
+ }
256
274
  let mergedNumberingXml = existing || null;
257
275
  for (const incomingNumberingXml of incomingPayloads) {
258
276
  if (!mergedNumberingXml) {
@@ -284,7 +302,7 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
284
302
  const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
285
303
  if (relsText) {
286
304
  const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
287
- if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml')) {
305
+ if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml', options)) {
288
306
  zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
289
307
  }
290
308
  }
@@ -303,7 +321,7 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
303
321
 
304
322
  const serializer = createSerializer();
305
323
  const existingText = await readZipText(zip, COMMENTS_PATH);
306
- if (!existingText) {
324
+ if (!existingText || options.replaceExisting === true) {
307
325
  onInfo('[Demo] Adding comments.xml');
308
326
  zip.file(COMMENTS_PATH, commentsXml);
309
327
  } else {
@@ -336,7 +354,29 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
336
354
  const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
337
355
  if (relsText) {
338
356
  const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
339
- if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml')) {
357
+ if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml', options)) {
358
+ zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
359
+ }
360
+ }
361
+ }
362
+
363
+ /** Ensures the modern Word comment-threading part and package metadata exist. */
364
+ export async function ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml, options = {}) {
365
+ if (!commentsExtendedXml) return;
366
+ parseXmlStrictStandalone(commentsExtendedXml, 'word/commentsExtended.xml');
367
+ zip.file(COMMENTS_EXTENDED_PATH, commentsExtendedXml);
368
+ const serializer = createSerializer();
369
+ const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
370
+ if (ctText) {
371
+ const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
372
+ if (upsertContentTypeOverride(ctDoc, '/word/commentsExtended.xml', COMMENTS_EXTENDED_CONTENT_TYPE)) {
373
+ zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
374
+ }
375
+ }
376
+ const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
377
+ if (relsText) {
378
+ const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
379
+ if (upsertDocumentRelationship(relsDoc, COMMENTS_EXTENDED_REL_TYPE, 'commentsExtended.xml', options)) {
340
380
  zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
341
381
  }
342
382
  }
@@ -395,6 +435,10 @@ export async function validateDocxPackage(zip) {
395
435
 
396
436
  const numberingXml = await readZipText(zip, NUMBERING_PATH);
397
437
  const commentsXml = await readZipText(zip, COMMENTS_PATH);
438
+ const commentsExtendedXml = await readZipText(zip, COMMENTS_EXTENDED_PATH);
439
+ if (commentsExtendedXml && !commentsXml) {
440
+ throw new Error('Validation failed: commentsExtended part exists but comments part is missing');
441
+ }
398
442
 
399
443
  if (numberingXml) {
400
444
  parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
@@ -403,7 +447,66 @@ export async function validateDocxPackage(zip) {
403
447
  }
404
448
 
405
449
  if (commentsXml) {
406
- parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
450
+ const commentsDoc = parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
451
+ const idsFor = (doc, localName) => Array.from(doc.getElementsByTagNameNS(NS_W, localName))
452
+ .map(node => node.getAttribute('w:id') || node.getAttribute('id'))
453
+ .filter(id => id !== '');
454
+ const starts = new Set(idsFor(documentDoc, 'commentRangeStart'));
455
+ const ends = new Set(idsFor(documentDoc, 'commentRangeEnd'));
456
+ const references = new Set(idsFor(documentDoc, 'commentReference'));
457
+ const definitionIds = idsFor(commentsDoc, 'comment');
458
+ const definitions = new Set(definitionIds);
459
+ const sorted = ids => Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
460
+ const difference = (left, right) => sorted(new Set(Array.from(left).filter(id => !right.has(id))));
461
+
462
+ const duplicateDefinitions = sorted(new Set(definitionIds.filter((id, index) => definitionIds.indexOf(id) !== index)));
463
+ if (duplicateDefinitions.length > 0) {
464
+ throw new Error(`Validation failed: duplicate comment definitions for id(s): ${duplicateDefinitions.join(', ')}`);
465
+ }
466
+ const startsWithoutEnds = difference(starts, ends);
467
+ const endsWithoutStarts = difference(ends, starts);
468
+ if (startsWithoutEnds.length > 0 || endsWithoutStarts.length > 0) {
469
+ const parts = [];
470
+ if (startsWithoutEnds.length > 0) parts.push(`start without end: ${startsWithoutEnds.join(', ')}`);
471
+ if (endsWithoutStarts.length > 0) parts.push(`end without start: ${endsWithoutStarts.join(', ')}`);
472
+ throw new Error(`Validation failed: unbalanced comment range marker(s) (${parts.join('; ')})`);
473
+ }
474
+ const rangesWithoutReferences = difference(new Set([...starts, ...ends]), references);
475
+ if (rangesWithoutReferences.length > 0) {
476
+ throw new Error(`Validation failed: comment range has no reference for id(s): ${rangesWithoutReferences.join(', ')}`);
477
+ }
478
+ const usagesWithoutDefinitions = difference(new Set([...starts, ...ends, ...references]), definitions);
479
+ if (usagesWithoutDefinitions.length > 0) {
480
+ throw new Error(`Validation failed: comment usage has no definition for id(s): ${usagesWithoutDefinitions.join(', ')}`);
481
+ }
482
+ const replyCommentIds = new Set();
483
+ if (commentsExtendedXml) {
484
+ const extendedDoc = parseXmlStrictStandalone(commentsExtendedXml, COMMENTS_EXTENDED_PATH);
485
+ const paraIdToCommentId = new Map();
486
+ for (const comment of Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'))) {
487
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
488
+ const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
489
+ const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
490
+ if (paraId) paraIdToCommentId.set(paraId.toUpperCase(), id);
491
+ }
492
+ const knownParaIds = new Set(paraIdToCommentId.keys());
493
+ const extendedParaIds = new Set();
494
+ for (const entry of Array.from(extendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
495
+ const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId');
496
+ const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent');
497
+ if (!paraId || !knownParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: commentsExtended entry has no matching comment paragraph: ${paraId || '(missing)'}`);
498
+ if (extendedParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: duplicate commentsExtended entry for paraId: ${paraId}`);
499
+ extendedParaIds.add(paraId.toUpperCase());
500
+ if (parentParaId) {
501
+ if (!knownParaIds.has(parentParaId.toUpperCase())) throw new Error(`Validation failed: commentsExtended parent paragraph was not found: ${parentParaId}`);
502
+ replyCommentIds.add(paraIdToCommentId.get(paraId.toUpperCase()));
503
+ }
504
+ }
505
+ }
506
+ const definitionsWithoutReferences = difference(new Set([...definitions].filter(id => !replyCommentIds.has(id))), references);
507
+ if (definitionsWithoutReferences.length > 0) {
508
+ throw new Error(`Validation failed: comment definition has no document reference for id(s): ${definitionsWithoutReferences.join(', ')}`);
509
+ }
407
510
  } else if (hasCommentUsage) {
408
511
  throw new Error('Validation failed: comments used but part missing');
409
512
  }
@@ -451,4 +554,16 @@ export async function validateDocxPackage(zip) {
451
554
  throw new Error('Validation failed: comments rel missing');
452
555
  }
453
556
  }
557
+
558
+ if (commentsExtendedXml) {
559
+ const hasContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
560
+ (override.getAttribute('PartName') || '').toLowerCase() === '/word/commentsextended.xml'
561
+ && (override.getAttribute('ContentType') || '') === COMMENTS_EXTENDED_CONTENT_TYPE
562
+ );
563
+ const hasRelationship = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
564
+ (rel.getAttribute('Type') || '') === COMMENTS_EXTENDED_REL_TYPE
565
+ );
566
+ if (!hasContentType) throw new Error('Validation failed: commentsExtended CT override missing');
567
+ if (!hasRelationship) throw new Error('Validation failed: commentsExtended rel missing');
568
+ }
454
569
  }