@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  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/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,1733 @@
1
+ /**
2
+ * OOXML mutation implementations used by the document operation applier.
3
+ */
4
+
5
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
+ import { findReconstructionParagraphRange } from '../engine/reconstruction-mapper.js';
7
+ import { createRevisionMetadata } from '../core/types.js';
8
+ import { containsTrackedChanges, getTrackedChangeAuthors, createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
9
+ import {
10
+ markParagraphMarkInserted,
11
+ markParagraphMarkDeleted,
12
+ snapshotAndAttachRPrChange,
13
+ snapshotAndAttachPPrChange
14
+ } from '../engine/run-builders.js';
15
+ import {
16
+ applyCharacterFormatToRPr,
17
+ checkRunPropertiesChanged,
18
+ applyParagraphPropertiesToPPr,
19
+ checkParagraphPropertiesChanged
20
+ } from '../engine/rpr-helpers.js';
21
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
22
+ import { getDefaultAuthor } from '../adapters/config.js';
23
+ import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
24
+ import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
25
+ import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
26
+ import { injectCommentsIntoOoxml } from './comment-engine.js';
27
+ import {
28
+ getParagraphText as getParagraphTextFromOxml,
29
+ getParagraphId,
30
+ createParagraphFingerprint,
31
+ isMarkdownTableText,
32
+ findContainingWordElement,
33
+ resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
34
+ resolveParagraphRangeByRefs,
35
+ validateParagraphBoundaryMutation
36
+ } from '../core/paragraph-targeting.js';
37
+ import {
38
+ synthesizeExpandedListScopeEdit,
39
+ planListInsertionOnlyEdit,
40
+ getParagraphListInfo,
41
+ stripRedundantLeadingListMarkers
42
+ } from '../core/list-targeting.js';
43
+ import {
44
+ synthesizeTableMarkdownFromMultilineCellEdit,
45
+ inferTableReplacementParagraphBlock
46
+ } from '../core/table-targeting.js';
47
+ import {
48
+ buildSingleLineListStructuralFallbackPlan,
49
+ executeSingleLineListStructuralFallback,
50
+ resolveSingleLineListFallbackNumberingAction,
51
+ recordSingleLineListFallbackExplicitSequence,
52
+ clearSingleLineListFallbackExplicitSequence,
53
+ enforceListBindingOnParagraphNodes,
54
+ stripSingleLineListMarkerPrefix
55
+ } from '../orchestration/list-structural-fallback.js';
56
+ import {
57
+ reserveNextNumberingIdPair,
58
+ remapNumberingPayloadForDocument,
59
+ overwriteParagraphNumIds,
60
+ extractFirstParagraphNumId,
61
+ buildExplicitDecimalMultilevelNumberingXml
62
+ } from './numbering-helpers.js';
63
+ import {
64
+ extractReplacementNodesFromOoxml,
65
+ normalizeBodySectionOrderStandalone
66
+ } from './standalone-docx-plumbing.js';
67
+ import { prepareRevisionAllocator } from './document-operation-session.js';
68
+ import {
69
+ buildExplicitRangeInsertionEntries,
70
+ deriveSingleParagraphListAdjacencyInsertion,
71
+ deriveSingleParagraphPlainAdjacencyInsertion
72
+ } from './operation-heuristics.js';
73
+ import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
74
+
75
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
76
+
77
+ function getCommentIdsInElement(element) {
78
+ const ids = new Set();
79
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
80
+ for (const node of Array.from(element?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
81
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
82
+ if (id !== '') ids.add(id);
83
+ }
84
+ }
85
+ return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
86
+ }
87
+
88
+ function commentDetailsForIds(ids, detailsById) {
89
+ if (!detailsById || typeof detailsById !== 'object') return [];
90
+ return ids.map(id => detailsById[id]).filter(Boolean);
91
+ }
92
+
93
+ function resolveMutationDocument(documentXml, options = {}) {
94
+ const operationSession = options?._documentOperationSession || null;
95
+ if (operationSession?.valid && operationSession.document) {
96
+ return {
97
+ xmlDoc: operationSession.document,
98
+ serializer: operationSession.serializer,
99
+ operationSession
100
+ };
101
+ }
102
+ return {
103
+ xmlDoc: parseOoxmlSafe(documentXml, 'application/xml').doc,
104
+ serializer: createSerializer(),
105
+ operationSession: null
106
+ };
107
+ }
108
+
109
+ function completedDocumentXml(xmlDoc, serializer, documentXml, operationSession) {
110
+ return operationSession ? documentXml : serializer.serializeToString(xmlDoc);
111
+ }
112
+
113
+ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
114
+ const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
115
+ if (result?.useNativeApi && typeof result?.oxml !== 'string') {
116
+ return withOoxmlSourceType({
117
+ ...result,
118
+ oxml,
119
+ hasChanges: false,
120
+ warnings: [
121
+ ...(Array.isArray(result?.warnings) ? result.warnings : []),
122
+ 'Standalone mode cannot execute native Word API fallback for this operation.'
123
+ ]
124
+ });
125
+ }
126
+ return result;
127
+ }
128
+
129
+ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, options = {}) {
130
+ const sourceOoxml = typeof oxml === 'string' ? oxml : '';
131
+ const tableText = typeof markdownTable === 'string' ? markdownTable : String(markdownTable || '');
132
+ let tableData;
133
+ try {
134
+ tableData = parseMarkdownTable(tableText);
135
+ } catch {
136
+ tableData = { headers: [], rows: [] };
137
+ }
138
+ if (!((tableData?.headers?.length || 0) > 0 || (tableData?.rows?.length || 0) > 0)) {
139
+ return {
140
+ oxml: sourceOoxml,
141
+ hasChanges: false,
142
+ isMarkdownTable: false,
143
+ warnings: ['Could not parse Markdown table from input.']
144
+ };
145
+ }
146
+ return {
147
+ ...await applyRedlineToOxml(sourceOoxml, originalText || '', tableText, options),
148
+ isMarkdownTable: true,
149
+ tableData
150
+ };
151
+ }
152
+
153
+ function getParagraphText(paragraph) {
154
+ return getParagraphTextFromOxml(paragraph);
155
+ }
156
+
157
+ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
158
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
159
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
160
+ const session = options?._documentOperationSession || null;
161
+ const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
162
+
163
+ if (options?.targetDescriptor?.captureRef) {
164
+ try {
165
+ const resolved = resolveTargetFromCapture(xmlDoc, session, options.targetDescriptor, opType, options);
166
+ if (options?._resolutionCapture && resolved?.paragraph) {
167
+ const paragraph = resolved.paragraph;
168
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
169
+ Object.assign(options._resolutionCapture, {
170
+ resolvedBy: resolved.resolvedBy,
171
+ resolvedTarget: {
172
+ index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
173
+ paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
174
+ text: metadata?.text ?? getParagraphText(paragraph),
175
+ fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
176
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
177
+ }
178
+ });
179
+ }
180
+ return resolved;
181
+ } catch (error) {
182
+ return {
183
+ error: {
184
+ code: typeof error?.code === 'string' && error.code ? error.code : 'TARGET_NOT_FOUND',
185
+ message: error?.message || String(error)
186
+ }
187
+ };
188
+ }
189
+ }
190
+
191
+ const resolved = resolveTargetParagraphWithSnapshotShared(xmlDoc, {
192
+ targetText,
193
+ targetRef,
194
+ opType,
195
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
196
+ targetDescriptor: options?.targetDescriptor || null,
197
+ strictAmbiguity: options?.strictTargets === true,
198
+ paragraphMetadataIndex,
199
+ onInfo,
200
+ onWarn
201
+ });
202
+ if (options?._resolutionCapture && resolved?.paragraph) {
203
+ const paragraph = resolved.paragraph;
204
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
205
+ Object.assign(options._resolutionCapture, {
206
+ resolvedBy: resolved.resolvedBy,
207
+ resolvedTarget: {
208
+ index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
209
+ paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
210
+ text: metadata?.text ?? getParagraphText(paragraph),
211
+ fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
212
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
213
+ }
214
+ });
215
+ }
216
+ return resolved;
217
+ }
218
+
219
+ function extractReplacementNodes(outputOxml) {
220
+ return extractReplacementNodesFromOoxml(outputOxml);
221
+ }
222
+
223
+ function removeListPackagingSentinel(replacementNodes, warnings) {
224
+ if (!Array.isArray(replacementNodes) || replacementNodes.length === 0) return replacementNodes;
225
+ if (!Array.isArray(warnings) || !warnings.includes('Paragraph expanded to list fragment')) return replacementNodes;
226
+
227
+ const last = replacementNodes[replacementNodes.length - 1];
228
+ if (!last || last.localName !== 'p') return replacementNodes;
229
+ const meaningfulContent = Array.from(last.childNodes || []).some(child => (
230
+ child.nodeType === 1 && child.localName !== 'pPr'
231
+ ));
232
+ if (meaningfulContent) return replacementNodes;
233
+ return replacementNodes.slice(0, -1);
234
+ }
235
+
236
+ function normalizeBodySectionOrder(xmlDoc) {
237
+ normalizeBodySectionOrderStandalone(xmlDoc);
238
+ }
239
+
240
+ function removeProofErrNodes(paragraph) {
241
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
242
+ node.parentNode?.removeChild(node);
243
+ }
244
+ }
245
+
246
+ function preprocessRedlineTargetParagraph(targetParagraph) {
247
+ if (!targetParagraph) return;
248
+ removeProofErrNodes(targetParagraph);
249
+ }
250
+
251
+ function getDirectWordChild(element, localName) {
252
+ if (!element) return null;
253
+ return Array.from(element.childNodes || []).find(
254
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
255
+ ) || null;
256
+ }
257
+
258
+ function computeTableIndexInDocument(xmlDoc, tableElement) {
259
+ if (!xmlDoc || !tableElement) return null;
260
+ const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
261
+ const idx = tables.indexOf(tableElement);
262
+ return idx >= 0 ? idx + 1 : null;
263
+ }
264
+
265
+ function normalizeMultilineTableStructuralPayload(text) {
266
+ return String(text || '')
267
+ .replace(/\r\n/g, '\n')
268
+ .split('\n')
269
+ .map(line => line.trim())
270
+ .filter(Boolean)
271
+ .join('\n');
272
+ }
273
+
274
+ function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
275
+ const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
276
+ if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
277
+ const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
278
+ if (!normalizedPayload) return null;
279
+ return `table:${tableIndex}|payload:${normalizedPayload}`;
280
+ }
281
+
282
+ function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
283
+ let pPr = getDirectWordChild(paragraph, 'pPr');
284
+ if (!pPr) {
285
+ pPr = createWordElement(xmlDoc, 'w:pPr');
286
+ paragraph.insertBefore(pPr, paragraph.firstChild);
287
+ }
288
+
289
+ let numPr = getDirectWordChild(pPr, 'numPr');
290
+ if (!numPr) {
291
+ numPr = createWordElement(xmlDoc, 'w:numPr');
292
+ pPr.appendChild(numPr);
293
+ }
294
+
295
+ let ilvlEl = getDirectWordChild(numPr, 'ilvl');
296
+ if (!ilvlEl) {
297
+ ilvlEl = createWordElement(xmlDoc, 'w:ilvl');
298
+ numPr.appendChild(ilvlEl);
299
+ }
300
+ ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
301
+
302
+ let numIdEl = getDirectWordChild(numPr, 'numId');
303
+ if (!numIdEl) {
304
+ numIdEl = createWordElement(xmlDoc, 'w:numId');
305
+ numPr.appendChild(numIdEl);
306
+ }
307
+ numIdEl.setAttribute('w:val', String(numId));
308
+ }
309
+
310
+ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMetadata, author, options = {}) {
311
+ const generateRedlines = options.generateRedlines !== false;
312
+ const paragraph = createWordElement(xmlDoc, 'w:p');
313
+
314
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
315
+ if (anchorPPr) {
316
+ paragraph.appendChild(anchorPPr.cloneNode(true));
317
+ }
318
+ ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
319
+
320
+ // A new list item is a whole inserted paragraph, not merely inserted text.
321
+ // Tracking its paragraph mark lets Word (and our accept/reject helpers)
322
+ // remove the list paragraph itself on Reject All instead of leaving an
323
+ // empty bullet or number behind.
324
+ if (generateRedlines) {
325
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
326
+ }
327
+
328
+ const run = createWordElement(xmlDoc, 'w:r');
329
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
330
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
331
+ if (anchorRunPr) {
332
+ run.appendChild(anchorRunPr.cloneNode(true));
333
+ }
334
+
335
+ const textNode = createWordElement(xmlDoc, 'w:t');
336
+ const safeText = String(entry.text || '').trim();
337
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
338
+ textNode.textContent = safeText;
339
+ run.appendChild(textNode);
340
+ if (generateRedlines) {
341
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
342
+ const ins = createWordElement(xmlDoc, 'w:ins');
343
+ ins.setAttribute('w:id', String(metadata.id));
344
+ ins.setAttribute('w:author', metadata.author);
345
+ ins.setAttribute('w:date', metadata.date);
346
+ ins.appendChild(run);
347
+ paragraph.appendChild(ins);
348
+ } else {
349
+ paragraph.appendChild(run);
350
+ }
351
+
352
+ return paragraph;
353
+ }
354
+
355
+ function serializeParagraphRangeAsDocument(paragraphs, serializer) {
356
+ const paragraphXml = (paragraphs || [])
357
+ .map(paragraph => serializer.serializeToString(paragraph))
358
+ .join('');
359
+ return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
360
+ }
361
+ function applyExplicitRangeListInsertions({
362
+ xmlDoc,
363
+ explicitRangeParagraphs,
364
+ insertionEntries,
365
+ generateRedlines,
366
+ author
367
+ }) {
368
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
369
+ if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
370
+
371
+ const parent = explicitRangeParagraphs[0].parentNode;
372
+ if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
373
+
374
+ const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
375
+ for (const entry of insertionEntries) {
376
+ const referenceParagraph = entry.insertBeforeOriginalIndex != null
377
+ ? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
378
+ : explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
379
+ if (!referenceParagraph) return false;
380
+
381
+ const listParagraph = buildInsertedListParagraph(
382
+ xmlDoc,
383
+ referenceParagraph,
384
+ {
385
+ ilvl: entry.ilvl,
386
+ markerType: entry.markerType,
387
+ numId: entry.numId,
388
+ text: entry.text
389
+ },
390
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
391
+ author,
392
+ { generateRedlines }
393
+ );
394
+
395
+ if (entry.insertBeforeOriginalIndex != null) {
396
+ parent.insertBefore(listParagraph, referenceParagraph);
397
+ } else {
398
+ parent.insertBefore(listParagraph, tailInsertionPoint);
399
+ }
400
+ }
401
+
402
+ normalizeBodySectionOrder(xmlDoc);
403
+ return true;
404
+ }
405
+ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
406
+ const generateRedlines = options.generateRedlines !== false;
407
+ const paragraph = createWordElement(xmlDoc, 'w:p');
408
+ const run = createWordElement(xmlDoc, 'w:r');
409
+ const textNode = createWordElement(xmlDoc, 'w:t');
410
+ const safeText = String(text || '');
411
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
412
+ textNode.textContent = safeText;
413
+ run.appendChild(textNode);
414
+
415
+ if (generateRedlines) {
416
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
417
+ const ins = createWordElement(xmlDoc, 'w:ins');
418
+ ins.setAttribute('w:id', String(metadata.id));
419
+ ins.setAttribute('w:author', metadata.author);
420
+ ins.setAttribute('w:date', metadata.date);
421
+ ins.appendChild(run);
422
+ paragraph.appendChild(ins);
423
+ } else {
424
+ paragraph.appendChild(run);
425
+ }
426
+
427
+ return paragraph;
428
+ }
429
+
430
+ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
431
+ const paragraph = createWordElement(xmlDoc, 'w:p');
432
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
433
+ if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
434
+
435
+ const run = createWordElement(xmlDoc, 'w:r');
436
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
437
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
438
+ if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
439
+
440
+ const textNode = createWordElement(xmlDoc, 'w:t');
441
+ textNode.textContent = '';
442
+ run.appendChild(textNode);
443
+ paragraph.appendChild(run);
444
+ return paragraph;
445
+ }
446
+
447
+ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
448
+ const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
449
+ const pPr = getDirectWordChild(paragraph, 'pPr');
450
+ if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
451
+
452
+ const ins = createWordElement(xmlDoc, 'w:ins');
453
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
454
+ ins.setAttribute('w:id', String(metadata.id));
455
+ ins.setAttribute('w:author', metadata.author);
456
+ ins.setAttribute('w:date', metadata.date);
457
+
458
+ for (const child of Array.from(paragraph.childNodes || [])) {
459
+ if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
460
+ ins.appendChild(child.cloneNode(true));
461
+ }
462
+
463
+ wrappedParagraph.appendChild(ins);
464
+ return wrappedParagraph;
465
+ }
466
+
467
+ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionMetadata, author, options = {}) {
468
+ const generateRedlines = options.generateRedlines !== false;
469
+ const serializer = createSerializer();
470
+ const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
471
+ const templateXml = serializer.serializeToString(templateParagraph);
472
+ const markdownResult = await applyRedlineToOxml(
473
+ templateXml,
474
+ '',
475
+ String(text || ''),
476
+ {
477
+ author,
478
+ generateRedlines: false
479
+ }
480
+ );
481
+
482
+ let sourceParagraph = null;
483
+ if (typeof markdownResult?.oxml === 'string') {
484
+ const extracted = extractReplacementNodes(markdownResult.oxml);
485
+ sourceParagraph = (extracted.replacementNodes || []).find(
486
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
487
+ ) || null;
488
+ }
489
+
490
+ if (!sourceParagraph) {
491
+ return buildFallbackInsertedPlainParagraph(
492
+ xmlDoc,
493
+ text,
494
+ revisionMetadata,
495
+ author,
496
+ { generateRedlines }
497
+ );
498
+ }
499
+
500
+ if (!generateRedlines) {
501
+ return sourceParagraph;
502
+ }
503
+
504
+ return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
505
+ }
506
+
507
+ function collectNumberingIdsFromNodes(nodes) {
508
+ const ids = new Set();
509
+ for (const node of nodes || []) {
510
+ const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
511
+ for (const numIdNode of numIdNodes) {
512
+ const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
513
+ if (val != null && val !== '') {
514
+ ids.add(String(val));
515
+ }
516
+ }
517
+ }
518
+ return Array.from(ids);
519
+ }
520
+
521
+ async function tryExplicitDecimalHeaderListConversion({
522
+ xmlDoc,
523
+ serializer,
524
+ documentXml,
525
+ operationSession,
526
+ targetParagraph,
527
+ currentParagraphText,
528
+ modifiedText,
529
+ author,
530
+ runtimeContext,
531
+ revisionIdAllocator,
532
+ generateRedlines = true,
533
+ onInfo = () => { },
534
+ options = {}
535
+ }) {
536
+ if (!targetParagraph) return null;
537
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
538
+ const explicitPlan = buildSingleLineListStructuralFallbackPlan({
539
+ oxml: scopedParagraphOxml,
540
+ originalText: currentParagraphText,
541
+ modifiedText,
542
+ allowExistingList: false
543
+ });
544
+ if (
545
+ !explicitPlan ||
546
+ explicitPlan.numberingKey !== 'numbered:decimal:single' ||
547
+ !Number.isInteger(explicitPlan.startAt) ||
548
+ explicitPlan.startAt < 1
549
+ ) {
550
+ return null;
551
+ }
552
+
553
+ const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
554
+ if (!strippedContent) return null;
555
+
556
+ onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
557
+ const redlineResult = await applyRedlineToOxml(
558
+ serializer.serializeToString(targetParagraph),
559
+ currentParagraphText,
560
+ strippedContent,
561
+ {
562
+ author,
563
+ generateRedlines,
564
+ _revisionIdAllocator: revisionIdAllocator
565
+ }
566
+ );
567
+ if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
568
+
569
+ const extracted = extractReplacementNodes(redlineResult.oxml);
570
+ const replacementNodes = extracted.replacementNodes;
571
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
572
+ explicitPlan,
573
+ runtimeContext?.listFallbackSequenceState || null
574
+ );
575
+
576
+ const explicitStart = explicitPlan.startAt;
577
+ const numberingState = runtimeContext?.numberingIdState || null;
578
+ let appliedNumId = null;
579
+ let numberingXml = null;
580
+
581
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
582
+ appliedNumId = String(numberingAction.numId);
583
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
584
+ } else {
585
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
586
+ if (!reservedPair) return null;
587
+
588
+ appliedNumId = String(reservedPair.numId);
589
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
590
+ reservedPair.numId,
591
+ reservedPair.abstractNumId,
592
+ explicitStart
593
+ );
594
+
595
+ if (numberingAction.type === 'explicitStartNew') {
596
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
597
+ }
598
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
599
+ }
600
+
601
+ if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
602
+ runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
603
+ }
604
+
605
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
606
+ recordSingleLineListFallbackExplicitSequence(
607
+ runtimeContext?.listFallbackSequenceState || null,
608
+ numberingAction.numberingKey || explicitPlan.numberingKey,
609
+ appliedNumId,
610
+ explicitStart
611
+ );
612
+ } else {
613
+ clearSingleLineListFallbackExplicitSequence(
614
+ runtimeContext?.listFallbackSequenceState || null,
615
+ numberingAction.numberingKey || explicitPlan.numberingKey
616
+ );
617
+ }
618
+
619
+ enforceListBindingOnParagraphNodes(replacementNodes, {
620
+ numId: appliedNumId,
621
+ ilvl: 0,
622
+ clearParagraphPropertyChanges: true,
623
+ removeListPropertyNode: true
624
+ });
625
+
626
+ const parent = targetParagraph.parentNode;
627
+ if (!parent) return null;
628
+ for (const node of replacementNodes) {
629
+ const imported = xmlDoc.importNode(node, true);
630
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
631
+ const inserted = parent.insertBefore(imported, targetParagraph);
632
+ options?._mutationLiveNodes?.push(inserted);
633
+ }
634
+ options?._mutationRemovedNodes?.push(targetParagraph);
635
+ parent.removeChild(targetParagraph);
636
+ normalizeBodySectionOrder(xmlDoc);
637
+ if (operationSession?.receiptCollector && numberingXml) {
638
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
639
+ for (const id of numIds) {
640
+ operationSession.receiptCollector.recordNumbering(id);
641
+ }
642
+ }
643
+ return {
644
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
645
+ hasChanges: true,
646
+ numberingXml
647
+ };
648
+ }
649
+
650
+ async function trySingleParagraphListStructuralFallback({
651
+ xmlDoc,
652
+ serializer,
653
+ documentXml,
654
+ operationSession,
655
+ targetParagraph,
656
+ currentParagraphText,
657
+ modifiedText,
658
+ author,
659
+ runtimeContext,
660
+ revisionIdAllocator,
661
+ generateRedlines = true,
662
+ onInfo = () => { },
663
+ options = {}
664
+ }) {
665
+ if (!targetParagraph) return null;
666
+
667
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
668
+ const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
669
+ oxml: scopedParagraphOxml,
670
+ originalText: currentParagraphText,
671
+ modifiedText,
672
+ allowExistingList: false
673
+ });
674
+ if (!fallbackPlan) return null;
675
+
676
+ onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
677
+ const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
678
+ author,
679
+ generateRedlines,
680
+ revisionIdAllocator,
681
+ setAbstractStartOverride: false
682
+ });
683
+ if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
684
+ onInfo('[List] Structural list fallback produced no valid OOXML payload.');
685
+ return null;
686
+ }
687
+
688
+ const extracted = extractReplacementNodes(fallbackResult.oxml);
689
+ let replacementNodes = extracted.replacementNodes;
690
+ let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
691
+ const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
692
+ const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
693
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
694
+ fallbackPlan,
695
+ runtimeContext?.listFallbackSequenceState || null
696
+ );
697
+ if (hasExplicitStartAt) {
698
+ const explicitStart = fallbackPlan.startAt;
699
+ let explicitNumIdForBinding = null;
700
+ const numberingState = runtimeContext?.numberingIdState || null;
701
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
702
+ explicitNumIdForBinding = String(numberingAction.numId);
703
+ numberingXml = null;
704
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
705
+ } else if (numberingState) {
706
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
707
+ if (!reservedPair) return null;
708
+ overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
709
+ explicitNumIdForBinding = String(reservedPair.numId);
710
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
711
+ reservedPair.numId,
712
+ reservedPair.abstractNumId,
713
+ explicitStart
714
+ );
715
+ if (numberingAction.type === 'explicitStartNew') {
716
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
717
+ }
718
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
719
+ } else {
720
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
721
+ explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
722
+ onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
723
+ }
724
+
725
+ if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
726
+ runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
727
+ }
728
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
729
+ recordSingleLineListFallbackExplicitSequence(
730
+ runtimeContext?.listFallbackSequenceState || null,
731
+ numberingAction.numberingKey || numberingKey,
732
+ explicitNumIdForBinding,
733
+ explicitStart
734
+ );
735
+ } else {
736
+ clearSingleLineListFallbackExplicitSequence(
737
+ runtimeContext?.listFallbackSequenceState || null,
738
+ numberingAction.numberingKey || numberingKey
739
+ );
740
+ }
741
+
742
+ if (explicitNumIdForBinding) {
743
+ enforceListBindingOnParagraphNodes(replacementNodes, {
744
+ numId: explicitNumIdForBinding,
745
+ ilvl: 0,
746
+ clearParagraphPropertyChanges: true,
747
+ removeListPropertyNode: true
748
+ });
749
+ }
750
+ } else {
751
+ if (numberingXml && runtimeContext?.numberingIdState) {
752
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
753
+ replacementNodes = normalizedNumbering.replacementNodes;
754
+ numberingXml = normalizedNumbering.numberingXml;
755
+ }
756
+ clearSingleLineListFallbackExplicitSequence(
757
+ runtimeContext?.listFallbackSequenceState || null,
758
+ numberingAction.numberingKey || numberingKey
759
+ );
760
+ }
761
+
762
+ if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
763
+ const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
764
+ if (sharedNumId) {
765
+ overwriteParagraphNumIds(replacementNodes, sharedNumId);
766
+ numberingXml = null;
767
+ onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
768
+ } else if (numberingKey) {
769
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
770
+ if (generatedNumId) {
771
+ runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
772
+ onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
773
+ }
774
+ }
775
+ }
776
+
777
+ const parent = targetParagraph.parentNode;
778
+ if (!parent) return null;
779
+ for (const node of replacementNodes) {
780
+ const imported = xmlDoc.importNode(node, true);
781
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
782
+ const inserted = parent.insertBefore(imported, targetParagraph);
783
+ options?._mutationLiveNodes?.push(inserted);
784
+ }
785
+ options?._mutationRemovedNodes?.push(targetParagraph);
786
+ parent.removeChild(targetParagraph);
787
+ normalizeBodySectionOrder(xmlDoc);
788
+ if (operationSession?.receiptCollector && numberingXml) {
789
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
790
+ for (const id of numIds) {
791
+ operationSession.receiptCollector.recordNumbering(id);
792
+ }
793
+ }
794
+ return {
795
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
796
+ hasChanges: true,
797
+ numberingXml
798
+ };
799
+ }
800
+
801
+ export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
802
+ const generateRedlines = options.generateRedlines !== false;
803
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
804
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
805
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
806
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
807
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
808
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, {
809
+ ...options,
810
+ onInfo,
811
+ onWarn
812
+ });
813
+ if (resolved?.error || !resolved?.paragraph) {
814
+ return {
815
+ documentXml,
816
+ hasChanges: false,
817
+ numberingXml: null,
818
+ status: 'error',
819
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
820
+ };
821
+ }
822
+ const targetParagraph = resolved.paragraph;
823
+ preprocessRedlineTargetParagraph(targetParagraph);
824
+ const currentParagraphText = getParagraphText(targetParagraph);
825
+ if (modifiedText === '') {
826
+ const commentIds = getCommentIdsInElement(targetParagraph);
827
+ if (commentIds.length > 0) {
828
+ const comments = commentDetailsForIds(commentIds, options._existingCommentDetails);
829
+ return {
830
+ documentXml,
831
+ hasChanges: false,
832
+ numberingXml: null,
833
+ status: 'error',
834
+ error: {
835
+ code: 'COMMENTED_CONTENT_DELETE',
836
+ message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
837
+ commentIds,
838
+ ...(comments.length > 0 ? { comments } : {})
839
+ }
840
+ };
841
+ }
842
+ }
843
+ const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
844
+ if (!boundaryCheck.valid) {
845
+ return {
846
+ documentXml,
847
+ hasChanges: false,
848
+ numberingXml: null,
849
+ status: 'error',
850
+ error: {
851
+ code: boundaryCheck.code,
852
+ message: boundaryCheck.message
853
+ }
854
+ };
855
+ }
856
+ const hasRevisions = containsTrackedChanges(targetParagraph);
857
+ const existingPolicy = options.existingRevisions || 'merge-same-author';
858
+ if (
859
+ hasRevisions
860
+ && existingPolicy !== 'accept-all-first'
861
+ && existingPolicy !== 'accept-all-first-keep-normalized'
862
+ ) {
863
+ if (existingPolicy === 'merge-same-author') {
864
+ const authors = getTrackedChangeAuthors(targetParagraph);
865
+ const opAuthor = String(author || '').trim().toLowerCase();
866
+ const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
867
+ if (!allSame) {
868
+ return {
869
+ documentXml,
870
+ hasChanges: false,
871
+ numberingXml: null,
872
+ status: 'error',
873
+ error: {
874
+ code: 'EXISTING_REVISIONS',
875
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
876
+ }
877
+ };
878
+ }
879
+ const mergeCommentIds = getCommentIdsInElement(targetParagraph);
880
+ if (mergeCommentIds.length > 0) {
881
+ const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
882
+ return {
883
+ documentXml,
884
+ hasChanges: false,
885
+ numberingXml: null,
886
+ status: 'error',
887
+ error: {
888
+ code: 'COMMENTED_CONTENT_MERGE',
889
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
890
+ commentIds: mergeCommentIds,
891
+ ...(comments.length > 0 ? { comments } : {})
892
+ }
893
+ };
894
+ }
895
+ } else {
896
+ const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
897
+ const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
898
+ || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
899
+ if (hasDel) {
900
+ return {
901
+ documentXml,
902
+ hasChanges: false,
903
+ numberingXml: null,
904
+ status: 'error',
905
+ error: {
906
+ code: 'UNSAFE_REVISION_NESTING',
907
+ message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
908
+ }
909
+ };
910
+ }
911
+ if (hasMove) {
912
+ return {
913
+ documentXml,
914
+ hasChanges: false,
915
+ numberingXml: null,
916
+ status: 'error',
917
+ error: {
918
+ code: 'UNSAFE_REVISION_NESTING',
919
+ message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
920
+ }
921
+ };
922
+ }
923
+ return {
924
+ documentXml,
925
+ hasChanges: false,
926
+ numberingXml: null,
927
+ status: 'error',
928
+ error: {
929
+ code: 'EXISTING_REVISIONS',
930
+ message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
931
+ }
932
+ };
933
+ }
934
+ }
935
+ if (
936
+ modifiedText === ''
937
+ && currentParagraphText === ''
938
+ && !(hasRevisions && existingPolicy === 'merge-same-author')
939
+ ) {
940
+ if (generateRedlines) {
941
+ markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
942
+ options?._mutationLiveNodes?.push(targetParagraph);
943
+ } else {
944
+ options?._mutationRemovedNodes?.push(targetParagraph);
945
+ targetParagraph.parentNode?.removeChild(targetParagraph);
946
+ }
947
+ normalizeBodySectionOrder(xmlDoc);
948
+ return {
949
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
950
+ hasChanges: true,
951
+ numberingXml: null
952
+ };
953
+ }
954
+ const containingTable = findContainingWordElement(targetParagraph, 'tbl');
955
+ const rawTableStructuralCandidate = !!containingTable
956
+ && !targetEndRef
957
+ && typeof modifiedText === 'string'
958
+ && modifiedText.includes('\n')
959
+ && !isMarkdownTableText(modifiedText);
960
+ const rawTableStructuralDedupeKey = rawTableStructuralCandidate
961
+ ? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
962
+ : null;
963
+ const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
964
+ ? runtimeContext.tableStructuralRedlineKeys
965
+ : null;
966
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
967
+ onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
968
+ return {
969
+ documentXml,
970
+ hasChanges: false,
971
+ numberingXml: null,
972
+ warnings: ['Skipped duplicate table-structural redline in the same turn.']
973
+ };
974
+ }
975
+ const synthesizedTableMarkdown = containingTable
976
+ ? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
977
+ tableElement: containingTable,
978
+ currentParagraphText,
979
+ onInfo,
980
+ onWarn
981
+ })
982
+ : null;
983
+ let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
984
+ const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
985
+ const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
986
+ const explicitRangeParagraphs = targetEndRef
987
+ ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
988
+ opType: 'redline',
989
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
990
+ onInfo,
991
+ onWarn
992
+ })
993
+ : (typeof targetText === 'string' && /\r?\n/.test(targetText)
994
+ ? findReconstructionParagraphRange(xmlDoc, targetText)
995
+ : null);
996
+ const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
997
+ if (!useTableScope && hasExplicitRangeScope) {
998
+ const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
999
+ if (insertionEntries && insertionEntries.length > 0) {
1000
+ onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
1001
+ for (const entry of insertionEntries) {
1002
+ onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1003
+ }
1004
+ const applied = applyExplicitRangeListInsertions({
1005
+ xmlDoc,
1006
+ explicitRangeParagraphs,
1007
+ insertionEntries,
1008
+ generateRedlines,
1009
+ author
1010
+ });
1011
+ if (applied) {
1012
+ return {
1013
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1014
+ hasChanges: true,
1015
+ numberingXml: null
1016
+ };
1017
+ }
1018
+ }
1019
+ }
1020
+ let inferredTableRangeParagraphs = null;
1021
+ if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
1022
+ inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
1023
+ getParagraphText
1024
+ });
1025
+ if (inferredTableRangeParagraphs?.length > 1) {
1026
+ onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
1027
+ }
1028
+ }
1029
+ const targetListInfo = getParagraphListInfo(targetParagraph);
1030
+ if (
1031
+ targetListInfo &&
1032
+ typeof effectiveModifiedText === 'string' &&
1033
+ !effectiveModifiedText.includes('\n')
1034
+ ) {
1035
+ const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
1036
+ if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
1037
+ onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
1038
+ effectiveModifiedText = strippedListPrefix;
1039
+ }
1040
+ }
1041
+ if (useTableScope) {
1042
+ onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
1043
+ }
1044
+
1045
+ const bypassSingleParagraphHeuristics = options.explicitStructuredContent === true;
1046
+
1047
+ const adjacencyInsertionCandidate = (
1048
+ !bypassSingleParagraphHeuristics
1049
+ && !useTableScope
1050
+ && !hasExplicitRangeScope
1051
+ && targetListInfo
1052
+ )
1053
+ ? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1054
+ : null;
1055
+ if (adjacencyInsertionCandidate) {
1056
+ onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
1057
+ const parent = targetParagraph.parentNode;
1058
+ if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
1059
+
1060
+ const listParagraph = buildInsertedListParagraph(
1061
+ xmlDoc,
1062
+ targetParagraph,
1063
+ {
1064
+ ilvl: targetListInfo.ilvl,
1065
+ numId: targetListInfo.numId,
1066
+ markerType: 'numbered',
1067
+ text: adjacencyInsertionCandidate.text
1068
+ },
1069
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1070
+ author,
1071
+ { generateRedlines }
1072
+ );
1073
+
1074
+ const insertionPoint = adjacencyInsertionCandidate.position === 'before'
1075
+ ? targetParagraph
1076
+ : targetParagraph.nextSibling;
1077
+ ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
1078
+ parent.insertBefore(listParagraph, insertionPoint);
1079
+ options?._mutationLiveNodes?.push(listParagraph);
1080
+ normalizeBodySectionOrder(xmlDoc);
1081
+ return {
1082
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1083
+ hasChanges: true,
1084
+ numberingXml: null
1085
+ };
1086
+ }
1087
+
1088
+ const plainAdjacencyInsertionCandidate = (
1089
+ !bypassSingleParagraphHeuristics
1090
+ && !useTableScope
1091
+ && !hasExplicitRangeScope
1092
+ && !targetListInfo
1093
+ )
1094
+ ? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1095
+ : null;
1096
+ if (plainAdjacencyInsertionCandidate) {
1097
+ onInfo(
1098
+ `[Text] Applying single-paragraph plain adjacency insertion heuristic `
1099
+ + `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
1100
+ );
1101
+ const parent = targetParagraph.parentNode;
1102
+ if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
1103
+
1104
+ const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
1105
+ ? targetParagraph
1106
+ : targetParagraph.nextSibling;
1107
+
1108
+ for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
1109
+ const plainParagraph = await buildInsertedPlainParagraph(
1110
+ xmlDoc,
1111
+ targetParagraph,
1112
+ paragraphText,
1113
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1114
+ author,
1115
+ { generateRedlines }
1116
+ );
1117
+ const imported = xmlDoc.importNode(plainParagraph, true);
1118
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1119
+ const inserted = parent.insertBefore(imported, insertionPoint);
1120
+ options?._mutationLiveNodes?.push(inserted);
1121
+ }
1122
+
1123
+ normalizeBodySectionOrder(xmlDoc);
1124
+ return {
1125
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1126
+ hasChanges: true,
1127
+ numberingXml: null
1128
+ };
1129
+ }
1130
+
1131
+ const insertionOnlyPlan = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
1132
+ ? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
1133
+ currentParagraphText,
1134
+ onInfo,
1135
+ onWarn
1136
+ })
1137
+ : null;
1138
+ if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
1139
+ onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
1140
+ for (const entry of insertionOnlyPlan.entries) {
1141
+ onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1142
+ }
1143
+ const parent = targetParagraph.parentNode;
1144
+ if (!parent) throw new Error('Target paragraph has no parent for list insertion');
1145
+ const insertionPoint = targetParagraph.nextSibling;
1146
+ for (const entry of insertionOnlyPlan.entries) {
1147
+ const listParagraph = buildInsertedListParagraph(
1148
+ xmlDoc,
1149
+ targetParagraph,
1150
+ { ...entry, numId: insertionOnlyPlan.numId },
1151
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1152
+ author,
1153
+ { generateRedlines }
1154
+ );
1155
+ ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
1156
+ parent.insertBefore(listParagraph, insertionPoint);
1157
+ options?._mutationLiveNodes?.push(listParagraph);
1158
+ }
1159
+ normalizeBodySectionOrder(xmlDoc);
1160
+ return {
1161
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1162
+ hasChanges: true,
1163
+ numberingXml: null
1164
+ };
1165
+ }
1166
+
1167
+ const listScopeEdit = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
1168
+ ? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
1169
+ currentParagraphText,
1170
+ onInfo,
1171
+ onWarn
1172
+ })
1173
+ : null;
1174
+ const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
1175
+ if (useListScope) {
1176
+ effectiveModifiedText = listScopeEdit.modifiedText;
1177
+ }
1178
+
1179
+ if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
1180
+ const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
1181
+ xmlDoc,
1182
+ serializer,
1183
+ documentXml,
1184
+ operationSession,
1185
+ targetParagraph,
1186
+ currentParagraphText,
1187
+ modifiedText: effectiveModifiedText,
1188
+ author,
1189
+ runtimeContext,
1190
+ revisionIdAllocator,
1191
+ generateRedlines,
1192
+ onInfo,
1193
+ options
1194
+ });
1195
+ if (explicitHeaderListConversion) return explicitHeaderListConversion;
1196
+
1197
+ const listFallback = await trySingleParagraphListStructuralFallback({
1198
+ xmlDoc,
1199
+ serializer,
1200
+ documentXml,
1201
+ operationSession,
1202
+ targetParagraph,
1203
+ currentParagraphText,
1204
+ modifiedText: effectiveModifiedText,
1205
+ author,
1206
+ runtimeContext,
1207
+ revisionIdAllocator,
1208
+ generateRedlines,
1209
+ onInfo,
1210
+ options
1211
+ });
1212
+ if (listFallback) return listFallback;
1213
+ }
1214
+
1215
+ const originalTextForApply = useListScope
1216
+ ? listScopeEdit.originalText
1217
+ : (
1218
+ explicitRangeParagraphs
1219
+ ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1220
+ : (inferredTableRangeParagraphs
1221
+ ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1222
+ : (currentParagraphText || targetText))
1223
+ );
1224
+ const scopedXml = useTableScope
1225
+ ? serializer.serializeToString(containingTable)
1226
+ : (
1227
+ useListScope
1228
+ ? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
1229
+ : (
1230
+ explicitRangeParagraphs
1231
+ ? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
1232
+ : (inferredTableRangeParagraphs
1233
+ ? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
1234
+ : serializer.serializeToString(targetParagraph))
1235
+ )
1236
+ );
1237
+
1238
+ const result = isTableMarkdownEdit
1239
+ ? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1240
+ author,
1241
+ generateRedlines,
1242
+ existingRevisions: options.existingRevisions || 'merge-same-author',
1243
+ structuredContent: options.structuredContent !== false,
1244
+ explicitStructuredContent: options.explicitStructuredContent === true,
1245
+ _revisionIdAllocator: revisionIdAllocator,
1246
+ _isolatedTableCell: useTableScope
1247
+ })
1248
+ : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1249
+ author,
1250
+ generateRedlines,
1251
+ existingRevisions: options.existingRevisions || 'merge-same-author',
1252
+ structuredContent: options.structuredContent !== false,
1253
+ explicitStructuredContent: options.explicitStructuredContent === true,
1254
+ pairReplacements: options.pairReplacements === true,
1255
+ insertionAffinity: options.insertionAffinity || null,
1256
+ _revisionIdAllocator: revisionIdAllocator,
1257
+ _isolatedTableCell: useTableScope
1258
+ });
1259
+ if (!result?.hasChanges) {
1260
+ return {
1261
+ documentXml,
1262
+ hasChanges: false,
1263
+ numberingXml: null,
1264
+ status: result?.status || 'no-op',
1265
+ error: result?.error
1266
+ };
1267
+ }
1268
+ if (result.useNativeApi && !result.oxml) {
1269
+ const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
1270
+ onWarn(`[WARN] ${warning}`);
1271
+ return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
1272
+ }
1273
+ if (typeof result.oxml !== 'string') {
1274
+ throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
1275
+ }
1276
+ const extracted = extractReplacementNodes(result.oxml);
1277
+ let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
1278
+ let numberingXml = extracted.numberingXml;
1279
+ if (numberingXml && runtimeContext?.numberingIdState) {
1280
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
1281
+ replacementNodes = normalizedNumbering.replacementNodes;
1282
+ numberingXml = normalizedNumbering.numberingXml;
1283
+ }
1284
+ const scopeNodes = useTableScope
1285
+ ? [containingTable]
1286
+ : (
1287
+ useListScope
1288
+ ? listScopeEdit.paragraphs
1289
+ : (
1290
+ explicitRangeParagraphs
1291
+ ? explicitRangeParagraphs
1292
+ : (inferredTableRangeParagraphs || [targetParagraph])
1293
+ )
1294
+ );
1295
+ const anchorNode = scopeNodes[0];
1296
+ const parent = anchorNode.parentNode;
1297
+ for (const node of replacementNodes) {
1298
+ const imported = xmlDoc.importNode(node, true);
1299
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1300
+ const inserted = parent.insertBefore(imported, anchorNode);
1301
+ options?._mutationLiveNodes?.push(inserted);
1302
+ }
1303
+ for (const scopeNode of scopeNodes) {
1304
+ if (scopeNode && scopeNode.parentNode === parent) {
1305
+ options?._mutationRemovedNodes?.push(scopeNode);
1306
+ parent.removeChild(scopeNode);
1307
+ }
1308
+ }
1309
+ normalizeBodySectionOrder(xmlDoc);
1310
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
1311
+ tableStructuralDedupes.add(rawTableStructuralDedupeKey);
1312
+ }
1313
+ if (operationSession?.receiptCollector && numberingXml) {
1314
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
1315
+ for (const id of numIds) {
1316
+ operationSession.receiptCollector.recordNumbering(id);
1317
+ }
1318
+ }
1319
+ return {
1320
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1321
+ hasChanges: true,
1322
+ numberingXml,
1323
+ status: 'ok',
1324
+ ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
1325
+ };
1326
+ }
1327
+
1328
+ export async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
1329
+ const generateRedlines = options.generateRedlines !== false;
1330
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1331
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1332
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1333
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1334
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
1335
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, {
1336
+ ...options,
1337
+ onInfo,
1338
+ onWarn
1339
+ });
1340
+ if (resolved?.error || !resolved?.paragraph) {
1341
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
1342
+ }
1343
+ const targetParagraph = resolved.paragraph;
1344
+ const paragraphXml = serializer.serializeToString(targetParagraph);
1345
+ const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
1346
+ generateRedlines,
1347
+ author,
1348
+ _revisionIdAllocator: revisionIdAllocator
1349
+ });
1350
+ if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
1351
+ const { replacementNodes } = extractReplacementNodes(highlightedXml);
1352
+ const parent = targetParagraph.parentNode;
1353
+ for (const node of replacementNodes) {
1354
+ const imported = xmlDoc.importNode(node, true);
1355
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1356
+ const inserted = parent.insertBefore(imported, targetParagraph);
1357
+ options?._mutationLiveNodes?.push(inserted);
1358
+ }
1359
+ options?._mutationRemovedNodes?.push(targetParagraph);
1360
+ parent.removeChild(targetParagraph);
1361
+ normalizeBodySectionOrder(xmlDoc);
1362
+ return {
1363
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1364
+ hasChanges: true
1365
+ };
1366
+ }
1367
+
1368
+ export async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
1369
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1370
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1371
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1372
+ if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1373
+ if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
1374
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, {
1375
+ ...options,
1376
+ onInfo,
1377
+ onWarn
1378
+ });
1379
+ if (resolved?.error || !resolved?.paragraph) {
1380
+ return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
1381
+ }
1382
+ const targetParagraph = resolved.paragraph;
1383
+ const paragraphXml = serializer.serializeToString(targetParagraph);
1384
+ const commentAnchor = typeof textToComment === 'string' && textToComment.length > 0
1385
+ ? textToComment
1386
+ : getParagraphText(targetParagraph);
1387
+ const commentResult = injectCommentsIntoOoxml(paragraphXml, [{
1388
+ paragraphIndex: 1,
1389
+ textToFind: commentAnchor,
1390
+ commentContent
1391
+ }], { author, commentIdAllocator: options.commentIdAllocator });
1392
+ if (commentResult.status === 'error' || commentResult.error || !commentResult.commentsApplied) {
1393
+ return {
1394
+ documentXml,
1395
+ hasChanges: false,
1396
+ commentsXml: null,
1397
+ status: 'error',
1398
+ error: commentResult.error || {
1399
+ code: 'ANCHOR_INSERTION_FAILED',
1400
+ message: 'The comment anchor was not applied to the resolved paragraph.'
1401
+ },
1402
+ warnings: commentResult.warnings || []
1403
+ };
1404
+ }
1405
+ const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
1406
+ const parent = targetParagraph.parentNode;
1407
+ for (const node of replacementNodes) {
1408
+ const imported = xmlDoc.importNode(node, true);
1409
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1410
+ const inserted = parent.insertBefore(imported, targetParagraph);
1411
+ options?._mutationLiveNodes?.push(inserted);
1412
+ }
1413
+ options?._mutationRemovedNodes?.push(targetParagraph);
1414
+ parent.removeChild(targetParagraph);
1415
+ normalizeBodySectionOrder(xmlDoc);
1416
+ if (operationSession?.receiptCollector && Array.isArray(commentResult.placedComments)) {
1417
+ for (const c of commentResult.placedComments) {
1418
+ operationSession.receiptCollector.recordComment(c.id);
1419
+ }
1420
+ }
1421
+ return {
1422
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1423
+ hasChanges: true,
1424
+ commentsXml: commentResult.commentsXml || null,
1425
+ warnings: commentResult.warnings || [],
1426
+ resolvedAnchor: commentResult.resolvedAnchors?.[0] || null
1427
+ };
1428
+ }
1429
+
1430
+ function splitRunAtTextOffset(xmlDoc, run, localOffset, revisionIdAllocator) {
1431
+ const run1 = run.cloneNode(true);
1432
+ const run2 = run.cloneNode(true);
1433
+ refreshRunPropertyChangeIds(run2, revisionIdAllocator);
1434
+
1435
+ const isTextPiece = (node) => {
1436
+ if (node.nodeType !== 1) return false;
1437
+ const ln = node.localName || node.nodeName.replace(/^w:/, '');
1438
+ return ['t', 'tab', 'br', 'noBreakHyphen', 'delText'].includes(ln);
1439
+ };
1440
+
1441
+ Array.from(run1.childNodes).forEach(c => { if (isTextPiece(c)) run1.removeChild(c); });
1442
+ Array.from(run2.childNodes).forEach(c => { if (isTextPiece(c)) run2.removeChild(c); });
1443
+
1444
+ let fullText = '';
1445
+ for (const child of Array.from(run.childNodes)) {
1446
+ if (child.nodeType !== 1) continue;
1447
+ const ln = child.localName || child.nodeName.replace(/^w:/, '');
1448
+ if (ln === 't') fullText += child.textContent || '';
1449
+ else if (ln === 'tab') fullText += '\t';
1450
+ else if (ln === 'br') fullText += '\n';
1451
+ else if (ln === 'noBreakHyphen') fullText += '\u2011';
1452
+ }
1453
+
1454
+ const text1 = fullText.slice(0, localOffset);
1455
+ const text2 = fullText.slice(localOffset);
1456
+
1457
+ const appendPieces = (targetRun, text) => {
1458
+ const parts = text.split(/(\t|\n|\u2011)/);
1459
+ for (const part of parts) {
1460
+ if (!part) continue;
1461
+ if (part === '\t') {
1462
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:tab'));
1463
+ } else if (part === '\n') {
1464
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:br'));
1465
+ } else if (part === '\u2011') {
1466
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:noBreakHyphen'));
1467
+ } else {
1468
+ const tEl = createWordElement(xmlDoc, 'w:t');
1469
+ if (/^\s|\s$/.test(part)) tEl.setAttribute('xml:space', 'preserve');
1470
+ tEl.textContent = part;
1471
+ targetRun.appendChild(tEl);
1472
+ }
1473
+ }
1474
+ };
1475
+
1476
+ appendPieces(run1, text1);
1477
+ appendPieces(run2, text2);
1478
+
1479
+ const parent = run.parentNode;
1480
+ parent.insertBefore(run1, run);
1481
+ parent.insertBefore(run2, run);
1482
+ parent.removeChild(run);
1483
+
1484
+ return [run1, run2];
1485
+ }
1486
+
1487
+ export async function applyFormattingToParagraphByExactText(
1488
+ documentXml,
1489
+ targetText,
1490
+ textToFormat,
1491
+ properties = {},
1492
+ author,
1493
+ targetRef = null,
1494
+ runtimeContext = null,
1495
+ options = {}
1496
+ ) {
1497
+ const generateRedlines = options.generateRedlines !== false;
1498
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1499
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1500
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1501
+ if (!xmlDoc) {
1502
+ return {
1503
+ documentXml,
1504
+ hasChanges: false,
1505
+ status: 'error',
1506
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
1507
+ };
1508
+ }
1509
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
1510
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'format', runtimeContext, {
1511
+ ...options,
1512
+ onInfo,
1513
+ onWarn
1514
+ });
1515
+ if (resolved?.error || !resolved?.paragraph) {
1516
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
1517
+ }
1518
+ const targetParagraph = resolved.paragraph;
1519
+
1520
+ const collectVisibleRuns = () => {
1521
+ const runEntries = [];
1522
+ const allRuns = Array.from(targetParagraph.getElementsByTagNameNS(NS_W, 'r'));
1523
+ for (const run of allRuns) {
1524
+ let parent = run.parentNode;
1525
+ let isDel = false;
1526
+ while (parent && parent !== targetParagraph) {
1527
+ if (parent.nodeType === 1 && (parent.localName === 'del' || parent.nodeName === 'w:del')) {
1528
+ isDel = true;
1529
+ break;
1530
+ }
1531
+ parent = parent.parentNode;
1532
+ }
1533
+ if (isDel) continue;
1534
+
1535
+ let runText = '';
1536
+ for (const child of Array.from(run.childNodes)) {
1537
+ if (child.nodeType !== 1) continue;
1538
+ const ln = child.localName || child.nodeName.replace(/^w:/, '');
1539
+ if (ln === 't') runText += child.textContent || '';
1540
+ else if (ln === 'tab') runText += '\t';
1541
+ else if (ln === 'br') runText += '\n';
1542
+ else if (ln === 'noBreakHyphen') runText += '\u2011';
1543
+ }
1544
+ if (runText.length > 0) {
1545
+ runEntries.push({ run, text: runText });
1546
+ }
1547
+ }
1548
+ return runEntries;
1549
+ };
1550
+
1551
+ let runEntries = collectVisibleRuns();
1552
+ let fullParaText = '';
1553
+ let spanRanges = [];
1554
+ for (const entry of runEntries) {
1555
+ const start = fullParaText.length;
1556
+ fullParaText += entry.text;
1557
+ const end = fullParaText.length;
1558
+ spanRanges.push({ ...entry, start, end });
1559
+ }
1560
+
1561
+ const targetDescriptor = options.targetDescriptor;
1562
+ const occurrence = targetDescriptor?.occurrence ?? null;
1563
+ let matchStart = -1;
1564
+ if (occurrence != null && occurrence > 0) {
1565
+ let count = 0;
1566
+ let offset = 0;
1567
+ while (offset <= fullParaText.length - textToFormat.length) {
1568
+ const idx = fullParaText.indexOf(textToFormat, offset);
1569
+ if (idx === -1) break;
1570
+ count++;
1571
+ if (count === occurrence) {
1572
+ matchStart = idx;
1573
+ break;
1574
+ }
1575
+ offset = idx + 1;
1576
+ }
1577
+ } else {
1578
+ matchStart = fullParaText.indexOf(textToFormat);
1579
+ }
1580
+
1581
+ if (matchStart === -1) {
1582
+ return {
1583
+ documentXml,
1584
+ hasChanges: false,
1585
+ status: 'error',
1586
+ error: {
1587
+ code: 'TARGET_NOT_FOUND',
1588
+ message: `Text to format was not found in target paragraph: "${textToFormat}".`
1589
+ }
1590
+ };
1591
+ }
1592
+ const matchEnd = matchStart + textToFormat.length;
1593
+
1594
+ // Split runs at matchStart boundary
1595
+ let currentRuns = [];
1596
+ for (const span of spanRanges) {
1597
+ if (span.start < matchStart && span.end > matchStart) {
1598
+ const localOffset = matchStart - span.start;
1599
+ const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
1600
+ currentRuns.push({ run: r1, start: span.start, end: matchStart, text: span.text.slice(0, localOffset) });
1601
+ currentRuns.push({ run: r2, start: matchStart, end: span.end, text: span.text.slice(localOffset) });
1602
+ } else {
1603
+ currentRuns.push(span);
1604
+ }
1605
+ }
1606
+
1607
+ // Split runs at matchEnd boundary
1608
+ const finalRuns = [];
1609
+ for (const span of currentRuns) {
1610
+ if (span.start < matchEnd && span.end > matchEnd) {
1611
+ const localOffset = matchEnd - span.start;
1612
+ const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
1613
+ finalRuns.push({ run: r1, start: span.start, end: matchEnd, text: span.text.slice(0, localOffset) });
1614
+ finalRuns.push({ run: r2, start: matchEnd, end: span.end, text: span.text.slice(localOffset) });
1615
+ } else {
1616
+ finalRuns.push(span);
1617
+ }
1618
+ }
1619
+
1620
+ const targetRuns = finalRuns.filter(span => span.start >= matchStart && span.end <= matchEnd);
1621
+
1622
+ let anyChanged = false;
1623
+ for (const entry of targetRuns) {
1624
+ const rPr = entry.run.getElementsByTagNameNS(NS_W, 'rPr')[0] || null;
1625
+ if (checkRunPropertiesChanged(rPr, properties)) {
1626
+ anyChanged = true;
1627
+ break;
1628
+ }
1629
+ }
1630
+
1631
+ if (!anyChanged) {
1632
+ return { documentXml, hasChanges: false, status: 'no-op' };
1633
+ }
1634
+
1635
+ for (const entry of targetRuns) {
1636
+ const run = entry.run;
1637
+ let rPr = run.getElementsByTagNameNS(NS_W, 'rPr')[0];
1638
+ if (!rPr) {
1639
+ rPr = createWordElement(xmlDoc, 'w:rPr');
1640
+ run.insertBefore(rPr, run.firstChild);
1641
+ }
1642
+
1643
+ let insAncestor = run.parentNode;
1644
+ let insideSameAuthorIns = false;
1645
+ while (insAncestor && insAncestor !== targetParagraph) {
1646
+ if (insAncestor.nodeType === 1 && (insAncestor.localName === 'ins' || insAncestor.nodeName === 'w:ins')) {
1647
+ const insAuthor = insAncestor.getAttribute('w:author') || insAncestor.getAttributeNS(NS_W, 'author');
1648
+ if (insAuthor === (author || getDefaultAuthor())) {
1649
+ insideSameAuthorIns = true;
1650
+ }
1651
+ break;
1652
+ }
1653
+ insAncestor = insAncestor.parentNode;
1654
+ }
1655
+
1656
+ const coalesce = insideSameAuthorIns && options.formattingRevisionPolicy === 'coalesce-own-insertion';
1657
+
1658
+ if (generateRedlines && !coalesce) {
1659
+ snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
1660
+ }
1661
+
1662
+ applyCharacterFormatToRPr(xmlDoc, rPr, properties);
1663
+ }
1664
+
1665
+ normalizeBodySectionOrder(xmlDoc);
1666
+ ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
1667
+ options?._mutationLiveNodes?.push(targetParagraph);
1668
+
1669
+ return {
1670
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1671
+ hasChanges: true,
1672
+ status: 'ok'
1673
+ };
1674
+ }
1675
+
1676
+ export async function applyParagraphFormatToParagraphByExactText(
1677
+ documentXml,
1678
+ targetText,
1679
+ properties = {},
1680
+ author,
1681
+ targetRef = null,
1682
+ runtimeContext = null,
1683
+ options = {}
1684
+ ) {
1685
+ const generateRedlines = options.generateRedlines !== false;
1686
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1687
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1688
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1689
+ if (!xmlDoc) {
1690
+ return {
1691
+ documentXml,
1692
+ hasChanges: false,
1693
+ status: 'error',
1694
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
1695
+ };
1696
+ }
1697
+ if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
1698
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'paragraph-format', runtimeContext, {
1699
+ ...options,
1700
+ onInfo,
1701
+ onWarn
1702
+ });
1703
+ if (resolved?.error || !resolved?.paragraph) {
1704
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
1705
+ }
1706
+ const targetParagraph = resolved.paragraph;
1707
+
1708
+ let pPr = targetParagraph.getElementsByTagNameNS(NS_W, 'pPr')[0];
1709
+ if (!pPr) {
1710
+ pPr = createWordElement(xmlDoc, 'w:pPr');
1711
+ targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
1712
+ }
1713
+
1714
+ const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
1715
+ if (!hasChanges) {
1716
+ return { documentXml, hasChanges: false, status: 'no-op' };
1717
+ }
1718
+
1719
+ if (generateRedlines) {
1720
+ snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
1721
+ }
1722
+
1723
+ applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
1724
+ normalizeBodySectionOrder(xmlDoc);
1725
+ ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
1726
+ options?._mutationLiveNodes?.push(targetParagraph);
1727
+
1728
+ return {
1729
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1730
+ hasChanges: true,
1731
+ status: 'ok'
1732
+ };
1733
+ }