@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
@@ -0,0 +1,209 @@
1
+ import { NS_W } from './types.js';
2
+
3
+ function localName(node) {
4
+ return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
5
+ }
6
+
7
+ /** Returns whether a node contributes to the selected revision view. */
8
+ export function isNodeVisibleInRevisionView(node, boundary = null, revisionView = 'accepted') {
9
+ const view = revisionView === 'current' ? 'accepted' : revisionView;
10
+ let cursor = node;
11
+ while (cursor && cursor !== boundary) {
12
+ const name = localName(cursor);
13
+ if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) return false;
14
+ if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) return false;
15
+ cursor = cursor.parentNode;
16
+ }
17
+ return true;
18
+ }
19
+
20
+ /** Reads exact visible text from a run, including tabs, breaks and non-breaking hyphens. */
21
+ export function readCanonicalRunText(run, options = {}) {
22
+ const revisionView = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
23
+ const boundary = options.boundary || null;
24
+ if (!isNodeVisibleInRevisionView(run, boundary, revisionView)) return '';
25
+ let text = '';
26
+ const visit = node => {
27
+ for (const child of Array.from(node?.childNodes || [])) {
28
+ if (child?.nodeType !== 1 || (child.namespaceURI && child.namespaceURI !== NS_W)) continue;
29
+ if (!isNodeVisibleInRevisionView(child, boundary, revisionView)) continue;
30
+ const name = localName(child);
31
+ if (name === 't' || (name === 'delText' && revisionView === 'rejected')) text += child.textContent || '';
32
+ else if (name === 'tab') text += '\t';
33
+ else if (name === 'br' || name === 'cr') text += '\n';
34
+ else if (name === 'noBreakHyphen') text += '\u2011';
35
+ else if (name === 'softHyphen') text += '\u00ad';
36
+ else visit(child);
37
+ }
38
+ };
39
+ visit(run);
40
+ return text;
41
+ }
42
+
43
+ const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
44
+
45
+ /**
46
+ * Extracts revision-view text segments from a paragraph or container node.
47
+ *
48
+ * Emits structural text pieces tracking revision ancestry (ins, del, moveFrom, moveTo),
49
+ * computes independent accepted and rejected UTF-16 cursor offsets, and merges
50
+ * adjacent pieces with compatible carrier containers and revision metadata.
51
+ *
52
+ * @param {Element | null | undefined} paragraph - Paragraph or container element
53
+ * @param {Object} [options={}] - Extraction options
54
+ * @param {boolean} [options.mergeRuns=true] - Merge adjacent runs sharing compatible container and revision metadata
55
+ * @returns {Array<{ text: string, kind: 'baseline' | 'insertion' | 'deletion' | 'move_from' | 'move_to', author?: string, revisionId?: string, acceptedStart: number | null, rejectedStart: number | null }>}
56
+ */
57
+ export function extractParagraphRevisionSegments(paragraph, options = {}) {
58
+ if (!paragraph) return [];
59
+
60
+ const rawPieces = [];
61
+
62
+ function walk(node, currentRevision, currentCarrier) {
63
+ for (const child of Array.from(node?.childNodes || [])) {
64
+ if (child?.nodeType !== 1) continue;
65
+ if (child.namespaceURI && child.namespaceURI !== NS_W) continue;
66
+
67
+ const name = localName(child);
68
+ if (name === 'pPr' || name === 'rPr') continue;
69
+
70
+ let nextRevision = currentRevision;
71
+ if (name === 'ins') {
72
+ nextRevision = {
73
+ kind: 'insertion',
74
+ author: attr(child, 'author') || undefined,
75
+ revisionId: attr(child, 'id') || undefined
76
+ };
77
+ } else if (name === 'del') {
78
+ nextRevision = {
79
+ kind: 'deletion',
80
+ author: attr(child, 'author') || undefined,
81
+ revisionId: attr(child, 'id') || undefined
82
+ };
83
+ } else if (name === 'moveFrom') {
84
+ nextRevision = {
85
+ kind: 'move_from',
86
+ author: attr(child, 'author') || undefined,
87
+ revisionId: attr(child, 'id') || undefined
88
+ };
89
+ } else if (name === 'moveTo') {
90
+ nextRevision = {
91
+ kind: 'move_to',
92
+ author: attr(child, 'author') || undefined,
93
+ revisionId: attr(child, 'id') || undefined
94
+ };
95
+ }
96
+
97
+ let nextCarrier = currentCarrier;
98
+ if (name === 'r') {
99
+ nextCarrier = child;
100
+ }
101
+
102
+ let text = null;
103
+ let kind = nextRevision ? nextRevision.kind : 'baseline';
104
+ let author = nextRevision?.author;
105
+ let revisionId = nextRevision?.revisionId;
106
+
107
+ if (name === 't') {
108
+ text = child.textContent || '';
109
+ } else if (name === 'delText') {
110
+ text = child.textContent || '';
111
+ if (!nextRevision) {
112
+ kind = 'deletion';
113
+ }
114
+ } else if (name === 'tab') {
115
+ text = '\t';
116
+ } else if (name === 'br' || name === 'cr') {
117
+ text = '\n';
118
+ } else if (name === 'noBreakHyphen') {
119
+ text = '\u2011';
120
+ } else if (name === 'softHyphen') {
121
+ text = '\u00ad';
122
+ }
123
+
124
+ if (text !== null) {
125
+ if (text.length > 0) {
126
+ rawPieces.push({
127
+ text,
128
+ kind,
129
+ author,
130
+ revisionId,
131
+ carrier: nextCarrier || child,
132
+ carrierContainer: (nextCarrier || child)?.parentNode || null
133
+ });
134
+ }
135
+ } else {
136
+ walk(child, nextRevision, nextCarrier);
137
+ }
138
+ }
139
+ }
140
+
141
+ const initialCarrier = localName(paragraph) === 'r' ? paragraph : null;
142
+ walk(paragraph, null, initialCarrier);
143
+
144
+ if (rawPieces.length === 0) return [];
145
+
146
+ const mergeRuns = options.mergeRuns !== false;
147
+ const merged = [];
148
+ let current = null;
149
+
150
+ for (const piece of rawPieces) {
151
+ if (!current) {
152
+ current = { ...piece };
153
+ continue;
154
+ }
155
+
156
+ const sameKind = current.kind === piece.kind;
157
+ const sameAuthor = current.author === piece.author;
158
+ const sameRevisionId = current.revisionId === piece.revisionId;
159
+ const sameCarrier = current.carrier === piece.carrier;
160
+ const compatibleContainer = mergeRuns && current.carrierContainer === piece.carrierContainer;
161
+
162
+ if (sameKind && sameAuthor && sameRevisionId && (sameCarrier || compatibleContainer)) {
163
+ current.text += piece.text;
164
+ } else {
165
+ merged.push(current);
166
+ current = { ...piece };
167
+ }
168
+ }
169
+ if (current) merged.push(current);
170
+
171
+ let acceptedCursor = 0;
172
+ let rejectedCursor = 0;
173
+ const segments = [];
174
+
175
+ for (const item of merged) {
176
+ const isAccepted = item.kind !== 'deletion' && item.kind !== 'move_from';
177
+ const isRejected = item.kind !== 'insertion' && item.kind !== 'move_to';
178
+
179
+ const acceptedStart = isAccepted ? acceptedCursor : null;
180
+ const rejectedStart = isRejected ? rejectedCursor : null;
181
+
182
+ if (isAccepted) acceptedCursor += item.text.length;
183
+ if (isRejected) rejectedCursor += item.text.length;
184
+
185
+ const segment = {
186
+ text: item.text,
187
+ kind: item.kind,
188
+ acceptedStart,
189
+ rejectedStart
190
+ };
191
+ if (item.author !== undefined) segment.author = item.author;
192
+ if (item.revisionId !== undefined) segment.revisionId = item.revisionId;
193
+
194
+ segments.push(segment);
195
+ }
196
+
197
+ return segments;
198
+ }
199
+
200
+ /** Canonical exact paragraph text used by inspection, targeting and ingestion. */
201
+ export function extractCanonicalParagraphText(paragraph, options = {}) {
202
+ if (!paragraph) return '';
203
+ const view = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
204
+ const segments = extractParagraphRevisionSegments(paragraph, options);
205
+ if (view === 'rejected') {
206
+ return segments.filter(s => s.rejectedStart !== null).map(s => s.text).join('');
207
+ }
208
+ return segments.filter(s => s.acceptedStart !== null).map(s => s.text).join('');
209
+ }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Mirrors the invariants enforced by the test-suite round-trip harness so
5
5
  * downstream consumers can verify output before writing it into a package:
