@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,258 @@
1
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
2
+ import { NS_W } from '../core/types.js';
3
+ import { extractCanonicalParagraphText, readCanonicalRunText, extractParagraphRevisionSegments } from '../core/paragraph-text.js';
4
+ import { createParagraphFingerprint, getDocumentParagraphNodes, getParagraphId } from '../core/paragraph-targeting.js';
5
+ import { extractDocumentPartsEntries, computeRevisionTokenSync } from './revision-token.js';
6
+
7
+ const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
8
+ const descendants = (node, name) => Array.from(node?.getElementsByTagNameNS?.(NS_W, name) || []);
9
+ const first = (node, name) => descendants(node, name)[0] || null;
10
+ function hasAncestor(node, localName) { let cursor = node?.parentNode; while (cursor) { if (cursor.localName === localName && (!cursor.namespaceURI || cursor.namespaceURI === NS_W)) return true; cursor = cursor.parentNode; } return false; }
11
+
12
+ function parseXml(xml, partName, required = false) {
13
+ if (!xml) return required ? { error: { code: 'MISSING_PART', message: `Missing ${partName}.` } } : { doc: null };
14
+ const parsed = parseOoxmlSafe(xml, 'application/xml');
15
+ if (!parsed.doc || parsed.error) return { error: { code: 'PARSE_ERROR', message: `Could not parse ${partName}: ${parsed.error?.message || 'invalid XML'}` } };
16
+ return { doc: parsed.doc, warnings: parsed.warnings || [] };
17
+ }
18
+
19
+ function paragraphListProperties(paragraph) {
20
+ const numPr = first(first(paragraph, 'pPr'), 'numPr');
21
+ if (!numPr) return null;
22
+ const numId = attr(first(numPr, 'numId'), 'val');
23
+ if (!numId || numId === '0') return null;
24
+ return { numId, level: Number.parseInt(attr(first(numPr, 'ilvl'), 'val') || '0', 10) || 0 };
25
+ }
26
+
27
+ function parseNumbering(numberingDoc) {
28
+ const abstracts = new Map();
29
+ for (const abstract of descendants(numberingDoc, 'abstractNum')) {
30
+ const levels = new Map();
31
+ for (const level of descendants(abstract, 'lvl')) {
32
+ const ilvl = Number.parseInt(attr(level, 'ilvl') || '0', 10) || 0;
33
+ levels.set(ilvl, {
34
+ start: Number.parseInt(attr(first(level, 'start'), 'val') || '1', 10) || 1,
35
+ format: attr(first(level, 'numFmt'), 'val') || 'decimal',
36
+ text: attr(first(level, 'lvlText'), 'val') || `%${ilvl + 1}.`
37
+ });
38
+ }
39
+ abstracts.set(attr(abstract, 'abstractNumId'), levels);
40
+ }
41
+ const nums = new Map();
42
+ for (const num of descendants(numberingDoc, 'num')) {
43
+ const abstractId = attr(first(num, 'abstractNumId'), 'val');
44
+ const levels = new Map(abstracts.get(abstractId) || []);
45
+ for (const override of descendants(num, 'lvlOverride')) {
46
+ const ilvl = Number.parseInt(attr(override, 'ilvl') || '0', 10) || 0;
47
+ const embedded = first(override, 'lvl');
48
+ const base = { ...(levels.get(ilvl) || { start: 1, format: 'decimal', text: `%${ilvl + 1}.` }) };
49
+ if (embedded) {
50
+ base.start = Number.parseInt(attr(first(embedded, 'start'), 'val') || String(base.start), 10) || base.start;
51
+ base.format = attr(first(embedded, 'numFmt'), 'val') || base.format;
52
+ base.text = attr(first(embedded, 'lvlText'), 'val') || base.text;
53
+ }
54
+ const startOverride = first(override, 'startOverride');
55
+ if (startOverride) base.start = Number.parseInt(attr(startOverride, 'val') || String(base.start), 10) || base.start;
56
+ levels.set(ilvl, base);
57
+ }
58
+ nums.set(attr(num, 'numId'), levels);
59
+ }
60
+ return nums;
61
+ }
62
+
63
+ function alpha(value, upper) {
64
+ let n = Math.max(1, value); let out = '';
65
+ while (n > 0) { n -= 1; out = String.fromCharCode(97 + (n % 26)) + out; n = Math.floor(n / 26); }
66
+ return upper ? out.toUpperCase() : out;
67
+ }
68
+ function roman(value) {
69
+ const pairs = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']];
70
+ let n = value; let out = ''; for (const [amount, glyph] of pairs) while (n >= amount) { out += glyph; n -= amount; } return out;
71
+ }
72
+ function formatCounter(value, format) {
73
+ if (format === 'lowerLetter') return alpha(value, false);
74
+ if (format === 'upperLetter') return alpha(value, true);
75
+ if (format === 'lowerRoman') return roman(value).toLowerCase();
76
+ if (format === 'upperRoman') return roman(value);
77
+ return String(value);
78
+ }
79
+
80
+ function createNumberingResolver(numberingDoc) {
81
+ const nums = numberingDoc ? parseNumbering(numberingDoc) : new Map();
82
+ const counters = new Map();
83
+ return list => {
84
+ if (!list?.numId) return null;
85
+ const levels = nums.get(String(list.numId));
86
+ if (!levels) return { ...list, label: null, format: null };
87
+ const state = counters.get(list.numId) || [];
88
+ const definition = levels.get(list.level) || { start: 1, format: 'decimal', text: `%${list.level + 1}.` };
89
+ state[list.level] = state[list.level] == null ? definition.start : state[list.level] + 1;
90
+ state.length = list.level + 1;
91
+ counters.set(list.numId, state);
92
+ const label = definition.text.replace(/%([1-9])/g, (_, raw) => {
93
+ const level = Number(raw) - 1;
94
+ const levelDef = levels.get(level) || definition;
95
+ return formatCounter(state[level] ?? levelDef.start, levelDef.format);
96
+ });
97
+ return { ...list, label, format: definition.format };
98
+ };
99
+ }
100
+
101
+ function headingLevel(paragraph) {
102
+ const pPr = first(paragraph, 'pPr');
103
+ const style = attr(first(pPr, 'pStyle'), 'val');
104
+ const match = style.match(/^heading\s*([1-9])$/i);
105
+ if (match) return Math.min(Number(match[1]), 6);
106
+ const outline = Number.parseInt(attr(first(pPr, 'outlineLvl'), 'val'), 10);
107
+ return Number.isInteger(outline) ? Math.min(outline + 1, 6) : null;
108
+ }
109
+
110
+ function structuralContext(paragraph, text) {
111
+ const references = [];
112
+ for (const [name, type] of [['footnoteReference', 'footnote'], ['endnoteReference', 'endnote'], ['commentReference', 'comment']]) {
113
+ for (const node of descendants(paragraph, name)) references.push({ type, id: attr(node, 'id') || null });
114
+ }
115
+ let cell = paragraph.parentNode; while (cell && cell.localName !== 'tc') cell = cell.parentNode;
116
+ let row = cell?.parentNode; while (row && row.localName !== 'tr') row = row.parentNode;
117
+ let table = row?.parentNode; while (table && table.localName !== 'tbl') table = table.parentNode;
118
+ const all = paragraph.ownerDocument;
119
+ return {
120
+ references,
121
+ table: table ? {
122
+ tableIndex: Array.from(all.getElementsByTagNameNS(NS_W, 'tbl')).indexOf(table) + 1,
123
+ rowIndex: Array.from(table.getElementsByTagNameNS(NS_W, 'tr')).indexOf(row) + 1,
124
+ cellIndex: Array.from(row.getElementsByTagNameNS(NS_W, 'tc')).indexOf(cell) + 1
125
+ } : null,
126
+ empty: text.length === 0
127
+ };
128
+ }
129
+
130
+ function revisionAuthors(paragraph) {
131
+ const authors = new Set();
132
+ for (const name of ['ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange']) {
133
+ for (const node of descendants(paragraph, name)) if (attr(node, 'author')) authors.add(attr(node, 'author'));
134
+ }
135
+ return [...authors].sort();
136
+ }
137
+
138
+ function readCommentDefinitions(commentsDoc) {
139
+ const result = new Map();
140
+ for (const comment of descendants(commentsDoc, 'comment')) {
141
+ const paragraphs = descendants(comment, 'p');
142
+ result.set(attr(comment, 'id'), {
143
+ id: attr(comment, 'id'), author: attr(comment, 'author') || null, date: attr(comment, 'date') || null,
144
+ text: paragraphs.map(p => extractCanonicalParagraphText(p)).join('\n'),
145
+ paraId: paragraphs[0]?.getAttribute?.('w14:paraId') || paragraphs[0]?.getAttribute?.('paraId') || null
146
+ });
147
+ }
148
+ return result;
149
+ }
150
+
151
+ function attachCommentThreadMetadata(comments, commentsExtendedDoc) {
152
+ if (!commentsExtendedDoc) return;
153
+ const byParaId = new Map([...comments.values()].filter(c => c.paraId).map(c => [c.paraId.toUpperCase(), c]));
154
+ for (const entry of Array.from(commentsExtendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
155
+ const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '';
156
+ const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '';
157
+ const comment = byParaId.get(paraId.toUpperCase());
158
+ if (!comment) continue;
159
+ comment.done = (entry.getAttribute('w15:done') || entry.getAttribute('done')) === '1';
160
+ if (parentParaId) {
161
+ comment.parentParaId = parentParaId;
162
+ comment.parentCommentId = byParaId.get(parentParaId.toUpperCase())?.id || null;
163
+ }
164
+ }
165
+ }
166
+
167
+ function collectDocumentCommentAnchors(paragraphNodes, revisionView) {
168
+ const active = new Map(); const anchors = new Map();
169
+ let paragraphBoundary = null;
170
+ const append = value => { for (const item of active.values()) item.text += value; };
171
+ const visit = node => {
172
+ for (const child of Array.from(node?.childNodes || [])) {
173
+ if (child?.nodeType !== 1) continue;
174
+ const name = child.localName;
175
+ if ((revisionView === 'accepted' && (name === 'del' || name === 'moveFrom')) || (revisionView === 'rejected' && (name === 'ins' || name === 'moveTo'))) continue;
176
+ if (name === 'commentRangeStart') active.set(attr(child, 'id'), { text: '' });
177
+ else if (name === 'commentRangeEnd') { const id = attr(child, 'id'); if (active.has(id)) { anchors.set(id, active.get(id).text); active.delete(id); } }
178
+ else if (name === 'r') append(readCanonicalRunText(child, { revisionView, boundary: paragraphBoundary }));
179
+ else visit(child);
180
+ }
181
+ };
182
+ paragraphNodes.forEach((paragraph, index) => { paragraphBoundary = paragraph; visit(paragraph); if (index < paragraphNodes.length - 1 && active.size) append('\n'); });
183
+ return anchors;
184
+ }
185
+
186
+ /** Read-only, stable document-parts inspection for agents and package adapters. */
187
+ export function inspectDocumentParts(parts, options = {}) {
188
+ const documentPart = parseXml(parts?.documentXml, 'word/document.xml', true);
189
+ if (documentPart.error) return { status: 'error', error: documentPart.error, paragraphs: [], comments: [], warnings: [] };
190
+ const commentsPart = parseXml(parts?.commentsXml, 'word/comments.xml');
191
+ const commentsExtendedPart = parseXml(parts?.commentsExtendedXml, 'word/commentsExtended.xml');
192
+ const numberingPart = parseXml(parts?.numberingXml, 'word/numbering.xml');
193
+ const warnings = [...(documentPart.warnings || [])];
194
+ if (commentsPart.error) warnings.push(commentsPart.error.message);
195
+ if (commentsExtendedPart.error) warnings.push(commentsExtendedPart.error.message);
196
+ if (numberingPart.error) warnings.push(numberingPart.error.message);
197
+ const comments = readCommentDefinitions(commentsPart.doc);
198
+ attachCommentThreadMetadata(comments, commentsExtendedPart.doc);
199
+ const resolveNumbering = createNumberingResolver(numberingPart.doc);
200
+ let nearestHeading = null;
201
+ const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
202
+ const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, options.revisionView || 'accepted');
203
+ let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
204
+ const text = extractCanonicalParagraphText(paragraph, { revisionView: options.revisionView || 'accepted' });
205
+ const level = headingLevel(paragraph);
206
+ if (level) nearestHeading = { level, text };
207
+ const ids = [...new Set([...descendants(paragraph, 'commentRangeStart'), ...descendants(paragraph, 'commentReference')].map(node => attr(node, 'id')).filter(Boolean))];
208
+ const authors = revisionAuthors(paragraph);
209
+ const list = resolveNumbering(paragraphListProperties(paragraph));
210
+ const styleId = attr(first(first(paragraph, 'pPr'), 'pStyle'), 'val') || null;
211
+ const structure = structuralContext(paragraph, text);
212
+ const index = zeroIndex + 1;
213
+ const provision = list?.label && list.format !== 'bullet' ? list.label : null;
214
+ const headingText = nearestHeading?.text || null;
215
+ const humanReference = [provision, headingText, text.slice(0, options.excerptLength || 120)].filter(Boolean).join(' — ');
216
+ const segments = extractParagraphRevisionSegments(paragraph);
217
+ return {
218
+ index, ref: `P${index}`, paragraphId: getParagraphId(paragraph), fingerprint: createParagraphFingerprint(paragraph),
219
+ text, exactText: text, excerpt: text.slice(0, options.excerptLength || 120), humanReference, inTable: hasAncestor(paragraph, 'tc'), table: structure.table,
220
+ styleId, headingLevel: level, nearestHeading, list, structuralReferences: structure.references, hasRevisions: authors.length > 0, revisionAuthors: authors, commentIds: ids,
221
+ segments
222
+ };
223
+ });
224
+ for (const paragraph of paragraphs) for (const id of paragraph.commentIds) {
225
+ const definition = comments.get(id) || { id, author: null, date: null, text: '' };
226
+ definition.paragraphIndex ??= paragraph.index; definition.targetRef ??= paragraph.ref;
227
+ definition.anchoredText ??= commentAnchors.get(id) || paragraph.text;
228
+ comments.set(id, definition);
229
+ }
230
+ if (options.revisedOnly) paragraphs = paragraphs.filter(item => item.hasRevisions);
231
+ if (options.inTable != null) paragraphs = paragraphs.filter(item => item.inTable === !!options.inTable);
232
+ if (options.skipEmpty) paragraphs = paragraphs.filter(item => item.text.length > 0);
233
+ if (options.search) { const needle = String(options.search).toLowerCase(); paragraphs = paragraphs.filter(item => item.text.toLowerCase().includes(needle)); }
234
+ if (Array.isArray(options.indexes)) { const indexes = new Set(options.indexes); paragraphs = paragraphs.filter(item => indexes.has(item.index)); }
235
+ if (options.range) { const start = Number(options.range.start ?? options.range[0]); const end = Number(options.range.end ?? options.range[1]); paragraphs = paragraphs.filter(item => item.index >= start && item.index <= end); }
236
+ const allRevisionAuthors = [...new Set(paragraphs.flatMap(item => item.revisionAuthors))].sort();
237
+ const coveredEntries = extractDocumentPartsEntries(parts);
238
+ const coveredParts = coveredEntries.map(e => e.name).sort();
239
+ let revisionToken = null;
240
+ if (typeof options.digestFn === 'function') {
241
+ revisionToken = computeRevisionTokenSync({
242
+ scope: 'document-parts',
243
+ entries: coveredEntries,
244
+ digestFn: options.digestFn
245
+ });
246
+ }
247
+ return {
248
+ status: 'ok',
249
+ revisionToken,
250
+ coveredParts,
251
+ paragraphs,
252
+ comments: [...comments.values()],
253
+ revisionAuthors: allRevisionAuthors,
254
+ commentAuthors: [...new Set([...comments.values()].map(item => item.author).filter(Boolean))].sort(),
255
+ counts: { paragraphs: paragraphs.length, comments: comments.size, revisedParagraphs: paragraphs.filter(item => item.hasRevisions).length },
256
+ warnings
257
+ };
258
+ }
@@ -0,0 +1,372 @@
1
+ import { getDefaultAuthor } from '../adapters/config.js';
2
+ import {
3
+ normalizeDocumentOperation,
4
+ resolveDocumentOperationAuthor,
5
+ validateDocumentOperation
6
+ } from './document-operation-contract.js';
7
+ import { DocumentOperationSession } from './document-operation-session.js';
8
+ import {
9
+ validateRevisionToken,
10
+ computeDocumentPartsRevisionToken,
11
+ areRevisionTokensEqual
12
+ } from './revision-token.js';
13
+ import {
14
+ applyCommentToParagraphByExactText,
15
+ applyFormattingToParagraphByExactText,
16
+ applyHighlightToParagraphByExactText,
17
+ applyParagraphFormatToParagraphByExactText,
18
+ applyToParagraphByExactText
19
+ } from './document-operation-mutations.js';
20
+ import { applyCommentReplyToParts } from './comment-replies.js';
21
+ import {
22
+ deriveCapturedEntity,
23
+ invalidateAffectedCaptures
24
+ } from './capture-engine.js';
25
+ import {
26
+ createEmptyReceipt,
27
+ reconcileReceiptsAgainstOutput
28
+ } from './receipt-collector.js';
29
+
30
+ export function normalizeOperationError(error) {
31
+ return {
32
+ code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
33
+ message: error?.message || String(error),
34
+ ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Validates and dispatches one structured operation against full document XML.
40
+ * Result metadata is assembled here so every mutation path exposes the same
41
+ * */
42
+ export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
43
+ const operationIndex = typeof options._operationIndex === 'number' ? options._operationIndex : 1;
44
+ const validation = validateDocumentOperation(op);
45
+ if (!validation.valid) {
46
+ const authorUsed = resolveDocumentOperationAuthor(op, author, getDefaultAuthor());
47
+ return {
48
+ documentXml,
49
+ hasChanges: false,
50
+ status: 'error',
51
+ error: validation.error,
52
+ operationType: normalizeDocumentOperation(op).operationKind,
53
+ authorUsed,
54
+ receipt: createEmptyReceipt(operationIndex, op?.operationId, authorUsed, 'refused')
55
+ };
56
+ }
57
+
58
+ const operation = validation.operation || normalizeDocumentOperation(op);
59
+ const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
60
+
61
+ if (operation.operationKind !== 'comment_reply' && operation.targetDescriptor?.revisionView === 'rejected') {
62
+ return {
63
+ documentXml,
64
+ hasChanges: false,
65
+ status: 'error',
66
+ error: {
67
+ code: 'UNSUPPORTED_REVISION_VIEW_MUTATION',
68
+ message: 'Targeting rejected revision view for mutation is not supported yet.'
69
+ },
70
+ operationType: operation.operationKind,
71
+ authorUsed,
72
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
73
+ };
74
+ }
75
+
76
+ if (!options?._documentOperationSession && options?.expectedRevision) {
77
+ const tokenValidation = validateRevisionToken(options.expectedRevision);
78
+ if (!tokenValidation.valid) {
79
+ return {
80
+ documentXml,
81
+ hasChanges: false,
82
+ status: 'error',
83
+ error: {
84
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
85
+ message: tokenValidation.error?.message || 'Invalid revision token.'
86
+ },
87
+ operationType: operation.operationKind,
88
+ authorUsed,
89
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
90
+ };
91
+ }
92
+ if (options.expectedRevision.scope !== 'document-parts') {
93
+ return {
94
+ documentXml,
95
+ hasChanges: false,
96
+ status: 'error',
97
+ error: {
98
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
99
+ message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
100
+ },
101
+ operationType: operation.operationKind,
102
+ authorUsed,
103
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
104
+ };
105
+ }
106
+ const currentToken = await computeDocumentPartsRevisionToken({
107
+ documentXml,
108
+ commentsXml: runtimeContext?.commentsXml || options.commentsXml,
109
+ commentsExtendedXml: runtimeContext?.commentsExtendedXml || options.commentsExtendedXml,
110
+ numberingXml: runtimeContext?.numberingXml || options.numberingXml,
111
+ stylesXml: runtimeContext?.stylesXml || options.stylesXml
112
+ }, options);
113
+ if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
114
+ return {
115
+ documentXml,
116
+ hasChanges: false,
117
+ status: 'error',
118
+ error: {
119
+ code: 'REVISION_MISMATCH',
120
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
121
+ },
122
+ operationType: operation.operationKind,
123
+ authorUsed,
124
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
125
+ };
126
+ }
127
+ }
128
+
129
+ const session = options?._documentOperationSession instanceof DocumentOperationSession
130
+ ? options._documentOperationSession
131
+ : new DocumentOperationSession(documentXml, options);
132
+ if (!session.valid) {
133
+ return {
134
+ documentXml,
135
+ hasChanges: false,
136
+ status: 'error',
137
+ error: session.parseResult.error,
138
+ warnings: session.parseResult.warnings,
139
+ operationType: operation.operationKind,
140
+ authorUsed,
141
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
142
+ };
143
+ }
144
+
145
+ const resolutionCapture = {};
146
+ const savepoint = session.createSavepoint();
147
+ session.receiptCollector?.beginOperation(
148
+ operationIndex,
149
+ operation.operationId,
150
+ authorUsed
151
+ );
152
+ const operationWarnings = [];
153
+ const operationOptions = {
154
+ ...options,
155
+ ...(typeof operation.generateRedlines === 'boolean' ? { generateRedlines: operation.generateRedlines } : {}),
156
+ ...(operation.existingRevisions ? { existingRevisions: operation.existingRevisions } : {}),
157
+ structuredContent: typeof operation.structuredContent === 'boolean' ? operation.structuredContent : (options.structuredContent !== false),
158
+ explicitStructuredContent: operation.structuredContent === true,
159
+ pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
160
+ ...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
161
+ ...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
162
+ targetDescriptor: operation.targetDescriptor,
163
+ _resolutionCapture: resolutionCapture,
164
+ _revisionIdAllocator: session.revisionIdAllocator,
165
+ _documentOperationSession: session,
166
+ _mutationLiveNodes: [],
167
+ _mutationRemovedNodes: [],
168
+ onInfo: (msg) => {
169
+ if (typeof options?.onInfo === 'function') options.onInfo(msg);
170
+ },
171
+ onWarn: (msg) => {
172
+ operationWarnings.push(String(msg));
173
+ if (typeof options?.onWarn === 'function') options.onWarn(msg);
174
+ }
175
+ };
176
+
177
+ try {
178
+ let result;
179
+ if (operation.operationKind === 'comment_reply') {
180
+ const existingCommentsXml = session.commentsXml || runtimeContext?.commentsXml || options.commentsXml;
181
+ const existingExtendedXml = session.commentsExtendedXml || runtimeContext?.commentsExtendedXml || options.commentsExtendedXml;
182
+ let commentId = typeof options.commentIdAllocator === 'function' ? options.commentIdAllocator() : null;
183
+ if (commentId == null && existingCommentsXml) {
184
+ const ids = [...existingCommentsXml.matchAll(/<(?:w:)?comment\b[^>]*\b(?:w:)?id=["'](\d+)["']/g)].map(match => Number(match[1]));
185
+ commentId = (ids.length ? Math.max(...ids) : -1) + 1;
186
+ }
187
+ if (commentId == null) {
188
+ result = { documentXml, hasChanges: false, status: 'error', error: { code: 'COMMENTS_PART_MISSING', message: 'A comment reply requires an existing comments part.' } };
189
+ } else {
190
+ result = applyCommentReplyToParts({
191
+ commentsXml: existingCommentsXml,
192
+ commentsExtendedXml: existingExtendedXml,
193
+ parentCommentId: operation.parentCommentId,
194
+ commentId,
195
+ commentContent: operation.commentContent,
196
+ author: authorUsed,
197
+ date: operation.date || new Date().toISOString()
198
+ });
199
+ result.documentXml = documentXml;
200
+ if (result.hasChanges && session.receiptCollector) session.receiptCollector.recordComment(commentId);
201
+ }
202
+ } else if (operation.operationKind === 'highlight') {
203
+ result = await applyHighlightToParagraphByExactText(
204
+ documentXml,
205
+ operation.target,
206
+ operation.textToHighlight,
207
+ operation.color,
208
+ authorUsed,
209
+ operation.targetRef,
210
+ runtimeContext,
211
+ operationOptions
212
+ );
213
+ } else if (operation.operationKind === 'comment') {
214
+ result = await applyCommentToParagraphByExactText(
215
+ documentXml,
216
+ operation.target,
217
+ operation.textToComment,
218
+ operation.commentContent,
219
+ authorUsed,
220
+ operation.targetRef,
221
+ runtimeContext,
222
+ operationOptions
223
+ );
224
+ } else if (operation.operationKind === 'format') {
225
+ result = await applyFormattingToParagraphByExactText(
226
+ documentXml,
227
+ operation.target,
228
+ operation.textToFormat,
229
+ operation.properties,
230
+ authorUsed,
231
+ operation.targetRef,
232
+ runtimeContext,
233
+ operationOptions
234
+ );
235
+ } else if (operation.operationKind === 'paragraph-format') {
236
+ result = await applyParagraphFormatToParagraphByExactText(
237
+ documentXml,
238
+ operation.target,
239
+ operation.properties,
240
+ authorUsed,
241
+ operation.targetRef,
242
+ runtimeContext,
243
+ operationOptions
244
+ );
245
+ } else {
246
+ result = await applyToParagraphByExactText(
247
+ documentXml,
248
+ operation.target,
249
+ operation.modified,
250
+ authorUsed,
251
+ operation.targetRef,
252
+ operation.targetEndRef,
253
+ runtimeContext,
254
+ operationOptions
255
+ );
256
+ }
257
+ if (
258
+ (operation.operationKind === 'comment' || operation.operationKind === 'comment_reply')
259
+ && result?.hasChanges !== true
260
+ && result?.status !== 'error'
261
+ && !result?.error
262
+ ) {
263
+ result = {
264
+ ...result,
265
+ status: 'error',
266
+ error: {
267
+ code: 'COMMENT_NOT_APPLIED',
268
+ message: 'The comment operation completed without placing a comment.'
269
+ }
270
+ };
271
+ }
272
+ if (operationWarnings.length > 0 && result) {
273
+ const merged = Array.from(new Set([
274
+ ...(Array.isArray(result.warnings) ? result.warnings : []),
275
+ ...operationWarnings
276
+ ]));
277
+ result.warnings = merged;
278
+ }
279
+ const isError = result?.status === 'error' || !!result?.error;
280
+ let operationReceipt = null;
281
+ if (isError || result?.hasChanges !== true) {
282
+ session.restoreSavepoint(savepoint);
283
+ const disposition = isError ? 'refused' : 'no_change';
284
+ operationReceipt = createEmptyReceipt(
285
+ operationIndex,
286
+ operation.operationId,
287
+ authorUsed,
288
+ disposition
289
+ );
290
+ if (Array.isArray(result?.warnings)) {
291
+ for (const w of result.warnings) {
292
+ operationReceipt.warnings.push(String(w));
293
+ }
294
+ }
295
+ } else {
296
+ session.markMutationCommitted(operation.operationKind !== 'comment_reply');
297
+ if (operation.captureKey && session.captureTable) {
298
+ session.captureTable.set(
299
+ operation.captureKey,
300
+ deriveCapturedEntity(session, operation, operationOptions._mutationLiveNodes)
301
+ );
302
+ }
303
+ if (operationOptions._mutationRemovedNodes?.length > 0 && session.captureTable) {
304
+ invalidateAffectedCaptures(session.captureTable, operationOptions._mutationRemovedNodes);
305
+ }
306
+ if (resolutionCapture.resolvedTarget) {
307
+ session.receiptCollector?.recordAffectedTarget(resolutionCapture.resolvedTarget);
308
+ }
309
+ if (Array.isArray(result.warnings)) {
310
+ for (const w of result.warnings) {
311
+ session.receiptCollector?.recordWarning(w);
312
+ }
313
+ }
314
+ operationReceipt = session.receiptCollector?.commitOperation('applied');
315
+ result.documentXml = session.deferSerialization
316
+ ? session.currentDocumentXml
317
+ : session.serializeCurrent();
318
+
319
+ if (!session.deferSerialization && operationReceipt) {
320
+ const reconciliation = reconcileReceiptsAgainstOutput({
321
+ documentXml: result.documentXml,
322
+ commentsXml: result.commentsXml || null,
323
+ numberingXml: result.numberingXml || null
324
+ }, [operationReceipt]);
325
+ if (!reconciliation.valid) {
326
+ session.restoreSavepoint(savepoint);
327
+ operationReceipt.finalDisposition = 'rolled_back';
328
+ operationReceipt.committed = false;
329
+ return {
330
+ documentXml,
331
+ hasChanges: false,
332
+ status: 'error',
333
+ error: reconciliation.error,
334
+ warnings: [reconciliation.error.message],
335
+ operationType: operation.operationKind,
336
+ authorUsed,
337
+ receipt: operationReceipt,
338
+ ...resolutionCapture
339
+ };
340
+ }
341
+ }
342
+ }
343
+ return {
344
+ ...result,
345
+ operationType: operation.operationKind,
346
+ authorUsed,
347
+ receipt: operationReceipt,
348
+ ...resolutionCapture
349
+ };
350
+ } catch (error) {
351
+ session.restoreSavepoint(savepoint);
352
+ const normalizedError = normalizeOperationError(error);
353
+ const operationReceipt = createEmptyReceipt(
354
+ operationIndex,
355
+ operation.operationId,
356
+ authorUsed,
357
+ 'refused'
358
+ );
359
+ operationReceipt.warnings.push(normalizedError.message);
360
+ return {
361
+ documentXml,
362
+ hasChanges: false,
363
+ status: 'error',
364
+ error: normalizedError,
365
+ warnings: [normalizedError.message],
366
+ operationType: operation.operationKind,
367
+ authorUsed,
368
+ receipt: operationReceipt,
369
+ ...resolutionCapture
370
+ };
371
+ }
372
+ }