@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,594 +1,634 @@
1
- /**
2
- * Revision/comment management utilities for OOXML payloads.
3
- */
4
-
5
- import { NS_W } from '../core/types.js';
6
- import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
7
- import { createWordElement } from '../core/word-xml.js';
8
- import { getXmlParseError } from '../core/xml-query.js';
9
-
10
- function getAttributeByLocalName(node, localName) {
11
- if (!node || !node.attributes) return '';
12
- for (const attr of Array.from(node.attributes)) {
13
- if ((attr.localName || '').toLowerCase() === localName.toLowerCase()) {
14
- return String(attr.value || '');
15
- }
16
- }
17
- return String(
18
- node.getAttribute?.(`w:${localName}`)
19
- || node.getAttribute?.(localName)
20
- || ''
21
- );
22
- }
23
-
24
- function normalizeAuthor(author) {
25
- return typeof author === 'string' ? author.trim().toLowerCase() : '';
26
- }
27
-
28
- function isElement(node) {
29
- return !!node && node.nodeType === 1;
30
- }
31
-
32
- function isWordElement(node, localName) {
33
- return isElement(node)
34
- && node.namespaceURI === NS_W
35
- && String(node.localName || '').toLowerCase() === localName.toLowerCase();
36
- }
37
-
38
- function getWordElementsByLocalName(xmlDoc, localName) {
39
- return Array.from(xmlDoc.getElementsByTagNameNS(NS_W, localName));
40
- }
41
-
42
- function resolveAuthorFilter(options = {}) {
43
- if (options?.allAuthors === true) {
44
- return { valid: true, allAuthors: true, normalizedAuthor: '' };
45
- }
46
- const normalizedAuthor = normalizeAuthor(options?.author);
47
- if (!normalizedAuthor) {
48
- return {
49
- valid: false,
50
- allAuthors: false,
51
- normalizedAuthor: '',
52
- warning: 'No author provided. Pass { author } or set { allAuthors: true }.'
53
- };
54
- }
55
- return { valid: true, allAuthors: false, normalizedAuthor };
56
- }
57
-
58
- function authorMatchesNode(node, filter) {
59
- if (filter.allAuthors) return true;
60
- const nodeAuthor = normalizeAuthor(getAttributeByLocalName(node, 'author'));
61
- return !!nodeAuthor && nodeAuthor === filter.normalizedAuthor;
62
- }
63
-
64
- function parseXmlWithWarnings(oxml, parseFailurePrefix) {
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
- }
76
- const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
77
- if (parsed.error || parseError) {
78
- const message = parsed.error?.message || parseError?.textContent || 'parse error';
79
- return {
80
- xmlDoc: null,
81
- serializer: null,
82
- isFragmentWrapped: false,
83
- warning: `${parseFailurePrefix}: ${message}`,
84
- warnings: parsed.warnings,
85
- error: { code: 'PARSE_ERROR', message }
86
- };
87
- }
88
- return {
89
- xmlDoc: parsed.doc,
90
- serializer: createSerializer(),
91
- isFragmentWrapped,
92
- warning: null,
93
- warnings: parsed.warnings,
94
- error: null
95
- };
96
- }
97
-
98
- function removeNode(node) {
99
- if (node?.parentNode) {
100
- node.parentNode.removeChild(node);
101
- return true;
102
- }
103
- return false;
104
- }
105
-
106
- function unwrapNode(node) {
107
- const parent = node?.parentNode;
108
- if (!parent) return false;
109
- while (node.firstChild) {
110
- parent.insertBefore(node.firstChild, node);
111
- }
112
- parent.removeChild(node);
113
- return true;
114
- }
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
-
154
- function isTableRowRevisionMarker(node) {
155
- const parent = node?.parentNode;
156
- return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
157
- }
158
-
159
- function isParagraphMarkRevisionMarker(node) {
160
- const rPr = node?.parentNode;
161
- const pPr = rPr?.parentNode;
162
- const paragraph = pPr?.parentNode;
163
- return isWordElement(rPr, 'rPr') && isWordElement(pPr, 'pPr') && isWordElement(paragraph, 'p');
164
- }
165
-
166
- function getContainingParagraphMarkRevision(node) {
167
- return isParagraphMarkRevisionMarker(node) ? node.parentNode.parentNode.parentNode : null;
168
- }
169
-
170
- function getNextWordParagraph(paragraph) {
171
- let cursor = paragraph?.nextSibling || null;
172
- while (cursor) {
173
- if (isWordElement(cursor, 'p')) return cursor;
174
- cursor = cursor.nextSibling;
175
- }
176
- return null;
177
- }
178
-
179
- function mergeParagraphIntoNextAndRemove(paragraph) {
180
- if (!paragraph?.parentNode) return false;
181
- const nextParagraph = getNextWordParagraph(paragraph);
182
- if (!nextParagraph) {
183
- if (isWordElement(paragraph.parentNode, 'tc') && getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1) {
184
- return false;
185
- }
186
- return removeNode(paragraph);
187
- }
188
-
189
- const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
190
- let insertionPoint = nextParagraph.firstChild || null;
191
- if (isWordElement(insertionPoint, 'pPr')) {
192
- insertionPoint = insertionPoint.nextSibling || null;
193
- }
194
- for (const child of childrenToMove) {
195
- nextParagraph.insertBefore(child, insertionPoint);
196
- }
197
- return removeNode(paragraph);
198
- }
199
-
200
- /**
201
- * Accepts tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
202
- * or all authors in the provided OOXML payload.
203
- *
204
- * @param {string} oxml
205
- * @param {{ author?: string, allAuthors?: boolean }} [options]
206
- * @returns {{ oxml: string, hasChanges: boolean, acceptedCount: number, warnings: string[] }}
207
- */
208
- export function acceptTrackedChangesInOoxml(oxml, options = {}) {
209
- const warnings = [];
210
- const filter = resolveAuthorFilter(options);
211
- if (!filter.valid) {
212
- return { oxml, hasChanges: false, acceptedCount: 0, warnings: [filter.warning] };
213
- }
214
-
215
- const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
216
- if (!parseResult.xmlDoc) {
217
- return {
218
- oxml,
219
- hasChanges: false,
220
- acceptedCount: 0,
221
- status: 'error',
222
- error: parseResult.error,
223
- warnings: [...(parseResult.warnings || []), parseResult.warning]
224
- };
225
- }
226
-
227
- const { xmlDoc, serializer } = parseResult;
228
- warnings.push(...(parseResult.warnings || []));
229
- let acceptedCount = 0;
230
-
231
- for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
232
- if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
233
- if (isParagraphMarkRevisionMarker(insNode)) {
234
- if (removeNode(insNode)) acceptedCount += 1;
235
- continue;
236
- }
237
- if (isTableRowRevisionMarker(insNode)) {
238
- if (removeNode(insNode)) acceptedCount += 1;
239
- continue;
240
- }
241
- if (unwrapNode(insNode)) acceptedCount += 1;
242
- }
243
-
244
- for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
245
- if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
246
- const paragraphMark = getContainingParagraphMarkRevision(delNode);
247
- if (paragraphMark) {
248
- if (mergeParagraphIntoNextAndRemove(paragraphMark)) acceptedCount += 1;
249
- continue;
250
- }
251
- if (isTableRowRevisionMarker(delNode)) {
252
- const rowNode = delNode.parentNode?.parentNode;
253
- if (removeNode(rowNode)) acceptedCount += 1;
254
- continue;
255
- }
256
- if (removeNode(delNode)) acceptedCount += 1;
257
- }
258
-
259
- for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
260
- if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
261
- if (removeNode(moveFromNode)) acceptedCount += 1;
262
- }
263
-
264
- for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
265
- if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
266
- if (unwrapNode(moveToNode)) acceptedCount += 1;
267
- }
268
-
269
- acceptedCount += removeMoveRangeMarkers(xmlDoc, filter);
270
-
271
- const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
272
- for (const localName of changeTags) {
273
- for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
274
- if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
275
- if (removeNode(changeNode)) acceptedCount += 1;
276
- }
277
- }
278
-
279
- const serializedOxml = parseResult.isFragmentWrapped
280
- ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
281
- : serializer.serializeToString(xmlDoc);
282
-
283
- return {
284
- oxml: serializedOxml,
285
- hasChanges: acceptedCount > 0,
286
- acceptedCount,
287
- warnings
288
- };
289
- }
290
-
291
- function convertDeletionTextNodes(xmlDoc, delNode) {
292
- for (const delTextNode of Array.from(delNode.getElementsByTagNameNS(NS_W, 'delText'))) {
293
- const normalText = createWordElement(xmlDoc, 'w:t');
294
- const spaceValue = delTextNode.getAttribute('xml:space');
295
- if (spaceValue) {
296
- normalText.setAttribute('xml:space', spaceValue);
297
- }
298
- while (delTextNode.firstChild) {
299
- normalText.appendChild(delTextNode.firstChild);
300
- }
301
- delTextNode.parentNode?.replaceChild(normalText, delTextNode);
302
- }
303
- }
304
-
305
- function rejectPropertyChangeNode(changeNode, localName) {
306
- const parent = changeNode?.parentNode;
307
- if (!parent) return false;
308
-
309
- const baseLocalName = localName.endsWith('Change')
310
- ? localName.slice(0, -'Change'.length)
311
- : '';
312
-
313
- if (
314
- !baseLocalName
315
- || String(parent.localName || '').toLowerCase() !== baseLocalName.toLowerCase()
316
- || parent.namespaceURI !== NS_W
317
- ) {
318
- return removeNode(changeNode);
319
- }
320
-
321
- const historicalNode = Array.from(changeNode.childNodes || []).find(
322
- child => child.nodeType === 1 && child.namespaceURI === NS_W
323
- && String(child.localName || '').toLowerCase() === baseLocalName.toLowerCase()
324
- );
325
-
326
- if (!historicalNode) {
327
- return removeNode(changeNode);
328
- }
329
-
330
- const toAppend = Array.from(historicalNode.childNodes || []);
331
- while (parent.firstChild) {
332
- parent.removeChild(parent.firstChild);
333
- }
334
- for (const node of toAppend) {
335
- const clone = xmlDocImportNode(parent.ownerDocument, node);
336
- parent.appendChild(clone);
337
- }
338
- return true;
339
- }
340
-
341
- function xmlDocImportNode(xmlDoc, node) {
342
- if (xmlDoc && typeof xmlDoc.importNode === 'function') {
343
- return xmlDoc.importNode(node, true);
344
- }
345
- return node.cloneNode(true);
346
- }
347
-
348
- function collectMoveRangeStartIds(xmlDoc, localName, filter) {
349
- const ids = new Set();
350
- for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
351
- if (!authorMatchesNode(node, filter)) continue;
352
- const id = getAttributeByLocalName(node, 'id');
353
- if (id) ids.add(id);
354
- }
355
- return ids;
356
- }
357
-
358
- function removeMoveRangeMarkers(xmlDoc, filter) {
359
- let removed = 0;
360
- const moveFromIds = collectMoveRangeStartIds(xmlDoc, 'moveFromRangeStart', filter);
361
- const moveToIds = collectMoveRangeStartIds(xmlDoc, 'moveToRangeStart', filter);
362
- const markerSpecs = [
363
- ['moveFromRangeStart', moveFromIds, true],
364
- ['moveFromRangeEnd', moveFromIds, false],
365
- ['moveToRangeStart', moveToIds, true],
366
- ['moveToRangeEnd', moveToIds, false]
367
- ];
368
-
369
- for (const [localName, ids, isStart] of markerSpecs) {
370
- for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
371
- if (!node.parentNode) continue;
372
- const id = getAttributeByLocalName(node, 'id');
373
- if (!id) continue;
374
- if (filter.allAuthors || ids.has(id) || (isStart && authorMatchesNode(node, filter))) {
375
- if (removeNode(node)) removed += 1;
376
- }
377
- }
378
- }
379
-
380
- return removed;
381
- }
382
-
383
- /**
384
- * Rejects tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
385
- * or all authors in the provided OOXML payload.
386
- *
387
- * @param {string} oxml
388
- * @param {{ author?: string, allAuthors?: boolean }} [options]
389
- * @returns {{ oxml: string, hasChanges: boolean, rejectedCount: number, warnings: string[] }}
390
- */
391
- export function rejectTrackedChangesInOoxml(oxml, options = {}) {
392
- const warnings = [];
393
- const filter = resolveAuthorFilter(options);
394
- if (!filter.valid) {
395
- return { oxml, hasChanges: false, rejectedCount: 0, warnings: [filter.warning] };
396
- }
397
-
398
- const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
399
- if (!parseResult.xmlDoc) {
400
- return {
401
- oxml,
402
- hasChanges: false,
403
- rejectedCount: 0,
404
- status: 'error',
405
- error: parseResult.error,
406
- warnings: [...(parseResult.warnings || []), parseResult.warning]
407
- };
408
- }
409
-
410
- const { xmlDoc, serializer } = parseResult;
411
- warnings.push(...(parseResult.warnings || []));
412
- let rejectedCount = 0;
413
-
414
- for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
415
- if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
416
- const paragraphMark = getContainingParagraphMarkRevision(insNode);
417
- if (paragraphMark) {
418
- if (mergeParagraphIntoNextAndRemove(paragraphMark)) rejectedCount += 1;
419
- continue;
420
- }
421
- if (isTableRowRevisionMarker(insNode)) {
422
- const rowNode = insNode.parentNode?.parentNode;
423
- if (removeNode(rowNode)) rejectedCount += 1;
424
- continue;
425
- }
426
- if (removeNode(insNode)) rejectedCount += 1;
427
- }
428
-
429
- for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
430
- if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
431
- if (isParagraphMarkRevisionMarker(delNode)) {
432
- if (removeNode(delNode)) rejectedCount += 1;
433
- continue;
434
- }
435
- if (isTableRowRevisionMarker(delNode)) {
436
- if (removeNode(delNode)) rejectedCount += 1;
437
- continue;
438
- }
439
- convertDeletionTextNodes(xmlDoc, delNode);
440
- if (unwrapNode(delNode)) rejectedCount += 1;
441
- }
442
-
443
- for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
444
- if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
445
- convertDeletionTextNodes(xmlDoc, moveFromNode);
446
- if (unwrapNode(moveFromNode)) rejectedCount += 1;
447
- }
448
-
449
- for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
450
- if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
451
- if (removeNode(moveToNode)) rejectedCount += 1;
452
- }
453
-
454
- rejectedCount += removeMoveRangeMarkers(xmlDoc, filter);
455
-
456
- const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
457
- for (const localName of changeTags) {
458
- for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
459
- if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
460
- if (rejectPropertyChangeNode(changeNode, localName)) rejectedCount += 1;
461
- }
462
- }
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
-
470
- return {
471
- oxml: serializedOxml,
472
- hasChanges: rejectedCount > 0,
473
- rejectedCount,
474
- warnings
475
- };
476
- }
477
-
478
- function collectCommentTargetIds(xmlDoc, filter) {
479
- const targetIds = new Set();
480
- const commentNodes = getWordElementsByLocalName(xmlDoc, 'comment');
481
-
482
- for (const commentNode of commentNodes) {
483
- if (!authorMatchesNode(commentNode, filter)) continue;
484
- const id = getAttributeByLocalName(commentNode, 'id');
485
- if (id) targetIds.add(id);
486
- }
487
-
488
- return { targetIds, commentNodes };
489
- }
490
-
491
- function removeCommentNodesById(commentNodes, targetIds) {
492
- let removed = 0;
493
- for (const commentNode of commentNodes) {
494
- const id = getAttributeByLocalName(commentNode, 'id');
495
- if (!id || !targetIds.has(id)) continue;
496
- if (removeNode(commentNode)) removed += 1;
497
- }
498
- return removed;
499
- }
500
-
501
- function runIsOnlyCommentReference(runNode) {
502
- if (!isWordElement(runNode, 'r')) return false;
503
- const meaningfulChildren = Array.from(runNode.childNodes || []).filter(child => {
504
- if (child.nodeType === 3) return String(child.nodeValue || '').trim().length > 0;
505
- if (child.nodeType !== 1) return false;
506
- if (child.namespaceURI !== NS_W) return true;
507
- const local = String(child.localName || '').toLowerCase();
508
- return local !== 'rpr' && local !== 'commentreference';
509
- });
510
- return meaningfulChildren.length === 0;
511
- }
512
-
513
- function removeCommentAnchors(xmlDoc, targetIds) {
514
- let removed = 0;
515
- const anchorTags = ['commentRangeStart', 'commentRangeEnd', 'commentReference'];
516
-
517
- for (const localName of anchorTags) {
518
- for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
519
- if (!node.parentNode) continue;
520
- const id = getAttributeByLocalName(node, 'id');
521
- if (!id || !targetIds.has(id)) continue;
522
-
523
- if (localName === 'commentReference' && runIsOnlyCommentReference(node.parentNode)) {
524
- if (removeNode(node.parentNode)) {
525
- removed += 1;
526
- }
527
- continue;
528
- }
529
- if (removeNode(node)) {
530
- removed += 1;
531
- }
532
- }
533
- }
534
- return removed;
535
- }
536
-
537
- /**
538
- * Deletes comments authored by one user (or all users) and removes matching
539
- * comment anchors/references from the OOXML payload.
540
- *
541
- * @param {string} oxml
542
- * @param {{ author?: string, allAuthors?: boolean }} [options]
543
- * @returns {{ oxml: string, hasChanges: boolean, commentsRemoved: number, referencesRemoved: number, warnings: string[] }}
544
- */
545
- export function deleteCommentsByAuthorInOoxml(oxml, options = {}) {
546
- const warnings = [];
547
- const filter = resolveAuthorFilter(options);
548
- if (!filter.valid) {
549
- return {
550
- oxml,
551
- hasChanges: false,
552
- commentsRemoved: 0,
553
- referencesRemoved: 0,
554
- warnings: [filter.warning]
555
- };
556
- }
557
-
558
- const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
559
- if (!parseResult.xmlDoc) {
560
- return {
561
- oxml,
562
- hasChanges: false,
563
- commentsRemoved: 0,
564
- referencesRemoved: 0,
565
- status: 'error',
566
- error: parseResult.error,
567
- warnings: [...(parseResult.warnings || []), parseResult.warning]
568
- };
569
- }
570
-
571
- const { xmlDoc, serializer } = parseResult;
572
- warnings.push(...(parseResult.warnings || []));
573
- const { targetIds, commentNodes } = collectCommentTargetIds(xmlDoc, filter);
574
-
575
- if (filter.allAuthors) {
576
- for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
577
- for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
578
- const id = getAttributeByLocalName(node, 'id');
579
- if (id) targetIds.add(id);
580
- }
581
- }
582
- }
583
-
584
- const commentsRemoved = removeCommentNodesById(commentNodes, targetIds);
585
- const referencesRemoved = removeCommentAnchors(xmlDoc, targetIds);
586
-
587
- return {
588
- oxml: serializer.serializeToString(xmlDoc),
589
- hasChanges: commentsRemoved > 0 || referencesRemoved > 0,
590
- commentsRemoved,
591
- referencesRemoved,
592
- warnings
593
- };
594
- }
1
+ /**
2
+ * Revision/comment management utilities for OOXML payloads.
3
+ */
4
+
5
+ import { NS_W } from '../core/types.js';
6
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
7
+ import { createWordElement } from '../core/word-xml.js';
8
+ import { getXmlParseError } from '../core/xml-query.js';
9
+
10
+ function getAttributeByLocalName(node, localName) {
11
+ if (!node || !node.attributes) return '';
12
+ for (const attr of Array.from(node.attributes)) {
13
+ if ((attr.localName || '').toLowerCase() === localName.toLowerCase()) {
14
+ return String(attr.value || '');
15
+ }
16
+ }
17
+ return String(
18
+ node.getAttribute?.(`w:${localName}`)
19
+ || node.getAttribute?.(localName)
20
+ || ''
21
+ );
22
+ }
23
+
24
+ function normalizeAuthor(author) {
25
+ return typeof author === 'string' ? author.trim().toLowerCase() : '';
26
+ }
27
+
28
+ function isElement(node) {
29
+ return !!node && node.nodeType === 1;
30
+ }
31
+
32
+ function isWordElement(node, localName) {
33
+ return isElement(node)
34
+ && node.namespaceURI === NS_W
35
+ && String(node.localName || '').toLowerCase() === localName.toLowerCase();
36
+ }
37
+
38
+ function getWordElementsByLocalName(xmlDoc, localName) {
39
+ return Array.from(xmlDoc.getElementsByTagNameNS(NS_W, localName));
40
+ }
41
+
42
+ function resolveAuthorFilter(options = {}) {
43
+ if (options?.allAuthors === true) {
44
+ return { valid: true, allAuthors: true, normalizedAuthor: '' };
45
+ }
46
+ const normalizedAuthor = normalizeAuthor(options?.author);
47
+ if (!normalizedAuthor) {
48
+ return {
49
+ valid: false,
50
+ allAuthors: false,
51
+ normalizedAuthor: '',
52
+ warning: 'No author provided. Pass { author } or set { allAuthors: true }.'
53
+ };
54
+ }
55
+ return { valid: true, allAuthors: false, normalizedAuthor };
56
+ }
57
+
58
+ function authorMatchesNode(node, filter) {
59
+ if (filter.allAuthors) return true;
60
+ const nodeAuthor = normalizeAuthor(getAttributeByLocalName(node, 'author'));
61
+ return !!nodeAuthor && nodeAuthor === filter.normalizedAuthor;
62
+ }
63
+
64
+ function parseXmlWithWarnings(oxml, parseFailurePrefix) {
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
+ }
76
+ const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
77
+ if (parsed.error || parseError) {
78
+ const message = parsed.error?.message || parseError?.textContent || 'parse error';
79
+ return {
80
+ xmlDoc: null,
81
+ serializer: null,
82
+ isFragmentWrapped: false,
83
+ warning: `${parseFailurePrefix}: ${message}`,
84
+ warnings: parsed.warnings,
85
+ error: { code: 'PARSE_ERROR', message }
86
+ };
87
+ }
88
+ return {
89
+ xmlDoc: parsed.doc,
90
+ serializer: createSerializer(),
91
+ isFragmentWrapped,
92
+ warning: null,
93
+ warnings: parsed.warnings,
94
+ error: null
95
+ };
96
+ }
97
+
98
+ function removeNode(node) {
99
+ if (node?.parentNode) {
100
+ node.parentNode.removeChild(node);
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function unwrapNode(node) {
107
+ const parent = node?.parentNode;
108
+ if (!parent) return false;
109
+ while (node.firstChild) {
110
+ parent.insertBefore(node.firstChild, node);
111
+ }
112
+ parent.removeChild(node);
113
+ return true;
114
+ }
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
+
154
+ function isTableRowRevisionMarker(node) {
155
+ const parent = node?.parentNode;
156
+ return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
157
+ }
158
+
159
+ function isParagraphMarkRevisionMarker(node) {
160
+ const rPr = node?.parentNode;
161
+ const pPr = rPr?.parentNode;
162
+ const paragraph = pPr?.parentNode;
163
+ return isWordElement(rPr, 'rPr') && isWordElement(pPr, 'pPr') && isWordElement(paragraph, 'p');
164
+ }
165
+
166
+ function getContainingParagraphMarkRevision(node) {
167
+ return isParagraphMarkRevisionMarker(node) ? node.parentNode.parentNode.parentNode : null;
168
+ }
169
+
170
+ function getNextWordParagraph(paragraph) {
171
+ let cursor = paragraph?.nextSibling || null;
172
+ while (cursor) {
173
+ if (isWordElement(cursor, 'p')) return cursor;
174
+ cursor = cursor.nextSibling;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Resolves a paragraph whose mark is being removed. `view` is 'accept' or 'reject'.
181
+ * A table cell must keep its last paragraph: Word keeps it on Reject even when the
182
+ * cell has other paragraphs, and on Accept only when it is the cell's sole paragraph
183
+ * (otherwise the previous paragraph absorbs the mark). `keptCellParagraphs`
184
+ * collects the sole kept paragraphs so Accept can drop fully-deleted tables.
185
+ */
186
+ function mergeParagraphIntoNextAndRemove(paragraph, view = 'accept', keptCellParagraphs = null) {
187
+ if (!paragraph?.parentNode) return false;
188
+ const nextParagraph = getNextWordParagraph(paragraph);
189
+ if (!nextParagraph) {
190
+ if (isWordElement(paragraph.parentNode, 'tc')) {
191
+ const sole = getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1;
192
+ if (view === 'reject' || sole) {
193
+ if (sole && keptCellParagraphs) keptCellParagraphs.add(paragraph);
194
+ return false;
195
+ }
196
+ }
197
+ return removeNode(paragraph);
198
+ }
199
+
200
+ const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
201
+ let insertionPoint = nextParagraph.firstChild || null;
202
+ if (isWordElement(insertionPoint, 'pPr')) {
203
+ insertionPoint = insertionPoint.nextSibling || null;
204
+ }
205
+ for (const child of childrenToMove) {
206
+ nextParagraph.insertBefore(child, insertionPoint);
207
+ }
208
+ return removeNode(paragraph);
209
+ }
210
+
211
+ /**
212
+ * Accepts tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
213
+ * or all authors in the provided OOXML payload.
214
+ *
215
+ * @param {string} oxml
216
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
217
+ * @returns {{ oxml: string, hasChanges: boolean, acceptedCount: number, warnings: string[] }}
218
+ */
219
+ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
220
+ const warnings = [];
221
+ const filter = resolveAuthorFilter(options);
222
+ if (!filter.valid) {
223
+ return { oxml, hasChanges: false, acceptedCount: 0, warnings: [filter.warning] };
224
+ }
225
+
226
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
227
+ if (!parseResult.xmlDoc) {
228
+ return {
229
+ oxml,
230
+ hasChanges: false,
231
+ acceptedCount: 0,
232
+ status: 'error',
233
+ error: parseResult.error,
234
+ warnings: [...(parseResult.warnings || []), parseResult.warning]
235
+ };
236
+ }
237
+
238
+ const { xmlDoc, serializer } = parseResult;
239
+ warnings.push(...(parseResult.warnings || []));
240
+ let acceptedCount = 0;
241
+ const keptCellParagraphs = new Set();
242
+
243
+ for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
244
+ if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
245
+ if (isParagraphMarkRevisionMarker(insNode)) {
246
+ if (removeNode(insNode)) acceptedCount += 1;
247
+ continue;
248
+ }
249
+ if (isTableRowRevisionMarker(insNode)) {
250
+ if (removeNode(insNode)) acceptedCount += 1;
251
+ continue;
252
+ }
253
+ if (unwrapNode(insNode)) acceptedCount += 1;
254
+ }
255
+
256
+ for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
257
+ if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
258
+ const paragraphMark = getContainingParagraphMarkRevision(delNode);
259
+ if (paragraphMark) {
260
+ if (mergeParagraphIntoNextAndRemove(paragraphMark, 'accept', keptCellParagraphs)) acceptedCount += 1;
261
+ else if (removeNode(delNode)) acceptedCount += 1;
262
+ continue;
263
+ }
264
+ if (isTableRowRevisionMarker(delNode)) {
265
+ const rowNode = delNode.parentNode?.parentNode;
266
+ if (removeNode(rowNode)) acceptedCount += 1;
267
+ continue;
268
+ }
269
+ if (removeNode(delNode)) acceptedCount += 1;
270
+ }
271
+
272
+ // Word removes a table outright when accepting leaves every cell with only a
273
+ // deleted, content-free paragraph.
274
+ for (const tableNode of getWordElementsByLocalName(xmlDoc, 'tbl')) {
275
+ if (!tableNode.parentNode) continue;
276
+ const cells = getWordElementsByLocalName(tableNode, 'tc');
277
+ const allDeleted = cells.length > 0 && cells.every(cell => {
278
+ const paragraphs = getWordElementsByLocalName(cell, 'p');
279
+ return paragraphs.length === 1 && keptCellParagraphs.has(paragraphs[0])
280
+ && !Array.from(paragraphs[0].childNodes || []).some(child => !isWordElement(child, 'pPr'));
281
+ });
282
+ if (allDeleted && removeNode(tableNode)) acceptedCount += 1;
283
+ }
284
+
285
+ for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
286
+ if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
287
+ if (removeNode(moveFromNode)) acceptedCount += 1;
288
+ }
289
+
290
+ for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
291
+ if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
292
+ if (unwrapNode(moveToNode)) acceptedCount += 1;
293
+ }
294
+
295
+ acceptedCount += removeMoveRangeMarkers(xmlDoc, filter);
296
+
297
+ const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
298
+ for (const localName of changeTags) {
299
+ for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
300
+ if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
301
+ if (removeNode(changeNode)) acceptedCount += 1;
302
+ }
303
+ }
304
+
305
+ const serializedOxml = parseResult.isFragmentWrapped
306
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
307
+ : serializer.serializeToString(xmlDoc);
308
+
309
+ return {
310
+ oxml: serializedOxml,
311
+ hasChanges: acceptedCount > 0,
312
+ acceptedCount,
313
+ warnings
314
+ };
315
+ }
316
+
317
+ function convertDeletionTextNodes(xmlDoc, delNode) {
318
+ for (const delTextNode of Array.from(delNode.getElementsByTagNameNS(NS_W, 'delText'))) {
319
+ const normalText = createWordElement(xmlDoc, 'w:t');
320
+ const spaceValue = delTextNode.getAttribute('xml:space');
321
+ if (spaceValue) {
322
+ normalText.setAttribute('xml:space', spaceValue);
323
+ }
324
+ while (delTextNode.firstChild) {
325
+ normalText.appendChild(delTextNode.firstChild);
326
+ }
327
+ delTextNode.parentNode?.replaceChild(normalText, delTextNode);
328
+ }
329
+ }
330
+
331
+ function rejectPropertyChangeNode(changeNode, localName) {
332
+ const parent = changeNode?.parentNode;
333
+ if (!parent) return false;
334
+
335
+ const baseLocalName = localName.endsWith('Change')
336
+ ? localName.slice(0, -'Change'.length)
337
+ : '';
338
+
339
+ if (
340
+ !baseLocalName
341
+ || String(parent.localName || '').toLowerCase() !== baseLocalName.toLowerCase()
342
+ || parent.namespaceURI !== NS_W
343
+ ) {
344
+ return removeNode(changeNode);
345
+ }
346
+
347
+ const historicalNode = Array.from(changeNode.childNodes || []).find(
348
+ child => child.nodeType === 1 && child.namespaceURI === NS_W
349
+ && String(child.localName || '').toLowerCase() === baseLocalName.toLowerCase()
350
+ );
351
+
352
+ if (!historicalNode) {
353
+ return removeNode(changeNode);
354
+ }
355
+
356
+ const toAppend = Array.from(historicalNode.childNodes || []);
357
+ // pPrChange records only paragraph properties: the paragraph-mark rPr and sectPr
358
+ // live in the same pPr but are not part of the recorded change, so they survive.
359
+ const keepNames = baseLocalName.toLowerCase() === 'ppr' ? ['rpr', 'sectpr'] : [];
360
+ const kept = Array.from(parent.childNodes || []).filter(child => child.nodeType === 1
361
+ && child.namespaceURI === NS_W && keepNames.includes(String(child.localName || '').toLowerCase()));
362
+ while (parent.firstChild) {
363
+ parent.removeChild(parent.firstChild);
364
+ }
365
+ for (const node of toAppend) {
366
+ const clone = xmlDocImportNode(parent.ownerDocument, node);
367
+ parent.appendChild(clone);
368
+ }
369
+ for (const node of kept) parent.appendChild(node);
370
+ return true;
371
+ }
372
+
373
+ function xmlDocImportNode(xmlDoc, node) {
374
+ if (xmlDoc && typeof xmlDoc.importNode === 'function') {
375
+ return xmlDoc.importNode(node, true);
376
+ }
377
+ return node.cloneNode(true);
378
+ }
379
+
380
+ function collectMoveRangeStartIds(xmlDoc, localName, filter) {
381
+ const ids = new Set();
382
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
383
+ if (!authorMatchesNode(node, filter)) continue;
384
+ const id = getAttributeByLocalName(node, 'id');
385
+ if (id) ids.add(id);
386
+ }
387
+ return ids;
388
+ }
389
+
390
+ function removeMoveRangeMarkers(xmlDoc, filter) {
391
+ let removed = 0;
392
+ const moveFromIds = collectMoveRangeStartIds(xmlDoc, 'moveFromRangeStart', filter);
393
+ const moveToIds = collectMoveRangeStartIds(xmlDoc, 'moveToRangeStart', filter);
394
+ const markerSpecs = [
395
+ ['moveFromRangeStart', moveFromIds, true],
396
+ ['moveFromRangeEnd', moveFromIds, false],
397
+ ['moveToRangeStart', moveToIds, true],
398
+ ['moveToRangeEnd', moveToIds, false]
399
+ ];
400
+
401
+ for (const [localName, ids, isStart] of markerSpecs) {
402
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
403
+ if (!node.parentNode) continue;
404
+ const id = getAttributeByLocalName(node, 'id');
405
+ if (!id) continue;
406
+ if (filter.allAuthors || ids.has(id) || (isStart && authorMatchesNode(node, filter))) {
407
+ if (removeNode(node)) removed += 1;
408
+ }
409
+ }
410
+ }
411
+
412
+ return removed;
413
+ }
414
+
415
+ /**
416
+ * Rejects tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
417
+ * or all authors in the provided OOXML payload.
418
+ *
419
+ * @param {string} oxml
420
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
421
+ * @returns {{ oxml: string, hasChanges: boolean, rejectedCount: number, warnings: string[] }}
422
+ */
423
+ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
424
+ const warnings = [];
425
+ const filter = resolveAuthorFilter(options);
426
+ if (!filter.valid) {
427
+ return { oxml, hasChanges: false, rejectedCount: 0, warnings: [filter.warning] };
428
+ }
429
+
430
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
431
+ if (!parseResult.xmlDoc) {
432
+ return {
433
+ oxml,
434
+ hasChanges: false,
435
+ rejectedCount: 0,
436
+ status: 'error',
437
+ error: parseResult.error,
438
+ warnings: [...(parseResult.warnings || []), parseResult.warning]
439
+ };
440
+ }
441
+
442
+ const { xmlDoc, serializer } = parseResult;
443
+ warnings.push(...(parseResult.warnings || []));
444
+ let rejectedCount = 0;
445
+
446
+ for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
447
+ if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
448
+ const paragraphMark = getContainingParagraphMarkRevision(insNode);
449
+ if (paragraphMark) {
450
+ if (mergeParagraphIntoNextAndRemove(paragraphMark, 'reject')) rejectedCount += 1;
451
+ else if (removeNode(insNode)) rejectedCount += 1;
452
+ continue;
453
+ }
454
+ if (isTableRowRevisionMarker(insNode)) {
455
+ const rowNode = insNode.parentNode?.parentNode;
456
+ if (removeNode(rowNode)) rejectedCount += 1;
457
+ continue;
458
+ }
459
+ if (removeNode(insNode)) rejectedCount += 1;
460
+ }
461
+
462
+ // A table whose every row was an inserted row is itself an inserted table: drop the empty shell.
463
+ for (const tableNode of getWordElementsByLocalName(xmlDoc, 'tbl')) {
464
+ if (!tableNode.parentNode) continue;
465
+ const hasRow = Array.from(tableNode.childNodes || []).some(child => isWordElement(child, 'tr'));
466
+ if (!hasRow && removeNode(tableNode)) rejectedCount += 1;
467
+ }
468
+
469
+ for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
470
+ if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
471
+ if (isParagraphMarkRevisionMarker(delNode)) {
472
+ if (removeNode(delNode)) rejectedCount += 1;
473
+ continue;
474
+ }
475
+ if (isTableRowRevisionMarker(delNode)) {
476
+ if (removeNode(delNode)) rejectedCount += 1;
477
+ continue;
478
+ }
479
+ convertDeletionTextNodes(xmlDoc, delNode);
480
+ if (unwrapNode(delNode)) rejectedCount += 1;
481
+ }
482
+
483
+ for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
484
+ if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
485
+ convertDeletionTextNodes(xmlDoc, moveFromNode);
486
+ if (unwrapNode(moveFromNode)) rejectedCount += 1;
487
+ }
488
+
489
+ for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
490
+ if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
491
+ if (removeNode(moveToNode)) rejectedCount += 1;
492
+ }
493
+
494
+ rejectedCount += removeMoveRangeMarkers(xmlDoc, filter);
495
+
496
+ const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
497
+ for (const localName of changeTags) {
498
+ for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
499
+ if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
500
+ if (rejectPropertyChangeNode(changeNode, localName)) rejectedCount += 1;
501
+ }
502
+ }
503
+
504
+ coalesceAdjacentCompatibleInsertions(xmlDoc);
505
+
506
+ const serializedOxml = parseResult.isFragmentWrapped
507
+ ? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
508
+ : serializer.serializeToString(xmlDoc);
509
+
510
+ return {
511
+ oxml: serializedOxml,
512
+ hasChanges: rejectedCount > 0,
513
+ rejectedCount,
514
+ warnings
515
+ };
516
+ }
517
+
518
+ function collectCommentTargetIds(xmlDoc, filter) {
519
+ const targetIds = new Set();
520
+ const commentNodes = getWordElementsByLocalName(xmlDoc, 'comment');
521
+
522
+ for (const commentNode of commentNodes) {
523
+ if (!authorMatchesNode(commentNode, filter)) continue;
524
+ const id = getAttributeByLocalName(commentNode, 'id');
525
+ if (id) targetIds.add(id);
526
+ }
527
+
528
+ return { targetIds, commentNodes };
529
+ }
530
+
531
+ function removeCommentNodesById(commentNodes, targetIds) {
532
+ let removed = 0;
533
+ for (const commentNode of commentNodes) {
534
+ const id = getAttributeByLocalName(commentNode, 'id');
535
+ if (!id || !targetIds.has(id)) continue;
536
+ if (removeNode(commentNode)) removed += 1;
537
+ }
538
+ return removed;
539
+ }
540
+
541
+ function runIsOnlyCommentReference(runNode) {
542
+ if (!isWordElement(runNode, 'r')) return false;
543
+ const meaningfulChildren = Array.from(runNode.childNodes || []).filter(child => {
544
+ if (child.nodeType === 3) return String(child.nodeValue || '').trim().length > 0;
545
+ if (child.nodeType !== 1) return false;
546
+ if (child.namespaceURI !== NS_W) return true;
547
+ const local = String(child.localName || '').toLowerCase();
548
+ return local !== 'rpr' && local !== 'commentreference';
549
+ });
550
+ return meaningfulChildren.length === 0;
551
+ }
552
+
553
+ function removeCommentAnchors(xmlDoc, targetIds) {
554
+ let removed = 0;
555
+ const anchorTags = ['commentRangeStart', 'commentRangeEnd', 'commentReference'];
556
+
557
+ for (const localName of anchorTags) {
558
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
559
+ if (!node.parentNode) continue;
560
+ const id = getAttributeByLocalName(node, 'id');
561
+ if (!id || !targetIds.has(id)) continue;
562
+
563
+ if (localName === 'commentReference' && runIsOnlyCommentReference(node.parentNode)) {
564
+ if (removeNode(node.parentNode)) {
565
+ removed += 1;
566
+ }
567
+ continue;
568
+ }
569
+ if (removeNode(node)) {
570
+ removed += 1;
571
+ }
572
+ }
573
+ }
574
+ return removed;
575
+ }
576
+
577
+ /**
578
+ * Deletes comments authored by one user (or all users) and removes matching
579
+ * comment anchors/references from the OOXML payload.
580
+ *
581
+ * @param {string} oxml
582
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
583
+ * @returns {{ oxml: string, hasChanges: boolean, commentsRemoved: number, referencesRemoved: number, warnings: string[] }}
584
+ */
585
+ export function deleteCommentsByAuthorInOoxml(oxml, options = {}) {
586
+ const warnings = [];
587
+ const filter = resolveAuthorFilter(options);
588
+ if (!filter.valid) {
589
+ return {
590
+ oxml,
591
+ hasChanges: false,
592
+ commentsRemoved: 0,
593
+ referencesRemoved: 0,
594
+ warnings: [filter.warning]
595
+ };
596
+ }
597
+
598
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
599
+ if (!parseResult.xmlDoc) {
600
+ return {
601
+ oxml,
602
+ hasChanges: false,
603
+ commentsRemoved: 0,
604
+ referencesRemoved: 0,
605
+ status: 'error',
606
+ error: parseResult.error,
607
+ warnings: [...(parseResult.warnings || []), parseResult.warning]
608
+ };
609
+ }
610
+
611
+ const { xmlDoc, serializer } = parseResult;
612
+ warnings.push(...(parseResult.warnings || []));
613
+ const { targetIds, commentNodes } = collectCommentTargetIds(xmlDoc, filter);
614
+
615
+ if (filter.allAuthors) {
616
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
617
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
618
+ const id = getAttributeByLocalName(node, 'id');
619
+ if (id) targetIds.add(id);
620
+ }
621
+ }
622
+ }
623
+
624
+ const commentsRemoved = removeCommentNodesById(commentNodes, targetIds);
625
+ const referencesRemoved = removeCommentAnchors(xmlDoc, targetIds);
626
+
627
+ return {
628
+ oxml: serializer.serializeToString(xmlDoc),
629
+ hasChanges: commentsRemoved > 0 || referencesRemoved > 0,
630
+ commentsRemoved,
631
+ referencesRemoved,
632
+ warnings
633
+ };
634
+ }