6
- * no nested revisions, deleted text uses w:delText, revision metadata is
7
- * complete, revision ids are unique, and boundary whitespace is preserved.
6
+ * only schema-permitted nested revisions, deleted text uses w:delText,
7
+ * revision metadata is complete, revision ids are unique, and boundary
8
+ * whitespace is preserved.
8
9
  */
9
10
 
10
11
  import { parseXml } from '../adapters/xml-adapter.js';
@@ -107,13 +108,18 @@ export function validateRedlineOoxml(oxml) {
107
108
  }
108
109
  }
109
110
 
110
- // No w:ins/w:del nested inside another w:ins/w:del.
111
+ // A w:del may be a direct revision child of w:ins. All other insertion /
112
+ // deletion nesting is rejected, including deeper revisions inside that del.
111
113
  for (const revision of revisions) {
112
114
  const nested = Array.from(revision.getElementsByTagName('*'))
113
115
  .filter(el => el !== revision && ['ins', 'del'].includes(localNameOf(el)));
114
- if (nested.length > 0) {
116
+ const invalidNested = nested.find(candidate => {
117
+ if (localNameOf(revision) !== 'ins' || localNameOf(candidate) !== 'del') return true;
118
+ return candidate.parentNode !== revision;
119
+ });
120
+ if (invalidNested) {
115
121
  addIssue('NESTED_REVISION', 'error',
116
- `<${revision.nodeName}> (w:id="${wordAttribute(revision, 'id')}") contains nested <${nested[0].nodeName}>.`);
122
+ `<${revision.nodeName}> (w:id="${wordAttribute(revision, 'id')}") contains invalid nested <${invalidNested.nodeName}>.`);
117
123
  }
118
124
  }
