@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /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) {
@@ -95,6 +113,44 @@ function unwrapNode(node) {
95
113
  return true;
96
114
  }
97
115
 
116
+ function nextElementSibling(node) {
117
+ let sibling = node?.nextSibling || null;
118
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
119
+ return sibling;
120
+ }
121
+
122
+ function revisionMetadataWithoutId(node) {
123
+ return Array.from(node?.attributes || [])
124
+ .filter(attribute => String(attribute.localName || attribute.name || '').toLowerCase() !== 'id')
125
+ .map(attribute => `${attribute.namespaceURI || ''}|${attribute.localName || attribute.name}=${attribute.value}`)
126
+ .sort()
127
+ .join('\n');
128
+ }
129
+
130
+ function coalesceAdjacentCompatibleInsertions(xmlDoc) {
131
+ let coalesced = 0;
132
+ const insertions = getWordElementsByLocalName(xmlDoc, 'ins');
133
+
134
+ for (const insertion of insertions) {
135
+ if (!insertion.parentNode || isParagraphMarkRevisionMarker(insertion)) continue;
136
+ let sibling = nextElementSibling(insertion);
137
+ while (
138
+ isWordElement(sibling, 'ins')
139
+ && normalizeAuthor(getAttributeByLocalName(sibling, 'author'))
140
+ === normalizeAuthor(getAttributeByLocalName(insertion, 'author'))
141
+ && revisionMetadataWithoutId(sibling) === revisionMetadataWithoutId(insertion)
142
+ ) {
143
+ const next = nextElementSibling(sibling);
144
+ while (sibling.firstChild) insertion.appendChild(sibling.firstChild);
145
+ sibling.parentNode?.removeChild(sibling);
146
+ coalesced += 1;
147
+ sibling = next;
148
+ }
149
+ }
150
+
151
+ return coalesced;
152
+ }
153
+
98
154
  function isTableRowRevisionMarker(node) {
99
155
  const parent = node?.parentNode;
100
156
  return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
@@ -124,11 +180,17 @@ function mergeParagraphIntoNextAndRemove(paragraph) {
124
180
  if (!paragraph?.parentNode) return false;
125
181
  const nextParagraph = getNextWordParagraph(paragraph);
126
182
  if (!nextParagraph) {
183
+ if (isWordElement(paragraph.parentNode, 'tc') && getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1) {
184
+ return false;
185
+ }
127
186
  return removeNode(paragraph);
128
187
  }
129
188
 
130
189
  const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
131
- const insertionPoint = nextParagraph.firstChild || null;
190
+ let insertionPoint = nextParagraph.firstChild || null;
191
+ if (isWordElement(insertionPoint, 'pPr')) {
192
+ insertionPoint = insertionPoint.nextSibling || null;
193
+ }
132
194
  for (const child of childrenToMove) {
133
195
  nextParagraph.insertBefore(child, insertionPoint);
134
196
  }
@@ -214,8 +276,12 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
214
276
  }
215
277
  }
216
278
 
279
+ const serializedOxml = parseResult.isFragmentWrapped
280
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
281
+ : serializer.serializeToString(xmlDoc);
282
+
217
283
  return {
218
- oxml: serializer.serializeToString(xmlDoc),
284
+ oxml: serializedOxml,
219
285
  hasChanges: acceptedCount > 0,
220
286
  acceptedCount,
221
287
  warnings
@@ -395,8 +461,14 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
395
461
  }
396
462
  }
397
463
 
464
+ coalesceAdjacentCompatibleInsertions(xmlDoc);
465
+
466
+ const serializedOxml = parseResult.isFragmentWrapped
467
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
468
+ : serializer.serializeToString(xmlDoc);
469
+
398
470
  return {
399
- oxml: serializer.serializeToString(xmlDoc),
471
+ oxml: serializedOxml,
400
472
  hasChanges: rejectedCount > 0,
401
473
  rejectedCount,
402
474
  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
  }