@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  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/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  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 +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  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/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  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 +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -0,0 +1,256 @@
1
+ import { NS_W } from './types.js';
2
+ import { readCanonicalRunText } from './paragraph-text.js';
3
+
4
+ /**
5
+ * Word-semantics Reject All / Accept All projection over a WordprocessingML DOM.
6
+ *
7
+ * The projection is a list of paragraph records describing what Word shows after
8
+ * rejecting or accepting every tracked revision. It never mutates the input and
9
+ * never serializes. Rules (Word behavior):
10
+ * - a paragraph mark in w:pPr/w:rPr/w:ins (or moveTo) is removed on Reject; the
11
+ * paragraph's remaining content merges into the FOLLOWING paragraph of the same
12
+ * container, whose mark and properties survive. A deleted mark (w:del/moveFrom)
13
+ * is removed on Accept with the same merge rule.
14
+ * - at the end of a container: Reject keeps a table cell's last paragraph; otherwise a
15
+ * trailing paragraph left empty by the removed mark disappears.
16
+ * - w:ins/moveTo content is removed on Reject, w:del/moveFrom restored; Accept is
17
+ * the converse. Rows whose w:trPr carries w:ins (Reject) or w:del (Accept) vanish.
18
+ * - Reject restores the w:pPrChange snapshot as the paragraph's properties.
19
+ * - a w:body whose last block is a table gets one untracked empty paragraph from Word on
20
+ * open (a body must end with a paragraph). It is not a revision, so it survives Reject and
21
+ * Accept alike: such a body counts one extra paragraph in both views even when Reject
22
+ * removes the whole table (Word 16 COM evidence, R5).
23
+ *
24
+ * Shared by the post-apply fidelity verifier and the reject/accept tests.
25
+ */
26
+
27
+ const CONTAINER_NAMES = new Set(['body', 'tc', 'txbxContent', 'footnote', 'endnote', 'comment', 'hdr', 'ftr']);
28
+ const WRAPPER_NAMES = new Set(['hyperlink', 'fldSimple', 'sdt', 'smartTag', 'customXml']);
29
+ const PROPERTY_SKIP = new Set(['rPr', 'pPrChange', 'sectPr']);
30
+
31
+ const localName = node => {
32
+ const local = node?.localName;
33
+ if (local) return local.indexOf(':') < 0 ? local : local.replace(/^.*:/, '');
34
+ return String(node?.nodeName || '').replace(/^.*:/, '');
35
+ };
36
+ const isWordElement = node => node?.nodeType === 1 && (!node.namespaceURI || node.namespaceURI === NS_W);
37
+ const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
38
+
39
+ /** Document-order w:<name> descendants of `root`; a plain walk avoids the live node list's per-call cost. */
40
+ function descendantWordElements(root, name) {
41
+ const found = [];
42
+ if (!root) return found;
43
+ const stack = [];
44
+ for (let child = root.lastChild; child; child = child.previousSibling) stack.push(child);
45
+ while (stack.length) {
46
+ const node = stack.pop();
47
+ if (node.nodeType !== 1) continue;
48
+ if (node.namespaceURI === NS_W && node.localName === name) found.push(node);
49
+ for (let child = node.lastChild; child; child = child.previousSibling) stack.push(child);
50
+ }
51
+ return found;
52
+ }
53
+
54
+ function directChild(node, name) {
55
+ for (let child = node?.firstChild; child; child = child.nextSibling) {
56
+ if (isWordElement(child) && localName(child) === name) return child;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** Revision kind on the paragraph mark: 'ins' | 'del' | null (moveTo/moveFrom map to ins/del). */
62
+ export function paragraphMarkRevision(paragraph) {
63
+ const rPr = directChild(directChild(paragraph, 'pPr'), 'rPr');
64
+ if (!rPr) return null;
65
+ for (let child = rPr.firstChild; child; child = child.nextSibling) {
66
+ if (!isWordElement(child)) continue;
67
+ const name = localName(child);
68
+ if (name === 'ins' || name === 'moveTo') return 'ins';
69
+ if (name === 'del' || name === 'moveFrom') return 'del';
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function containerOf(paragraph) {
75
+ for (let cur = paragraph.parentNode; cur; cur = cur.parentNode) {
76
+ if (CONTAINER_NAMES.has(localName(cur))) return cur;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function hiddenByStructure(paragraph, view) {
82
+ const revisionName = view === 'rejected' ? 'ins' : 'del';
83
+ for (let cur = paragraph.parentNode; cur; cur = cur.parentNode) {
84
+ const name = localName(cur);
85
+ if (name === 'tr') {
86
+ if (directChild(directChild(cur, 'trPr'), revisionName)) return true;
87
+ } else if (name === 'tc') {
88
+ const cellRevision = view === 'rejected' ? 'cellIns' : 'cellDel';
89
+ if (directChild(directChild(cur, 'tcPr'), cellRevision)) return true;
90
+ } else if (name === 'body') {
91
+ break;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function canonicalElement(node) {
98
+ const attrs = Array.from(node.attributes || [])
99
+ .filter(a => !/^xmlns(:|$)/.test(a.name) && !/(^|:)(rsid\w*|paraId|textId)$/.test(a.name))
100
+ .map(a => `${a.name.replace(/^.*:/, '')}=${a.value}`)
101
+ .sort();
102
+ const children = Array.from(node.childNodes || [])
103
+ .filter(isWordElement)
104
+ .map(canonicalElement)
105
+ .sort();
106
+ return `${localName(node)}[${attrs.join(',')}]{${children.join('')}}`;
107
+ }
108
+
109
+ /** Properties a paragraph carries in a view (Reject restores the pPrChange snapshot). */
110
+ function viewProperties(paragraph, view) {
111
+ const pPr = directChild(paragraph, 'pPr');
112
+ let source = pPr;
113
+ if (view === 'rejected') {
114
+ const change = directChild(pPr, 'pPrChange');
115
+ if (change) source = directChild(change, 'pPr');
116
+ }
117
+ let numPr = null;
118
+ const children = [];
119
+ for (const child of Array.from(source?.childNodes || [])) {
120
+ if (!isWordElement(child) || PROPERTY_SKIP.has(localName(child))) continue;
121
+ if (localName(child) === 'numPr') {
122
+ numPr = {
123
+ numId: attr(directChild(child, 'numId'), 'val') || null,
124
+ ilvl: attr(directChild(child, 'ilvl'), 'val') || '0'
125
+ };
126
+ continue;
127
+ }
128
+ children.push(canonicalElement(child));
129
+ }
130
+ return { props: children.sort().join(''), numPr };
131
+ }
132
+
133
+ function wrapperKey(node) {
134
+ const name = localName(node);
135
+ if (name === 'hyperlink') {
136
+ return `hyperlink:${node.getAttribute?.('r:id') || attr(node, 'anchor') || ''}`;
137
+ }
138
+ if (name === 'fldSimple') return `fldSimple:${attr(node, 'instr').trim()}`;
139
+ if (name === 'sdt') {
140
+ const sdtPr = directChild(node, 'sdtPr');
141
+ return `sdt:${attr(directChild(sdtPr, 'id'), 'val')}:${attr(directChild(sdtPr, 'tag'), 'val')}`;
142
+ }
143
+ return `${name}:${attr(node, 'element') || attr(node, 'uri')}`;
144
+ }
145
+
146
+ /** Visible text and inline wrapper spans of one paragraph in a view. */
147
+ function paragraphContent(paragraph, view) {
148
+ let text = '';
149
+ const spans = [];
150
+ const walk = (node, wrappers) => {
151
+ for (let child = node.firstChild; child; child = child.nextSibling) {
152
+ if (!isWordElement(child)) continue;
153
+ const name = localName(child);
154
+ if (name === 'pPr') continue;
155
+ if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) continue;
156
+ if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) continue;
157
+ if (name === 'r') {
158
+ const start = text.length;
159
+ text += readCanonicalRunText(child, { revisionView: view, boundary: paragraph });
160
+ if (text.length > start) {
161
+ for (const key of wrappers) spans.push({ key, start, end: text.length });
162
+ }
163
+ continue;
164
+ }
165
+ walk(child, WRAPPER_NAMES.has(name) ? [...wrappers, wrapperKey(child)] : wrappers);
166
+ }
167
+ };
168
+ walk(paragraph, []);
169
+ return { text, spans };
170
+ }
171
+
172
+ function mergeSpans(spans) {
173
+ const byKey = new Map();
174
+ for (const span of spans) {
175
+ const list = byKey.get(span.key) || [];
176
+ const last = list[list.length - 1];
177
+ if (last && last.end >= span.start) last.end = Math.max(last.end, span.end);
178
+ else list.push({ ...span });
179
+ byKey.set(span.key, list);
180
+ }
181
+ return [...byKey.values()].flat()
182
+ .sort((a, b) => a.start - b.start || a.end - b.end || (a.key < b.key ? -1 : 1))
183
+ .map(span => `${span.key}@${span.start}-${span.end}`);
184
+ }
185
+
186
+ /**
187
+ * Projects a document (or any element containing paragraphs) into the paragraph list Word
188
+ * shows after Reject All (`view: 'rejected'`) or Accept All (`view: 'accepted'`).
189
+ *
190
+ * @returns {Array<{ text: string, props: string, numPr: {numId: string|null, ilvl: string}|null,
191
+ * wrappers: string[], container: string, sourceIndex: number }>}
192
+ */
193
+ export function projectWordRevisionView(root, view = 'rejected') {
194
+ if (view !== 'rejected' && view !== 'accepted') throw new Error(`Unknown revision view '${view}'.`);
195
+ const paragraphs = descendantWordElements(root, 'p');
196
+ const removedMark = view === 'rejected' ? 'ins' : 'del';
197
+ const records = [];
198
+ const pending = new Map();
199
+ paragraphs.forEach((paragraph, sourceIndex) => {
200
+ if (hiddenByStructure(paragraph, view)) return;
201
+ const container = containerOf(paragraph);
202
+ const carried = pending.get(container) || null;
203
+ pending.delete(container);
204
+ const { text, spans } = paragraphContent(paragraph, view);
205
+ const offset = carried ? carried.text.length : 0;
206
+ const merged = {
207
+ text: (carried?.text || '') + text,
208
+ spans: [...(carried?.spans || []), ...spans.map(s => ({ ...s, start: s.start + offset, end: s.end + offset }))]
209
+ };
210
+ if (paragraphMarkRevision(paragraph) === removedMark) {
211
+ pending.set(container, { ...merged, paragraph, sourceIndex });
212
+ return;
213
+ }
214
+ records.push(finishRecord(paragraph, merged, view, container, sourceIndex));
215
+ });
216
+ // A removed mark on the last paragraph of a container (Word 16 COM evidence,
217
+ // tests/fixtures/word-revision-semantics/expectations.json): Reject keeps a cell's last
218
+ // paragraph; otherwise an emptied trailing paragraph disappears and the previous mark becomes
219
+ // the container's last. Non-empty trailing content is kept (not yet pinned by COM evidence).
220
+ for (const [container, carried] of pending) {
221
+ const keepCellParagraph = view === 'rejected' && localName(container) === 'tc';
222
+ if (!keepCellParagraph && carried.text.length === 0) continue;
223
+ records.push(finishRecord(carried.paragraph, carried, view, container, carried.sourceIndex));
224
+ }
225
+ records.sort((a, b) => a.sourceIndex - b.sourceIndex);
226
+ appendImplicitBodyParagraphs(root, paragraphs, records);
227
+ return records;
228
+ }
229
+
230
+ /** Word adds an untracked empty paragraph after a table that ends a body. */
231
+ function appendImplicitBodyParagraphs(root, paragraphs, records) {
232
+ const bodies = localName(root) === 'body' ? [root] : descendantWordElements(root, 'body');
233
+ for (const body of bodies) {
234
+ const blocks = Array.from(body.childNodes || []).filter(node => isWordElement(node) && localName(node) !== 'sectPr');
235
+ const last = blocks[blocks.length - 1];
236
+ if (!last || localName(last) !== 'tbl') continue;
237
+ records.push({ text: '', props: '', numPr: null, wrappers: [], container: 'body', sourceIndex: paragraphs.length, implicit: true });
238
+ }
239
+ }
240
+
241
+ function finishRecord(paragraph, content, view, container, sourceIndex) {
242
+ const { props, numPr } = viewProperties(paragraph, view);
243
+ return {
244
+ text: content.text,
245
+ props,
246
+ numPr,
247
+ wrappers: mergeSpans(content.spans),
248
+ container: container ? localName(container) : 'root',
249
+ sourceIndex
250
+ };
251
+ }
252
+
253
+ /** Plain text per paragraph after Word Reject All / Accept All. */
254
+ export function wordRevisionParagraphTexts(root, view = 'rejected') {
255
+ return projectWordRevisionView(root, view).map(record => record.text);
256
+ }
package/core/word-xml.js CHANGED
@@ -1,118 +1,160 @@
1
- import { NS_W } from './types.js';
2
-
3
- /**
4
- * Returns true when a node is a WordprocessingML element with the given local name.
5
- *
6
- * @param {Node|null|undefined} node - Candidate node
7
- * @param {string} localName - Word local name, for example `r` or `tbl`
8
- * @returns {boolean}
9
- */
10
- export function isWordElement(node, localName) {
11
- if (!node || node.nodeType !== 1) return false;
12
- if (node.namespaceURI === NS_W && node.localName === localName) return true;
13
- const nodeName = String(node.nodeName || '');
14
- return nodeName === `w:${localName}` || nodeName === localName;
15
- }
16
-
17
- /**
18
- * Creates a WordprocessingML element using namespace-aware DOM APIs when available.
19
- *
20
- * @param {Document} xmlDoc - Target document
21
- * @param {string} qualifiedName - Qualified name, for example `w:r`
22
- * @returns {Element}
23
- */
24
- export function createWordElement(xmlDoc, qualifiedName) {
25
- return typeof xmlDoc.createElementNS === 'function'
26
- ? xmlDoc.createElementNS(NS_W, qualifiedName)
27
- : xmlDoc.createElement(qualifiedName);
28
- }
29
-
30
- function wordElementsByLocalName(xmlDoc, localName) {
31
- const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
32
- if (namespaced.length > 0) return namespaced;
33
- return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
34
- }
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
-
57
- /**
58
- * Returns true if a document or fragment contains Word tracked-change markup.
59
- *
60
- * @param {Document|Element} xmlDoc - Parsed OOXML document or element
61
- * @returns {boolean}
62
- */
63
- export function containsTrackedChanges(xmlDoc) {
64
- return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
65
- }
66
-
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();
87
- }
88
-
89
- /**
90
- * Classifies the shape of an OOXML payload.
91
- *
92
- * @param {string} oxml - OOXML payload
93
- * @returns {'package'|'document'|'fragment'}
94
- */
95
- export function classifyOoxmlSourceType(oxml) {
96
- const trimmed = String(oxml || '').trim();
97
- if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
98
- return 'package';
99
- }
100
- if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
101
- return 'document';
102
- }
103
- return 'fragment';
104
- }
105
-
106
- /**
107
- * Adds `sourceType` metadata to OOXML result objects without changing payloads.
108
- *
109
- * @template T
110
- * @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
111
- * @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
112
- */
113
- export function withOoxmlSourceType(result) {
114
- if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
115
- return result;
116
- }
117
- return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
118
- }
1
+ import { NS_W } from './types.js';
2
+
3
+ /**
4
+ * Returns true when a node is a WordprocessingML element with the given local name.
5
+ *
6
+ * @param {Node|null|undefined} node - Candidate node
7
+ * @param {string} localName - Word local name, for example `r` or `tbl`
8
+ * @returns {boolean}
9
+ */
10
+ export function isWordElement(node, localName) {
11
+ if (!node || node.nodeType !== 1) return false;
12
+ if (node.namespaceURI === NS_W && node.localName === localName) return true;
13
+ const nodeName = String(node.nodeName || '');
14
+ return nodeName === `w:${localName}` || nodeName === localName;
15
+ }
16
+
17
+ /**
18
+ * Creates a WordprocessingML element using namespace-aware DOM APIs when available.
19
+ *
20
+ * @param {Document} xmlDoc - Target document
21
+ * @param {string} qualifiedName - Qualified name, for example `w:r`
22
+ * @returns {Element}
23
+ */
24
+ export function createWordElement(xmlDoc, qualifiedName) {
25
+ return typeof xmlDoc.createElementNS === 'function'
26
+ ? xmlDoc.createElementNS(NS_W, qualifiedName)
27
+ : xmlDoc.createElement(qualifiedName);
28
+ }
29
+
30
+ function wordElementsByLocalName(xmlDoc, localName) {
31
+ const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
32
+ if (namespaced.length > 0) return namespaced;
33
+ return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
34
+ }
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
+
57
+ /**
58
+ * Returns true if a document or fragment contains Word tracked-change markup.
59
+ *
60
+ * @param {Document|Element} xmlDoc - Parsed OOXML document or element
61
+ * @returns {boolean}
62
+ */
63
+ export function containsTrackedChanges(xmlDoc) {
64
+ return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
65
+ }
66
+
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();
87
+ }
88
+
89
+ /**
90
+ * Classifies the shape of an OOXML payload.
91
+ *
92
+ * @param {string} oxml - OOXML payload
93
+ * @returns {'package'|'document'|'fragment'}
94
+ */
95
+ export function classifyOoxmlSourceType(oxml) {
96
+ const trimmed = String(oxml || '').trim();
97
+ if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
98
+ return 'package';
99
+ }
100
+ if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
101
+ return 'document';
102
+ }
103
+ return 'fragment';
104
+ }
105
+
106
+ /**
107
+ * Adds `sourceType` metadata to OOXML result objects without changing payloads.
108
+ *
109
+ * @template T
110
+ * @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
111
+ * @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
112
+ */
113
+ export function withOoxmlSourceType(result) {
114
+ if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
115
+ return result;
116
+ }
117
+ return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
118
+ }
119
+
120
+ /**
121
+ * Returns true if any tracked change in the node lacks a non-blank author.
122
+ * Same-author checks treat such a revision as another reviewer's, because
123
+ * `getTrackedChangeAuthors` omits it.
124
+ *
125
+ * @param {Document|Element|null|undefined} node
126
+ * @returns {boolean}
127
+ */
128
+ export function hasUnattributedTrackedChange(node) {
129
+ if (!node) return false;
130
+ for (const localName of TRACKED_CHANGE_NAMES) {
131
+ for (const change of wordElementsByLocalName(node, localName)) {
132
+ const author = change.getAttribute?.('w:author')
133
+ || change.getAttribute?.('author')
134
+ || (typeof change.getAttributeNS === 'function' ? change.getAttributeNS(NS_W, 'author') : null);
135
+ if (!author || !String(author).trim()) return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Resolves `existingRevisions: 'auto'` for one target. A target without tracked
143
+ * changes, or whose changes are all by `author`, merges same-author history;
144
+ * any other or unattributed reviewer's change selects 'slice-cross-author',
145
+ * which preserves that history. Other policies are returned unchanged.
146
+ * @param {string|null|undefined} policy
147
+ * @param {Node|Array<Node>} node - target paragraph, scope element or document, or every paragraph of a range
148
+ * @param {string} author
149
+ * @returns {string|null|undefined}
150
+ */
151
+ export function resolveExistingRevisionsPolicy(policy, node, author) {
152
+ if (policy !== 'auto') return policy;
153
+ const nodes = (Array.isArray(node) ? node : [node]).filter(Boolean);
154
+ const own = String(author || '').trim().toLowerCase();
155
+ const foreign = nodes.some(item => containsTrackedChanges(item) && (
156
+ hasUnattributedTrackedChange(item)
157
+ || getTrackedChangeAuthors(item).some(name => name.trim().toLowerCase() !== own)
158
+ ));
159
+ return foreign ? 'slice-cross-author' : 'merge-same-author';
160
+ }