119
125
 
@@ -0,0 +1,38 @@
1
+ import {
2
+ NS_W,
3
+ RevisionIdAllocator,
4
+ createRevisionIdAllocator,
5
+ getRevisionIdAllocatorForDocument
6
+ } from './types.js';
7
+ import { isWordElement } from './word-xml.js';
8
+
9
+ /**
10
+ * Assigns fresh document-scoped IDs to w:rPrChange elements in a cloned
11
+ * run-properties subtree. This preserves formatting-revision metadata while
12
+ * preventing a DOM split from duplicating the original revision ID.
13
+ *
14
+ * @param {Element} root - Cloned subtree whose revision IDs should be refreshed
15
+ * @param {RevisionIdAllocator|null} [allocator=null] - Document-scoped allocator
16
+ * @returns {Element}
17
+ */
18
+ export function refreshRunPropertyChangeIds(root, allocator = null) {
19
+ if (!root) return root;
20
+
21
+ const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
22
+ const resolvedAllocator = allocator instanceof RevisionIdAllocator
23
+ ? allocator
24
+ : (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
25
+ const candidates = [root, ...Array.from(root.getElementsByTagName?.('*') || [])];
26
+
27
+ for (const node of candidates) {
28
+ if (!isWordElement(node, 'rPrChange')) continue;
29
+ const nextId = String(resolvedAllocator.next());
30
+ if (typeof node.setAttributeNS === 'function') {
31
+ node.setAttributeNS(NS_W, 'w:id', nextId);
32
+ } else {
33
+ node.setAttribute('w:id', nextId);
34
+ }
35
+ }
36
+
37
+ return root;
38
+ }
package/core/types.js CHANGED
@@ -202,15 +202,24 @@ export class RevisionIdAllocator {
202
202
 
203
203
  seed(xmlDoc) {
204
204
  let maxFound = -1;
205
- const elements = Array.from(xmlDoc?.getElementsByTagName?.('*') || []);
206
- if (xmlDoc?.nodeType === 1) elements.unshift(xmlDoc);
205
+ const traversalRoot = xmlDoc?.nodeType === 9 ? xmlDoc.documentElement : xmlDoc;
206
+ let node = traversalRoot || null;
207
207
 
208
- for (const element of elements) {
209
- if (!isRevisionIdElement(element)) continue;
210
- const id = readWordId(element);
211
- if (id == null) continue;
212
- this.occupiedIds.add(id);
213
- maxFound = Math.max(maxFound, id);
208
+ while (node) {
209
+ if (isRevisionIdElement(node)) {
210
+ const id = readWordId(node);
211
+ if (id != null) {
212
+ this.occupiedIds.add(id);
213
+ maxFound = Math.max(maxFound, id);
214
+ }
215
+ }
216
+
217
+ if (node.firstChild) {
218
+ node = node.firstChild;
219
+ continue;
220
+ }
221
+ while (node && node !== traversalRoot && !node.nextSibling) node = node.parentNode;
222
+ node = node && node !== traversalRoot ? node.nextSibling : null;
214
223
  }
215
224
 
216
225
  const highRiskBoundary = MAX_PRACTICAL_REVISION_ID - REVISION_ID_SAFETY_MARGIN;
@@ -286,7 +295,7 @@ export function getRevisionTimestamp(date = new Date()) {
286
295
  * @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode] - Scoped allocator or registered OOXML node
287
296
  * @returns {{ id: number, author: string, date: string }}
288
297
  */
289
- export function createRevisionMetadata(author, allocatorOrNode = null) {
298
+ export function createRevisionMetadata(author, allocatorOrNode = null, kind = null) {
290
299
  const resolvedAuthor = typeof author === 'string' && author.trim()
291
300
  ? author.trim()
292
301
  : getDefaultAuthor();
@@ -294,13 +303,58 @@ export function createRevisionMetadata(author, allocatorOrNode = null) {
294
303
  ? allocatorOrNode
295
304
  : (getRevisionIdAllocatorForDocument(allocatorOrNode) || defaultRevisionIdAllocator);
296
305
 
306
+ const id = allocator.next();
307
+ if (allocator._receiptCollector) {
308
+ allocator._receiptCollector.recordRevision(id, kind || 'structural');
309
+ }
310
+
297
311
  return {
298
- id: allocator.next(),
312
+ id,
299
313
  author: resolvedAuthor,
300
314
  date: getRevisionTimestamp()
301
315
  };
302
316
  }
303
317
 
318
+ /**
319
+ * @typedef {Object} ReplacementRevisionEvent
320
+ * @property {number} deletionId - Unique revision ID for deletion
321
+ * @property {number} insertionId - Unique revision ID for insertion
322
+ * @property {string} author - Change author
323
+ * @property {string} date - Shared ISO timestamp
324
+ */
325
+
326
+ /**
327
+ * Creates paired revision metadata for a replacement event.
328
+ * Allocates two unique IDs but shares author and timestamp.
329
+ *
330
+ * @param {string} [author]
331
+ * @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode=null]
332
+ * @returns {ReplacementRevisionEvent}
333
+ */
334
+ export function createReplacementRevisionEvent(author, allocatorOrNode = null) {
335
+ const resolvedAuthor = typeof author === 'string' && author.trim()
336
+ ? author.trim()
337
+ : getDefaultAuthor();
338
+ const allocator = allocatorOrNode instanceof RevisionIdAllocator
339
+ ? allocatorOrNode
340
+ : (getRevisionIdAllocatorForDocument(allocatorOrNode) || defaultRevisionIdAllocator);
341
+ const date = getRevisionTimestamp();
342
+ const deletionId = allocator.next();
343
+ const insertionId = allocator.next();
344
+
345
+ if (allocator._receiptCollector) {
346
+ allocator._receiptCollector.recordRevision(deletionId, 'del');
347
+ allocator._receiptCollector.recordRevision(insertionId, 'ins');
348
+ }
349
+
350
+ return {
351
+ deletionId,
352
+ insertionId,
353
+ author: resolvedAuthor,
354
+ date
355
+ };
356
+ }
357
+
304
358
  /**
305
359
  * Seeds the revision ID counter above any existing Word revision/comment id values.
306
360
  *
package/core/word-xml.js CHANGED
@@ -33,6 +33,27 @@ function wordElementsByLocalName(xmlDoc, localName) {
33
33
  return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
34
34
  }
35
35
 
36
+ // Keep revision discovery and author discovery on the same taxonomy. Cell
37
+ // markers are included for fail-closed detection even though selective
38
+ // acceptance/rejection of those structural revisions is not yet supported.
39
+ const TRACKED_CHANGE_NAMES = [
40
+ 'ins',
41
+ 'del',
42
+ 'moveFrom',
43
+ 'moveTo',
44
+ 'moveFromRangeStart',
45
+ 'moveFromRangeEnd',
46
+ 'moveToRangeStart',
47
+ 'moveToRangeEnd',
48
+ 'rPrChange',
49
+ 'pPrChange',
50
+ 'tblPrChange',
51
+ 'trPrChange',
52
+ 'tcPrChange',
53
+ 'cellIns',
54
+ 'cellDel'
55
+ ];
56
+
36
57
  /**
37
58
  * Returns true if a document or fragment contains Word tracked-change markup.
38
59
  *
@@ -40,22 +61,29 @@ function wordElementsByLocalName(xmlDoc, localName) {
40
61
  * @returns {boolean}
41
62
  */
42
63
  export function containsTrackedChanges(xmlDoc) {
43
- const trackedChangeNames = [
44
- 'ins',
45
- 'del',
46
- 'moveFrom',
47
- 'moveTo',
48
- 'moveFromRangeStart',
49
- 'moveFromRangeEnd',
50
- 'moveToRangeStart',
51
- 'moveToRangeEnd',
52
- 'rPrChange',
53
- 'pPrChange',
54
- 'cellIns',
55
- 'cellDel'
56
- ];
64
+ return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
65
+ }
57
66
 
58
- return trackedChangeNames.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
67
+ /**
68
+ * Collects all distinct author names from tracked changes in the given XML document or element.
69
+ *
70
+ * @param {Document|Element|null|undefined} xmlDocOrElement
71
+ * @returns {string[]} Sorted unique list of author names.
72
+ */
73
+ export function getTrackedChangeAuthors(xmlDocOrElement) {
74
+ if (!xmlDocOrElement) return [];
75
+ const authors = new Set();
76
+ for (const localName of TRACKED_CHANGE_NAMES) {
77
+ for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
78
+ const author = node.getAttribute?.('w:author')
79
+ || node.getAttribute?.('author')
80
+ || (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
81
+ if (author && typeof author === 'string' && author.trim()) {
82
+ authors.add(author.trim());
83
+ }
84
+ }
85
+ }
86
+ return [...authors].sort();
59
87
  }
60
88
 
61
89
  /**