@ansonlai/docx-redline-js 0.8.1 → 0.8.3

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.
@@ -1,25 +1,25 @@
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';
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
23
  import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
24
24
  import {
25
25
  getParagraphRestorationRefusal,
@@ -34,56 +34,57 @@ import {
34
34
  getRunTextLength,
35
35
  splitTrackChangeCarrier
36
36
  } from '../engine/surgical-run-splitting.js';
37
- import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
38
- import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
39
- import { injectCommentsIntoOoxml } from './comment-engine.js';
40
- import {
41
- getParagraphText as getParagraphTextFromOxml,
42
- getParagraphId,
43
- createParagraphFingerprint,
44
- isMarkdownTableText,
45
- findContainingWordElement,
37
+ import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
38
+ import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
39
+ import { injectCommentsIntoOoxml } from './comment-engine.js';
40
+ import {
41
+ getParagraphText as getParagraphTextFromOxml,
42
+ getParagraphId,
43
+ createParagraphFingerprint,
44
+ isMarkdownTableText,
45
+ findContainingWordElement,
46
46
  resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
47
47
  resolveParagraphRangeByRefs,
48
48
  getDocumentParagraphNodes,
49
49
  validateParagraphBoundaryMutation
50
50
  } from '../core/paragraph-targeting.js';
51
- import {
52
- synthesizeExpandedListScopeEdit,
53
- planListInsertionOnlyEdit,
54
- getParagraphListInfo,
55
- stripRedundantLeadingListMarkers
56
- } from '../core/list-targeting.js';
57
- import {
58
- synthesizeTableMarkdownFromMultilineCellEdit,
59
- inferTableReplacementParagraphBlock
60
- } from '../core/table-targeting.js';
61
- import {
62
- buildSingleLineListStructuralFallbackPlan,
63
- executeSingleLineListStructuralFallback,
64
- resolveSingleLineListFallbackNumberingAction,
65
- recordSingleLineListFallbackExplicitSequence,
66
- clearSingleLineListFallbackExplicitSequence,
67
- enforceListBindingOnParagraphNodes,
68
- stripSingleLineListMarkerPrefix
69
- } from '../orchestration/list-structural-fallback.js';
70
- import {
71
- reserveNextNumberingIdPair,
72
- remapNumberingPayloadForDocument,
73
- overwriteParagraphNumIds,
74
- extractFirstParagraphNumId,
75
- buildExplicitDecimalMultilevelNumberingXml
76
- } from './numbering-helpers.js';
77
- import {
78
- extractReplacementNodesFromOoxml,
79
- normalizeBodySectionOrderStandalone
80
- } from './standalone-docx-plumbing.js';
81
- import { prepareRevisionAllocator } from './document-operation-session.js';
82
- import {
83
- buildExplicitRangeInsertionEntries,
84
- deriveSingleParagraphListAdjacencyInsertion,
85
- deriveSingleParagraphPlainAdjacencyInsertion
86
- } from './operation-heuristics.js';
51
+ import {
52
+ synthesizeExpandedListScopeEdit,
53
+ planListInsertionOnlyEdit,
54
+ getParagraphListInfo,
55
+ stripRedundantLeadingListMarkers
56
+ } from '../core/list-targeting.js';
57
+ import {
58
+ synthesizeTableMarkdownFromMultilineCellEdit,
59
+ inferTableReplacementParagraphBlock
60
+ } from '../core/table-targeting.js';
61
+ import {
62
+ buildSingleLineListStructuralFallbackPlan,
63
+ executeSingleLineListStructuralFallback,
64
+ resolveSingleLineListFallbackNumberingAction,
65
+ recordSingleLineListFallbackExplicitSequence,
66
+ clearSingleLineListFallbackExplicitSequence,
67
+ enforceListBindingOnParagraphNodes,
68
+ stripSingleLineListMarkerPrefix
69
+ } from '../orchestration/list-structural-fallback.js';
70
+ import {
71
+ reserveNextNumberingIdPair,
72
+ remapNumberingPayloadForDocument,
73
+ reuseSourceListNumbering,
74
+ overwriteParagraphNumIds,
75
+ extractFirstParagraphNumId,
76
+ buildExplicitDecimalMultilevelNumberingXml
77
+ } from './numbering-helpers.js';
78
+ import {
79
+ extractReplacementNodesFromOoxml,
80
+ normalizeBodySectionOrderStandalone
81
+ } from './standalone-docx-plumbing.js';
82
+ import { prepareRevisionAllocator } from './document-operation-session.js';
83
+ import {
84
+ buildExplicitRangeInsertionEntries,
85
+ deriveSingleParagraphListAdjacencyInsertion,
86
+ deriveSingleParagraphPlainAdjacencyInsertion
87
+ } from './operation-heuristics.js';
87
88
  import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
88
89
  import {
89
90
  acceptTrackedChangesInOoxml,
@@ -92,83 +93,83 @@ import {
92
93
 
93
94
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
94
95
  const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
95
-
96
- function getCommentIdsInElement(element) {
97
- const ids = new Set();
98
- for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
99
- for (const node of Array.from(element?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
100
- const id = node.getAttribute('w:id') || node.getAttribute('id');
101
- if (id !== '') ids.add(id);
102
- }
103
- }
104
- return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
105
- }
106
-
107
- function commentDetailsForIds(ids, detailsById) {
108
- if (!detailsById || typeof detailsById !== 'object') return [];
109
- return ids.map(id => detailsById[id]).filter(Boolean);
110
- }
111
-
112
- function resolveMutationDocument(documentXml, options = {}) {
113
- const operationSession = options?._documentOperationSession || null;
114
- if (operationSession?.valid && operationSession.document) {
115
- return {
116
- xmlDoc: operationSession.document,
117
- serializer: operationSession.serializer,
118
- operationSession
119
- };
120
- }
121
- return {
122
- xmlDoc: parseOoxmlSafe(documentXml, 'application/xml').doc,
123
- serializer: createSerializer(),
124
- operationSession: null
125
- };
126
- }
127
-
128
- function completedDocumentXml(xmlDoc, serializer, documentXml, operationSession) {
129
- return operationSession ? documentXml : serializer.serializeToString(xmlDoc);
130
- }
131
-
132
- async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
133
- const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
134
- if (result?.useNativeApi && typeof result?.oxml !== 'string') {
135
- return withOoxmlSourceType({
136
- ...result,
137
- oxml,
138
- hasChanges: false,
139
- warnings: [
140
- ...(Array.isArray(result?.warnings) ? result.warnings : []),
141
- 'Standalone mode cannot execute native Word API fallback for this operation.'
142
- ]
143
- });
144
- }
145
- return result;
146
- }
147
-
148
- async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, options = {}) {
149
- const sourceOoxml = typeof oxml === 'string' ? oxml : '';
150
- const tableText = typeof markdownTable === 'string' ? markdownTable : String(markdownTable || '');
151
- let tableData;
152
- try {
153
- tableData = parseMarkdownTable(tableText);
154
- } catch {
155
- tableData = { headers: [], rows: [] };
156
- }
157
- if (!((tableData?.headers?.length || 0) > 0 || (tableData?.rows?.length || 0) > 0)) {
158
- return {
159
- oxml: sourceOoxml,
160
- hasChanges: false,
161
- isMarkdownTable: false,
162
- warnings: ['Could not parse Markdown table from input.']
163
- };
164
- }
165
- return {
166
- ...await applyRedlineToOxml(sourceOoxml, originalText || '', tableText, options),
167
- isMarkdownTable: true,
168
- tableData
169
- };
170
- }
171
-
96
+
97
+ function getCommentIdsInElement(element) {
98
+ const ids = new Set();
99
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
100
+ for (const node of Array.from(element?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
101
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
102
+ if (id !== '') ids.add(id);
103
+ }
104
+ }
105
+ return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
106
+ }
107
+
108
+ function commentDetailsForIds(ids, detailsById) {
109
+ if (!detailsById || typeof detailsById !== 'object') return [];
110
+ return ids.map(id => detailsById[id]).filter(Boolean);
111
+ }
112
+
113
+ function resolveMutationDocument(documentXml, options = {}) {
114
+ const operationSession = options?._documentOperationSession || null;
115
+ if (operationSession?.valid && operationSession.document) {
116
+ return {
117
+ xmlDoc: operationSession.document,
118
+ serializer: operationSession.serializer,
119
+ operationSession
120
+ };
121
+ }
122
+ return {
123
+ xmlDoc: parseOoxmlSafe(documentXml, 'application/xml').doc,
124
+ serializer: createSerializer(),
125
+ operationSession: null
126
+ };
127
+ }
128
+
129
+ function completedDocumentXml(xmlDoc, serializer, documentXml, operationSession) {
130
+ return operationSession ? documentXml : serializer.serializeToString(xmlDoc);
131
+ }
132
+
133
+ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
134
+ const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
135
+ if (result?.useNativeApi && typeof result?.oxml !== 'string') {
136
+ return withOoxmlSourceType({
137
+ ...result,
138
+ oxml,
139
+ hasChanges: false,
140
+ warnings: [
141
+ ...(Array.isArray(result?.warnings) ? result.warnings : []),
142
+ 'Standalone mode cannot execute native Word API fallback for this operation.'
143
+ ]
144
+ });
145
+ }
146
+ return result;
147
+ }
148
+
149
+ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, options = {}) {
150
+ const sourceOoxml = typeof oxml === 'string' ? oxml : '';
151
+ const tableText = typeof markdownTable === 'string' ? markdownTable : String(markdownTable || '');
152
+ let tableData;
153
+ try {
154
+ tableData = parseMarkdownTable(tableText);
155
+ } catch {
156
+ tableData = { headers: [], rows: [] };
157
+ }
158
+ if (!((tableData?.headers?.length || 0) > 0 || (tableData?.rows?.length || 0) > 0)) {
159
+ return {
160
+ oxml: sourceOoxml,
161
+ hasChanges: false,
162
+ isMarkdownTable: false,
163
+ warnings: ['Could not parse Markdown table from input.']
164
+ };
165
+ }
166
+ return {
167
+ ...await applyRedlineToOxml(sourceOoxml, originalText || '', tableText, options),
168
+ isMarkdownTable: true,
169
+ tableData
170
+ };
171
+ }
172
+
172
173
  function getParagraphText(paragraph) {
173
174
  return getParagraphTextFromOxml(paragraph);
174
175
  }
@@ -255,9 +256,9 @@ function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, opti
255
256
  }
256
257
 
257
258
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
258
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
259
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
260
- const session = options?._documentOperationSession || null;
259
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
260
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
261
+ const session = options?._documentOperationSession || null;
261
262
  const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
262
263
 
263
264
  if (options?._compiledSourceId && options?._sourceTargetRegistry) {
@@ -274,244 +275,244 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
274
275
  }
275
276
  return resolved;
276
277
  }
277
-
278
- if (options?.targetDescriptor?.captureRef) {
279
- try {
280
- const resolved = resolveTargetFromCapture(xmlDoc, session, options.targetDescriptor, opType, options);
281
- if (options?._resolutionCapture && resolved?.paragraph) {
282
- const paragraph = resolved.paragraph;
283
- const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
278
+
279
+ if (options?.targetDescriptor?.captureRef) {
280
+ try {
281
+ const resolved = resolveTargetFromCapture(xmlDoc, session, options.targetDescriptor, opType, options);
282
+ if (options?._resolutionCapture && resolved?.paragraph) {
283
+ const paragraph = resolved.paragraph;
284
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
284
285
  Object.assign(options._resolutionCapture, {
285
286
  resolvedBy: resolved.resolvedBy,
286
287
  resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
287
288
  });
288
- }
289
- return resolved;
290
- } catch (error) {
291
- return {
292
- error: {
293
- code: typeof error?.code === 'string' && error.code ? error.code : 'TARGET_NOT_FOUND',
294
- message: error?.message || String(error)
295
- }
296
- };
297
- }
298
- }
299
-
300
- const resolved = resolveTargetParagraphWithSnapshotShared(xmlDoc, {
301
- targetText,
302
- targetRef,
303
- opType,
304
- targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
305
- targetDescriptor: options?.targetDescriptor || null,
306
- strictAmbiguity: options?.strictTargets === true,
307
- paragraphMetadataIndex,
308
- onInfo,
309
- onWarn
310
- });
311
- if (options?._resolutionCapture && resolved?.paragraph) {
312
- const paragraph = resolved.paragraph;
313
- const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
289
+ }
290
+ return resolved;
291
+ } catch (error) {
292
+ return {
293
+ error: {
294
+ code: typeof error?.code === 'string' && error.code ? error.code : 'TARGET_NOT_FOUND',
295
+ message: error?.message || String(error)
296
+ }
297
+ };
298
+ }
299
+ }
300
+
301
+ const resolved = resolveTargetParagraphWithSnapshotShared(xmlDoc, {
302
+ targetText,
303
+ targetRef,
304
+ opType,
305
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
306
+ targetDescriptor: options?.targetDescriptor || null,
307
+ strictAmbiguity: options?.strictTargets === true,
308
+ paragraphMetadataIndex,
309
+ onInfo,
310
+ onWarn
311
+ });
312
+ if (options?._resolutionCapture && resolved?.paragraph) {
313
+ const paragraph = resolved.paragraph;
314
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
314
315
  Object.assign(options._resolutionCapture, {
315
316
  resolvedBy: resolved.resolvedBy,
316
317
  resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
317
318
  });
318
- }
319
- return resolved;
320
- }
321
-
322
- function extractReplacementNodes(outputOxml) {
323
- return extractReplacementNodesFromOoxml(outputOxml);
324
- }
325
-
326
- function removeListPackagingSentinel(replacementNodes, warnings) {
327
- if (!Array.isArray(replacementNodes) || replacementNodes.length === 0) return replacementNodes;
328
- if (!Array.isArray(warnings) || !warnings.includes('Paragraph expanded to list fragment')) return replacementNodes;
329
-
330
- const last = replacementNodes[replacementNodes.length - 1];
331
- if (!last || last.localName !== 'p') return replacementNodes;
332
- const meaningfulContent = Array.from(last.childNodes || []).some(child => (
333
- child.nodeType === 1 && child.localName !== 'pPr'
334
- ));
335
- if (meaningfulContent) return replacementNodes;
336
- return replacementNodes.slice(0, -1);
337
- }
338
-
339
- function normalizeBodySectionOrder(xmlDoc) {
340
- normalizeBodySectionOrderStandalone(xmlDoc);
341
- }
342
-
343
- function removeProofErrNodes(paragraph) {
344
- for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
345
- node.parentNode?.removeChild(node);
346
- }
347
- }
348
-
349
- function preprocessRedlineTargetParagraph(targetParagraph) {
350
- if (!targetParagraph) return;
351
- removeProofErrNodes(targetParagraph);
352
- }
353
-
319
+ }
320
+ return resolved;
321
+ }
322
+
323
+ function extractReplacementNodes(outputOxml) {
324
+ return extractReplacementNodesFromOoxml(outputOxml);
325
+ }
326
+
327
+ function removeListPackagingSentinel(replacementNodes, warnings) {
328
+ if (!Array.isArray(replacementNodes) || replacementNodes.length === 0) return replacementNodes;
329
+ if (!Array.isArray(warnings) || !warnings.includes('Paragraph expanded to list fragment')) return replacementNodes;
330
+
331
+ const last = replacementNodes[replacementNodes.length - 1];
332
+ if (!last || last.localName !== 'p') return replacementNodes;
333
+ const meaningfulContent = Array.from(last.childNodes || []).some(child => (
334
+ child.nodeType === 1 && child.localName !== 'pPr'
335
+ ));
336
+ if (meaningfulContent) return replacementNodes;
337
+ return replacementNodes.slice(0, -1);
338
+ }
339
+
340
+ function normalizeBodySectionOrder(xmlDoc) {
341
+ normalizeBodySectionOrderStandalone(xmlDoc);
342
+ }
343
+
344
+ function removeProofErrNodes(paragraph) {
345
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
346
+ node.parentNode?.removeChild(node);
347
+ }
348
+ }
349
+
350
+ function preprocessRedlineTargetParagraph(targetParagraph) {
351
+ if (!targetParagraph) return;
352
+ removeProofErrNodes(targetParagraph);
353
+ }
354
+
354
355
  function getDirectWordChild(element, localName) {
355
- if (!element) return null;
356
- return Array.from(element.childNodes || []).find(
357
- node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
358
- ) || null;
359
- }
360
-
361
- function computeTableIndexInDocument(xmlDoc, tableElement) {
362
- if (!xmlDoc || !tableElement) return null;
363
- const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
364
- const idx = tables.indexOf(tableElement);
365
- return idx >= 0 ? idx + 1 : null;
366
- }
367
-
368
- function normalizeMultilineTableStructuralPayload(text) {
369
- return String(text || '')
370
- .replace(/\r\n/g, '\n')
371
- .split('\n')
372
- .map(line => line.trim())
373
- .filter(Boolean)
374
- .join('\n');
375
- }
376
-
377
- function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
378
- const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
379
- if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
380
- const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
381
- if (!normalizedPayload) return null;
382
- return `table:${tableIndex}|payload:${normalizedPayload}`;
383
- }
384
-
385
- function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
386
- let pPr = getDirectWordChild(paragraph, 'pPr');
387
- if (!pPr) {
388
- pPr = createWordElement(xmlDoc, 'w:pPr');
389
- paragraph.insertBefore(pPr, paragraph.firstChild);
390
- }
391
-
392
- let numPr = getDirectWordChild(pPr, 'numPr');
393
- if (!numPr) {
394
- numPr = createWordElement(xmlDoc, 'w:numPr');
395
- pPr.appendChild(numPr);
396
- }
397
-
398
- let ilvlEl = getDirectWordChild(numPr, 'ilvl');
399
- if (!ilvlEl) {
400
- ilvlEl = createWordElement(xmlDoc, 'w:ilvl');
401
- numPr.appendChild(ilvlEl);
402
- }
403
- ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
404
-
405
- let numIdEl = getDirectWordChild(numPr, 'numId');
406
- if (!numIdEl) {
407
- numIdEl = createWordElement(xmlDoc, 'w:numId');
408
- numPr.appendChild(numIdEl);
409
- }
410
- numIdEl.setAttribute('w:val', String(numId));
411
- }
412
-
413
- function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMetadata, author, options = {}) {
414
- const generateRedlines = options.generateRedlines !== false;
415
- const paragraph = createWordElement(xmlDoc, 'w:p');
416
-
417
- const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
418
- if (anchorPPr) {
356
+ if (!element) return null;
357
+ return Array.from(element.childNodes || []).find(
358
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
359
+ ) || null;
360
+ }
361
+
362
+ function computeTableIndexInDocument(xmlDoc, tableElement) {
363
+ if (!xmlDoc || !tableElement) return null;
364
+ const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
365
+ const idx = tables.indexOf(tableElement);
366
+ return idx >= 0 ? idx + 1 : null;
367
+ }
368
+
369
+ function normalizeMultilineTableStructuralPayload(text) {
370
+ return String(text || '')
371
+ .replace(/\r\n/g, '\n')
372
+ .split('\n')
373
+ .map(line => line.trim())
374
+ .filter(Boolean)
375
+ .join('\n');
376
+ }
377
+
378
+ function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
379
+ const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
380
+ if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
381
+ const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
382
+ if (!normalizedPayload) return null;
383
+ return `table:${tableIndex}|payload:${normalizedPayload}`;
384
+ }
385
+
386
+ function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
387
+ let pPr = getDirectWordChild(paragraph, 'pPr');
388
+ if (!pPr) {
389
+ pPr = createWordElement(xmlDoc, 'w:pPr');
390
+ paragraph.insertBefore(pPr, paragraph.firstChild);
391
+ }
392
+
393
+ let numPr = getDirectWordChild(pPr, 'numPr');
394
+ if (!numPr) {
395
+ numPr = createWordElement(xmlDoc, 'w:numPr');
396
+ pPr.appendChild(numPr);
397
+ }
398
+
399
+ let ilvlEl = getDirectWordChild(numPr, 'ilvl');
400
+ if (!ilvlEl) {
401
+ ilvlEl = createWordElement(xmlDoc, 'w:ilvl');
402
+ numPr.appendChild(ilvlEl);
403
+ }
404
+ ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
405
+
406
+ let numIdEl = getDirectWordChild(numPr, 'numId');
407
+ if (!numIdEl) {
408
+ numIdEl = createWordElement(xmlDoc, 'w:numId');
409
+ numPr.appendChild(numIdEl);
410
+ }
411
+ numIdEl.setAttribute('w:val', String(numId));
412
+ }
413
+
414
+ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMetadata, author, options = {}) {
415
+ const generateRedlines = options.generateRedlines !== false;
416
+ const paragraph = createWordElement(xmlDoc, 'w:p');
417
+
418
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
419
+ if (anchorPPr) {
419
420
  paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
420
- }
421
- ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
422
-
423
- // A new list item is a whole inserted paragraph, not merely inserted text.
424
- // Tracking its paragraph mark lets Word (and our accept/reject helpers)
425
- // remove the list paragraph itself on Reject All instead of leaving an
426
- // empty bullet or number behind.
427
- if (generateRedlines) {
428
- markParagraphMarkInserted(xmlDoc, paragraph, author);
429
- }
430
-
431
- const run = createWordElement(xmlDoc, 'w:r');
432
- const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
433
- const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
434
- if (anchorRunPr) {
421
+ }
422
+ ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
423
+
424
+ // A new list item is a whole inserted paragraph, not merely inserted text.
425
+ // Tracking its paragraph mark lets Word (and our accept/reject helpers)
426
+ // remove the list paragraph itself on Reject All instead of leaving an
427
+ // empty bullet or number behind.
428
+ if (generateRedlines) {
429
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
430
+ }
431
+
432
+ const run = createWordElement(xmlDoc, 'w:r');
433
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
434
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
435
+ if (anchorRunPr) {
435
436
  run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
436
- }
437
-
438
- const textNode = createWordElement(xmlDoc, 'w:t');
439
- const safeText = String(entry.text || '').trim();
440
- if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
441
- textNode.textContent = safeText;
442
- run.appendChild(textNode);
443
- if (generateRedlines) {
444
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
445
- const ins = createWordElement(xmlDoc, 'w:ins');
446
- ins.setAttribute('w:id', String(metadata.id));
447
- ins.setAttribute('w:author', metadata.author);
448
- ins.setAttribute('w:date', metadata.date);
449
- ins.appendChild(run);
450
- paragraph.appendChild(ins);
451
- } else {
452
- paragraph.appendChild(run);
453
- }
454
-
455
- return paragraph;
456
- }
457
-
458
- function serializeParagraphRangeAsDocument(paragraphs, serializer) {
459
- const paragraphXml = (paragraphs || [])
460
- .map(paragraph => serializer.serializeToString(paragraph))
461
- .join('');
462
- return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
463
- }
464
- function applyExplicitRangeListInsertions({
465
- xmlDoc,
466
- explicitRangeParagraphs,
467
- insertionEntries,
468
- generateRedlines,
469
- author
470
- }) {
471
- if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
472
- if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
473
-
474
- const parent = explicitRangeParagraphs[0].parentNode;
475
- if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
476
-
477
- const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
478
- for (const entry of insertionEntries) {
479
- const referenceParagraph = entry.insertBeforeOriginalIndex != null
480
- ? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
481
- : explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
482
- if (!referenceParagraph) return false;
483
-
484
- const listParagraph = buildInsertedListParagraph(
485
- xmlDoc,
486
- referenceParagraph,
487
- {
488
- ilvl: entry.ilvl,
489
- markerType: entry.markerType,
490
- numId: entry.numId,
491
- text: entry.text
492
- },
493
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
494
- author,
495
- { generateRedlines }
496
- );
497
-
498
- if (entry.insertBeforeOriginalIndex != null) {
499
- parent.insertBefore(listParagraph, referenceParagraph);
500
- } else {
501
- parent.insertBefore(listParagraph, tailInsertionPoint);
502
- }
503
- }
504
-
505
- normalizeBodySectionOrder(xmlDoc);
506
- return true;
507
- }
437
+ }
438
+
439
+ const textNode = createWordElement(xmlDoc, 'w:t');
440
+ const safeText = String(entry.text || '').trim();
441
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
442
+ textNode.textContent = safeText;
443
+ run.appendChild(textNode);
444
+ if (generateRedlines) {
445
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
446
+ const ins = createWordElement(xmlDoc, 'w:ins');
447
+ ins.setAttribute('w:id', String(metadata.id));
448
+ ins.setAttribute('w:author', metadata.author);
449
+ ins.setAttribute('w:date', metadata.date);
450
+ ins.appendChild(run);
451
+ paragraph.appendChild(ins);
452
+ } else {
453
+ paragraph.appendChild(run);
454
+ }
455
+
456
+ return paragraph;
457
+ }
458
+
459
+ function serializeParagraphRangeAsDocument(paragraphs, serializer) {
460
+ const paragraphXml = (paragraphs || [])
461
+ .map(paragraph => serializer.serializeToString(paragraph))
462
+ .join('');
463
+ return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
464
+ }
465
+ function applyExplicitRangeListInsertions({
466
+ xmlDoc,
467
+ explicitRangeParagraphs,
468
+ insertionEntries,
469
+ generateRedlines,
470
+ author
471
+ }) {
472
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
473
+ if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
474
+
475
+ const parent = explicitRangeParagraphs[0].parentNode;
476
+ if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
477
+
478
+ const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
479
+ for (const entry of insertionEntries) {
480
+ const referenceParagraph = entry.insertBeforeOriginalIndex != null
481
+ ? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
482
+ : explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
483
+ if (!referenceParagraph) return false;
484
+
485
+ const listParagraph = buildInsertedListParagraph(
486
+ xmlDoc,
487
+ referenceParagraph,
488
+ {
489
+ ilvl: entry.ilvl,
490
+ markerType: entry.markerType,
491
+ numId: entry.numId,
492
+ text: entry.text
493
+ },
494
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
495
+ author,
496
+ { generateRedlines }
497
+ );
498
+
499
+ if (entry.insertBeforeOriginalIndex != null) {
500
+ parent.insertBefore(listParagraph, referenceParagraph);
501
+ } else {
502
+ parent.insertBefore(listParagraph, tailInsertionPoint);
503
+ }
504
+ }
505
+
506
+ normalizeBodySectionOrder(xmlDoc);
507
+ return true;
508
+ }
508
509
  function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
509
- const generateRedlines = options.generateRedlines !== false;
510
- const paragraph = createWordElement(xmlDoc, 'w:p');
511
- const run = createWordElement(xmlDoc, 'w:r');
512
- const textNode = createWordElement(xmlDoc, 'w:t');
513
- const safeText = String(text || '');
514
- if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
510
+ const generateRedlines = options.generateRedlines !== false;
511
+ const paragraph = createWordElement(xmlDoc, 'w:p');
512
+ const run = createWordElement(xmlDoc, 'w:r');
513
+ const textNode = createWordElement(xmlDoc, 'w:t');
514
+ const safeText = String(text || '');
515
+ if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
515
516
  textNode.textContent = safeText;
516
517
  run.appendChild(textNode);
517
518
 
@@ -520,36 +521,36 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
520
521
  markParagraphMarkInserted(xmlDoc, paragraph, author);
521
522
  }
522
523
  const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
523
- const ins = createWordElement(xmlDoc, 'w:ins');
524
- ins.setAttribute('w:id', String(metadata.id));
525
- ins.setAttribute('w:author', metadata.author);
526
- ins.setAttribute('w:date', metadata.date);
527
- ins.appendChild(run);
528
- paragraph.appendChild(ins);
529
- } else {
530
- paragraph.appendChild(run);
531
- }
532
-
533
- return paragraph;
534
- }
535
-
536
- function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
537
- const paragraph = createWordElement(xmlDoc, 'w:p');
538
- const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
524
+ const ins = createWordElement(xmlDoc, 'w:ins');
525
+ ins.setAttribute('w:id', String(metadata.id));
526
+ ins.setAttribute('w:author', metadata.author);
527
+ ins.setAttribute('w:date', metadata.date);
528
+ ins.appendChild(run);
529
+ paragraph.appendChild(ins);
530
+ } else {
531
+ paragraph.appendChild(run);
532
+ }
533
+
534
+ return paragraph;
535
+ }
536
+
537
+ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
538
+ const paragraph = createWordElement(xmlDoc, 'w:p');
539
+ const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
539
540
  if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
540
-
541
- const run = createWordElement(xmlDoc, 'w:r');
542
- const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
543
- const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
541
+
542
+ const run = createWordElement(xmlDoc, 'w:r');
543
+ const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
544
+ const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
544
545
  if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
545
-
546
- const textNode = createWordElement(xmlDoc, 'w:t');
547
- textNode.textContent = '';
548
- run.appendChild(textNode);
549
- paragraph.appendChild(run);
550
- return paragraph;
551
- }
552
-
546
+
547
+ const textNode = createWordElement(xmlDoc, 'w:t');
548
+ textNode.textContent = '';
549
+ run.appendChild(textNode);
550
+ paragraph.appendChild(run);
551
+ return paragraph;
552
+ }
553
+
553
554
  function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
554
555
  const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
555
556
  const pPr = options.sanitizeParagraphProperties === true
@@ -563,71 +564,71 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
563
564
 
564
565
  const ins = createWordElement(xmlDoc, 'w:ins');
565
566
  const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
566
- ins.setAttribute('w:id', String(metadata.id));
567
- ins.setAttribute('w:author', metadata.author);
568
- ins.setAttribute('w:date', metadata.date);
569
-
570
- for (const child of Array.from(paragraph.childNodes || [])) {
571
- if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
572
- ins.appendChild(child.cloneNode(true));
573
- }
574
-
575
- wrappedParagraph.appendChild(ins);
576
- return wrappedParagraph;
577
- }
578
-
579
- async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionMetadata, author, options = {}) {
580
- const generateRedlines = options.generateRedlines !== false;
581
- const serializer = createSerializer();
582
- const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
583
- const templateXml = serializer.serializeToString(templateParagraph);
584
- const markdownResult = await applyRedlineToOxml(
585
- templateXml,
586
- '',
587
- String(text || ''),
588
- {
589
- author,
590
- generateRedlines: false
591
- }
592
- );
593
-
594
- let sourceParagraph = null;
595
- if (typeof markdownResult?.oxml === 'string') {
596
- const extracted = extractReplacementNodes(markdownResult.oxml);
597
- sourceParagraph = (extracted.replacementNodes || []).find(
598
- node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
599
- ) || null;
600
- }
601
-
602
- if (!sourceParagraph) {
603
- return buildFallbackInsertedPlainParagraph(
604
- xmlDoc,
605
- text,
567
+ ins.setAttribute('w:id', String(metadata.id));
568
+ ins.setAttribute('w:author', metadata.author);
569
+ ins.setAttribute('w:date', metadata.date);
570
+
571
+ for (const child of Array.from(paragraph.childNodes || [])) {
572
+ if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
573
+ ins.appendChild(child.cloneNode(true));
574
+ }
575
+
576
+ wrappedParagraph.appendChild(ins);
577
+ return wrappedParagraph;
578
+ }
579
+
580
+ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionMetadata, author, options = {}) {
581
+ const generateRedlines = options.generateRedlines !== false;
582
+ const serializer = createSerializer();
583
+ const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
584
+ const templateXml = serializer.serializeToString(templateParagraph);
585
+ const markdownResult = await applyRedlineToOxml(
586
+ templateXml,
587
+ '',
588
+ String(text || ''),
589
+ {
590
+ author,
591
+ generateRedlines: false
592
+ }
593
+ );
594
+
595
+ let sourceParagraph = null;
596
+ if (typeof markdownResult?.oxml === 'string') {
597
+ const extracted = extractReplacementNodes(markdownResult.oxml);
598
+ sourceParagraph = (extracted.replacementNodes || []).find(
599
+ node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
600
+ ) || null;
601
+ }
602
+
603
+ if (!sourceParagraph) {
604
+ return buildFallbackInsertedPlainParagraph(
605
+ xmlDoc,
606
+ text,
606
607
  revisionMetadata,
607
608
  author,
608
609
  { ...options, generateRedlines }
609
- );
610
- }
611
-
612
- if (!generateRedlines) {
613
- return sourceParagraph;
614
- }
615
-
610
+ );
611
+ }
612
+
613
+ if (!generateRedlines) {
614
+ return sourceParagraph;
615
+ }
616
+
616
617
  return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
617
- }
618
-
618
+ }
619
+
619
620
  function collectNumberingIdsFromNodes(nodes) {
620
- const ids = new Set();
621
- for (const node of nodes || []) {
622
- const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
623
- for (const numIdNode of numIdNodes) {
624
- const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
625
- if (val != null && val !== '') {
626
- ids.add(String(val));
627
- }
628
- }
629
- }
630
- return Array.from(ids);
621
+ const ids = new Set();
622
+ for (const node of nodes || []) {
623
+ const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
624
+ for (const numIdNode of numIdNodes) {
625
+ const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
626
+ if (val != null && val !== '') {
627
+ ids.add(String(val));
628
+ }
629
+ }
630
+ }
631
+ return Array.from(ids);
631
632
  }
632
633
 
633
634
  function directDeletionCarrierText(carrier) {
@@ -1341,308 +1342,308 @@ export async function restoreDeletedParagraphByExactText(
1341
1342
  ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
1342
1343
  };
1343
1344
  }
1344
-
1345
- async function tryExplicitDecimalHeaderListConversion({
1346
- xmlDoc,
1347
- serializer,
1348
- documentXml,
1349
- operationSession,
1350
- targetParagraph,
1351
- currentParagraphText,
1352
- modifiedText,
1353
- author,
1354
- runtimeContext,
1355
- revisionIdAllocator,
1356
- generateRedlines = true,
1357
- onInfo = () => { },
1358
- options = {}
1359
- }) {
1360
- if (!targetParagraph) return null;
1361
- const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
1362
- const explicitPlan = buildSingleLineListStructuralFallbackPlan({
1363
- oxml: scopedParagraphOxml,
1364
- originalText: currentParagraphText,
1365
- modifiedText,
1366
- allowExistingList: false
1367
- });
1368
- if (
1369
- !explicitPlan ||
1370
- explicitPlan.numberingKey !== 'numbered:decimal:single' ||
1371
- !Number.isInteger(explicitPlan.startAt) ||
1372
- explicitPlan.startAt < 1
1373
- ) {
1374
- return null;
1375
- }
1376
-
1377
- const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
1378
- if (!strippedContent) return null;
1379
-
1380
- onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
1381
- const redlineResult = await applyRedlineToOxml(
1382
- serializer.serializeToString(targetParagraph),
1383
- currentParagraphText,
1384
- strippedContent,
1385
- {
1386
- author,
1387
- generateRedlines,
1388
- _revisionIdAllocator: revisionIdAllocator
1389
- }
1390
- );
1391
- if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
1392
-
1393
- const extracted = extractReplacementNodes(redlineResult.oxml);
1394
- const replacementNodes = extracted.replacementNodes;
1395
- const numberingAction = resolveSingleLineListFallbackNumberingAction(
1396
- explicitPlan,
1397
- runtimeContext?.listFallbackSequenceState || null
1398
- );
1399
-
1400
- const explicitStart = explicitPlan.startAt;
1401
- const numberingState = runtimeContext?.numberingIdState || null;
1402
- let appliedNumId = null;
1403
- let numberingXml = null;
1404
-
1405
- if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
1406
- appliedNumId = String(numberingAction.numId);
1407
- onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
1408
- } else {
1409
- const reservedPair = reserveNextNumberingIdPair(numberingState);
1410
- if (!reservedPair) return null;
1411
-
1412
- appliedNumId = String(reservedPair.numId);
1413
- numberingXml = buildExplicitDecimalMultilevelNumberingXml(
1414
- reservedPair.numId,
1415
- reservedPair.abstractNumId,
1416
- explicitStart
1417
- );
1418
-
1419
- if (numberingAction.type === 'explicitStartNew') {
1420
- onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
1421
- }
1422
- onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
1423
- }
1424
-
1425
- if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1426
- runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
1427
- }
1428
-
1429
- if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
1430
- recordSingleLineListFallbackExplicitSequence(
1431
- runtimeContext?.listFallbackSequenceState || null,
1432
- numberingAction.numberingKey || explicitPlan.numberingKey,
1433
- appliedNumId,
1434
- explicitStart
1435
- );
1436
- } else {
1437
- clearSingleLineListFallbackExplicitSequence(
1438
- runtimeContext?.listFallbackSequenceState || null,
1439
- numberingAction.numberingKey || explicitPlan.numberingKey
1440
- );
1441
- }
1442
-
1443
- enforceListBindingOnParagraphNodes(replacementNodes, {
1444
- numId: appliedNumId,
1445
- ilvl: 0,
1446
- clearParagraphPropertyChanges: true,
1447
- removeListPropertyNode: true
1448
- });
1449
-
1450
- const parent = targetParagraph.parentNode;
1451
- if (!parent) return null;
1452
- for (const node of replacementNodes) {
1453
- const imported = xmlDoc.importNode(node, true);
1454
- ensureParagraphIdsOnImportedNode(imported, operationSession);
1455
- const inserted = parent.insertBefore(imported, targetParagraph);
1456
- options?._mutationLiveNodes?.push(inserted);
1457
- }
1458
- options?._mutationRemovedNodes?.push(targetParagraph);
1459
- parent.removeChild(targetParagraph);
1460
- normalizeBodySectionOrder(xmlDoc);
1461
- if (operationSession?.receiptCollector && numberingXml) {
1462
- const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
1463
- for (const id of numIds) {
1464
- operationSession.receiptCollector.recordNumbering(id);
1465
- }
1466
- }
1467
- return {
1468
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1469
- hasChanges: true,
1470
- numberingXml
1471
- };
1472
- }
1473
-
1474
- async function trySingleParagraphListStructuralFallback({
1475
- xmlDoc,
1476
- serializer,
1477
- documentXml,
1478
- operationSession,
1479
- targetParagraph,
1480
- currentParagraphText,
1481
- modifiedText,
1482
- author,
1483
- runtimeContext,
1484
- revisionIdAllocator,
1485
- generateRedlines = true,
1486
- onInfo = () => { },
1487
- options = {}
1488
- }) {
1489
- if (!targetParagraph) return null;
1490
-
1491
- const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
1492
- const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
1493
- oxml: scopedParagraphOxml,
1494
- originalText: currentParagraphText,
1495
- modifiedText,
1496
- allowExistingList: false
1497
- });
1498
- if (!fallbackPlan) return null;
1499
-
1500
- onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
1501
- const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
1502
- author,
1503
- generateRedlines,
1504
- revisionIdAllocator,
1505
- setAbstractStartOverride: false
1506
- });
1507
- if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
1508
- onInfo('[List] Structural list fallback produced no valid OOXML payload.');
1509
- return null;
1510
- }
1511
-
1512
- const extracted = extractReplacementNodes(fallbackResult.oxml);
1513
- let replacementNodes = extracted.replacementNodes;
1514
- let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
1515
- const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
1516
- const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
1517
- const numberingAction = resolveSingleLineListFallbackNumberingAction(
1518
- fallbackPlan,
1519
- runtimeContext?.listFallbackSequenceState || null
1520
- );
1521
- if (hasExplicitStartAt) {
1522
- const explicitStart = fallbackPlan.startAt;
1523
- let explicitNumIdForBinding = null;
1524
- const numberingState = runtimeContext?.numberingIdState || null;
1525
- if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
1526
- explicitNumIdForBinding = String(numberingAction.numId);
1527
- numberingXml = null;
1528
- onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
1529
- } else if (numberingState) {
1530
- const reservedPair = reserveNextNumberingIdPair(numberingState);
1531
- if (!reservedPair) return null;
1532
- overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
1533
- explicitNumIdForBinding = String(reservedPair.numId);
1534
- numberingXml = buildExplicitDecimalMultilevelNumberingXml(
1535
- reservedPair.numId,
1536
- reservedPair.abstractNumId,
1537
- explicitStart
1538
- );
1539
- if (numberingAction.type === 'explicitStartNew') {
1540
- onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
1541
- }
1542
- onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
1543
- } else {
1544
- const generatedNumId = extractFirstParagraphNumId(replacementNodes);
1545
- explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
1546
- onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
1547
- }
1548
-
1549
- if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1550
- runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
1551
- }
1552
- if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
1553
- recordSingleLineListFallbackExplicitSequence(
1554
- runtimeContext?.listFallbackSequenceState || null,
1555
- numberingAction.numberingKey || numberingKey,
1556
- explicitNumIdForBinding,
1557
- explicitStart
1558
- );
1559
- } else {
1560
- clearSingleLineListFallbackExplicitSequence(
1561
- runtimeContext?.listFallbackSequenceState || null,
1562
- numberingAction.numberingKey || numberingKey
1563
- );
1564
- }
1565
-
1566
- if (explicitNumIdForBinding) {
1567
- enforceListBindingOnParagraphNodes(replacementNodes, {
1568
- numId: explicitNumIdForBinding,
1569
- ilvl: 0,
1570
- clearParagraphPropertyChanges: true,
1571
- removeListPropertyNode: true
1572
- });
1573
- }
1574
- } else {
1575
- if (numberingXml && runtimeContext?.numberingIdState) {
1576
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
1577
- replacementNodes = normalizedNumbering.replacementNodes;
1578
- numberingXml = normalizedNumbering.numberingXml;
1579
- }
1580
- clearSingleLineListFallbackExplicitSequence(
1581
- runtimeContext?.listFallbackSequenceState || null,
1582
- numberingAction.numberingKey || numberingKey
1583
- );
1584
- }
1585
-
1586
- if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1587
- const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
1588
- if (sharedNumId) {
1589
- overwriteParagraphNumIds(replacementNodes, sharedNumId);
1590
- numberingXml = null;
1591
- onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
1592
- } else if (numberingKey) {
1593
- const generatedNumId = extractFirstParagraphNumId(replacementNodes);
1594
- if (generatedNumId) {
1595
- runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
1596
- onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
1597
- }
1598
- }
1599
- }
1600
-
1601
- const parent = targetParagraph.parentNode;
1602
- if (!parent) return null;
1603
- for (const node of replacementNodes) {
1604
- const imported = xmlDoc.importNode(node, true);
1605
- ensureParagraphIdsOnImportedNode(imported, operationSession);
1606
- const inserted = parent.insertBefore(imported, targetParagraph);
1607
- options?._mutationLiveNodes?.push(inserted);
1608
- }
1609
- options?._mutationRemovedNodes?.push(targetParagraph);
1610
- parent.removeChild(targetParagraph);
1611
- normalizeBodySectionOrder(xmlDoc);
1612
- if (operationSession?.receiptCollector && numberingXml) {
1613
- const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
1614
- for (const id of numIds) {
1615
- operationSession.receiptCollector.recordNumbering(id);
1616
- }
1617
- }
1618
- return {
1619
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1620
- hasChanges: true,
1621
- numberingXml
1622
- };
1623
- }
1624
-
1625
- export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
1626
- const generateRedlines = options.generateRedlines !== false;
1627
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1628
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1629
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1630
- if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1631
- const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
1632
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, {
1633
- ...options,
1634
- onInfo,
1635
- onWarn
1636
- });
1637
- if (resolved?.error || !resolved?.paragraph) {
1638
- return {
1639
- documentXml,
1640
- hasChanges: false,
1641
- numberingXml: null,
1642
- status: 'error',
1643
- error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
1644
- };
1645
- }
1345
+
1346
+ async function tryExplicitDecimalHeaderListConversion({
1347
+ xmlDoc,
1348
+ serializer,
1349
+ documentXml,
1350
+ operationSession,
1351
+ targetParagraph,
1352
+ currentParagraphText,
1353
+ modifiedText,
1354
+ author,
1355
+ runtimeContext,
1356
+ revisionIdAllocator,
1357
+ generateRedlines = true,
1358
+ onInfo = () => { },
1359
+ options = {}
1360
+ }) {
1361
+ if (!targetParagraph) return null;
1362
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
1363
+ const explicitPlan = buildSingleLineListStructuralFallbackPlan({
1364
+ oxml: scopedParagraphOxml,
1365
+ originalText: currentParagraphText,
1366
+ modifiedText,
1367
+ allowExistingList: false
1368
+ });
1369
+ if (
1370
+ !explicitPlan ||
1371
+ explicitPlan.numberingKey !== 'numbered:decimal:single' ||
1372
+ !Number.isInteger(explicitPlan.startAt) ||
1373
+ explicitPlan.startAt < 1
1374
+ ) {
1375
+ return null;
1376
+ }
1377
+
1378
+ const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
1379
+ if (!strippedContent) return null;
1380
+
1381
+ onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
1382
+ const redlineResult = await applyRedlineToOxml(
1383
+ serializer.serializeToString(targetParagraph),
1384
+ currentParagraphText,
1385
+ strippedContent,
1386
+ {
1387
+ author,
1388
+ generateRedlines,
1389
+ _revisionIdAllocator: revisionIdAllocator
1390
+ }
1391
+ );
1392
+ if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
1393
+
1394
+ const extracted = extractReplacementNodes(redlineResult.oxml);
1395
+ const replacementNodes = extracted.replacementNodes;
1396
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
1397
+ explicitPlan,
1398
+ runtimeContext?.listFallbackSequenceState || null
1399
+ );
1400
+
1401
+ const explicitStart = explicitPlan.startAt;
1402
+ const numberingState = runtimeContext?.numberingIdState || null;
1403
+ let appliedNumId = null;
1404
+ let numberingXml = null;
1405
+
1406
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
1407
+ appliedNumId = String(numberingAction.numId);
1408
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
1409
+ } else {
1410
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
1411
+ if (!reservedPair) return null;
1412
+
1413
+ appliedNumId = String(reservedPair.numId);
1414
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
1415
+ reservedPair.numId,
1416
+ reservedPair.abstractNumId,
1417
+ explicitStart
1418
+ );
1419
+
1420
+ if (numberingAction.type === 'explicitStartNew') {
1421
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
1422
+ }
1423
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
1424
+ }
1425
+
1426
+ if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1427
+ runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
1428
+ }
1429
+
1430
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
1431
+ recordSingleLineListFallbackExplicitSequence(
1432
+ runtimeContext?.listFallbackSequenceState || null,
1433
+ numberingAction.numberingKey || explicitPlan.numberingKey,
1434
+ appliedNumId,
1435
+ explicitStart
1436
+ );
1437
+ } else {
1438
+ clearSingleLineListFallbackExplicitSequence(
1439
+ runtimeContext?.listFallbackSequenceState || null,
1440
+ numberingAction.numberingKey || explicitPlan.numberingKey
1441
+ );
1442
+ }
1443
+
1444
+ enforceListBindingOnParagraphNodes(replacementNodes, {
1445
+ numId: appliedNumId,
1446
+ ilvl: 0,
1447
+ clearParagraphPropertyChanges: true,
1448
+ removeListPropertyNode: true
1449
+ });
1450
+
1451
+ const parent = targetParagraph.parentNode;
1452
+ if (!parent) return null;
1453
+ for (const node of replacementNodes) {
1454
+ const imported = xmlDoc.importNode(node, true);
1455
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1456
+ const inserted = parent.insertBefore(imported, targetParagraph);
1457
+ options?._mutationLiveNodes?.push(inserted);
1458
+ }
1459
+ options?._mutationRemovedNodes?.push(targetParagraph);
1460
+ parent.removeChild(targetParagraph);
1461
+ normalizeBodySectionOrder(xmlDoc);
1462
+ if (operationSession?.receiptCollector && numberingXml) {
1463
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
1464
+ for (const id of numIds) {
1465
+ operationSession.receiptCollector.recordNumbering(id);
1466
+ }
1467
+ }
1468
+ return {
1469
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1470
+ hasChanges: true,
1471
+ numberingXml
1472
+ };
1473
+ }
1474
+
1475
+ async function trySingleParagraphListStructuralFallback({
1476
+ xmlDoc,
1477
+ serializer,
1478
+ documentXml,
1479
+ operationSession,
1480
+ targetParagraph,
1481
+ currentParagraphText,
1482
+ modifiedText,
1483
+ author,
1484
+ runtimeContext,
1485
+ revisionIdAllocator,
1486
+ generateRedlines = true,
1487
+ onInfo = () => { },
1488
+ options = {}
1489
+ }) {
1490
+ if (!targetParagraph) return null;
1491
+
1492
+ const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
1493
+ const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
1494
+ oxml: scopedParagraphOxml,
1495
+ originalText: currentParagraphText,
1496
+ modifiedText,
1497
+ allowExistingList: false
1498
+ });
1499
+ if (!fallbackPlan) return null;
1500
+
1501
+ onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
1502
+ const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
1503
+ author,
1504
+ generateRedlines,
1505
+ revisionIdAllocator,
1506
+ setAbstractStartOverride: false
1507
+ });
1508
+ if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
1509
+ onInfo('[List] Structural list fallback produced no valid OOXML payload.');
1510
+ return null;
1511
+ }
1512
+
1513
+ const extracted = extractReplacementNodes(fallbackResult.oxml);
1514
+ let replacementNodes = extracted.replacementNodes;
1515
+ let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
1516
+ const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
1517
+ const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
1518
+ const numberingAction = resolveSingleLineListFallbackNumberingAction(
1519
+ fallbackPlan,
1520
+ runtimeContext?.listFallbackSequenceState || null
1521
+ );
1522
+ if (hasExplicitStartAt) {
1523
+ const explicitStart = fallbackPlan.startAt;
1524
+ let explicitNumIdForBinding = null;
1525
+ const numberingState = runtimeContext?.numberingIdState || null;
1526
+ if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
1527
+ explicitNumIdForBinding = String(numberingAction.numId);
1528
+ numberingXml = null;
1529
+ onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
1530
+ } else if (numberingState) {
1531
+ const reservedPair = reserveNextNumberingIdPair(numberingState);
1532
+ if (!reservedPair) return null;
1533
+ overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
1534
+ explicitNumIdForBinding = String(reservedPair.numId);
1535
+ numberingXml = buildExplicitDecimalMultilevelNumberingXml(
1536
+ reservedPair.numId,
1537
+ reservedPair.abstractNumId,
1538
+ explicitStart
1539
+ );
1540
+ if (numberingAction.type === 'explicitStartNew') {
1541
+ onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
1542
+ }
1543
+ onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
1544
+ } else {
1545
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
1546
+ explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
1547
+ onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
1548
+ }
1549
+
1550
+ if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1551
+ runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
1552
+ }
1553
+ if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
1554
+ recordSingleLineListFallbackExplicitSequence(
1555
+ runtimeContext?.listFallbackSequenceState || null,
1556
+ numberingAction.numberingKey || numberingKey,
1557
+ explicitNumIdForBinding,
1558
+ explicitStart
1559
+ );
1560
+ } else {
1561
+ clearSingleLineListFallbackExplicitSequence(
1562
+ runtimeContext?.listFallbackSequenceState || null,
1563
+ numberingAction.numberingKey || numberingKey
1564
+ );
1565
+ }
1566
+
1567
+ if (explicitNumIdForBinding) {
1568
+ enforceListBindingOnParagraphNodes(replacementNodes, {
1569
+ numId: explicitNumIdForBinding,
1570
+ ilvl: 0,
1571
+ clearParagraphPropertyChanges: true,
1572
+ removeListPropertyNode: true
1573
+ });
1574
+ }
1575
+ } else {
1576
+ if (numberingXml && runtimeContext?.numberingIdState) {
1577
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
1578
+ replacementNodes = normalizedNumbering.replacementNodes;
1579
+ numberingXml = normalizedNumbering.numberingXml;
1580
+ }
1581
+ clearSingleLineListFallbackExplicitSequence(
1582
+ runtimeContext?.listFallbackSequenceState || null,
1583
+ numberingAction.numberingKey || numberingKey
1584
+ );
1585
+ }
1586
+
1587
+ if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
1588
+ const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
1589
+ if (sharedNumId) {
1590
+ overwriteParagraphNumIds(replacementNodes, sharedNumId);
1591
+ numberingXml = null;
1592
+ onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
1593
+ } else if (numberingKey) {
1594
+ const generatedNumId = extractFirstParagraphNumId(replacementNodes);
1595
+ if (generatedNumId) {
1596
+ runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
1597
+ onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ const parent = targetParagraph.parentNode;
1603
+ if (!parent) return null;
1604
+ for (const node of replacementNodes) {
1605
+ const imported = xmlDoc.importNode(node, true);
1606
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1607
+ const inserted = parent.insertBefore(imported, targetParagraph);
1608
+ options?._mutationLiveNodes?.push(inserted);
1609
+ }
1610
+ options?._mutationRemovedNodes?.push(targetParagraph);
1611
+ parent.removeChild(targetParagraph);
1612
+ normalizeBodySectionOrder(xmlDoc);
1613
+ if (operationSession?.receiptCollector && numberingXml) {
1614
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
1615
+ for (const id of numIds) {
1616
+ operationSession.receiptCollector.recordNumbering(id);
1617
+ }
1618
+ }
1619
+ return {
1620
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1621
+ hasChanges: true,
1622
+ numberingXml
1623
+ };
1624
+ }
1625
+
1626
+ export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
1627
+ const generateRedlines = options.generateRedlines !== false;
1628
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1629
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1630
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1631
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1632
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
1633
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, {
1634
+ ...options,
1635
+ onInfo,
1636
+ onWarn
1637
+ });
1638
+ if (resolved?.error || !resolved?.paragraph) {
1639
+ return {
1640
+ documentXml,
1641
+ hasChanges: false,
1642
+ numberingXml: null,
1643
+ status: 'error',
1644
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
1645
+ };
1646
+ }
1646
1647
  const targetParagraph = resolved.paragraph;
1647
1648
  if (typeof modifiedText === 'string' && modifiedText.length > 0) {
1648
1649
  const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
@@ -1662,37 +1663,37 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1662
1663
  }
1663
1664
  }
1664
1665
  preprocessRedlineTargetParagraph(targetParagraph);
1665
- const currentParagraphText = getParagraphText(targetParagraph);
1666
- if (modifiedText === '') {
1667
- const commentIds = getCommentIdsInElement(targetParagraph);
1668
- if (commentIds.length > 0) {
1669
- const comments = commentDetailsForIds(commentIds, options._existingCommentDetails);
1670
- return {
1671
- documentXml,
1672
- hasChanges: false,
1673
- numberingXml: null,
1674
- status: 'error',
1675
- error: {
1676
- code: 'COMMENTED_CONTENT_DELETE',
1677
- message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
1678
- commentIds,
1679
- ...(comments.length > 0 ? { comments } : {})
1680
- }
1681
- };
1682
- }
1683
- }
1684
- const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
1685
- if (!boundaryCheck.valid) {
1686
- return {
1687
- documentXml,
1688
- hasChanges: false,
1689
- numberingXml: null,
1690
- status: 'error',
1691
- error: {
1692
- code: boundaryCheck.code,
1693
- message: boundaryCheck.message
1694
- }
1695
- };
1666
+ const currentParagraphText = getParagraphText(targetParagraph);
1667
+ if (modifiedText === '') {
1668
+ const commentIds = getCommentIdsInElement(targetParagraph);
1669
+ if (commentIds.length > 0) {
1670
+ const comments = commentDetailsForIds(commentIds, options._existingCommentDetails);
1671
+ return {
1672
+ documentXml,
1673
+ hasChanges: false,
1674
+ numberingXml: null,
1675
+ status: 'error',
1676
+ error: {
1677
+ code: 'COMMENTED_CONTENT_DELETE',
1678
+ message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
1679
+ commentIds,
1680
+ ...(comments.length > 0 ? { comments } : {})
1681
+ }
1682
+ };
1683
+ }
1684
+ }
1685
+ const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
1686
+ if (!boundaryCheck.valid) {
1687
+ return {
1688
+ documentXml,
1689
+ hasChanges: false,
1690
+ numberingXml: null,
1691
+ status: 'error',
1692
+ error: {
1693
+ code: boundaryCheck.code,
1694
+ message: boundaryCheck.message
1695
+ }
1696
+ };
1696
1697
  }
1697
1698
  const hasRevisions = containsTrackedChanges(targetParagraph);
1698
1699
  const existingPolicy = options.existingRevisions || 'merge-same-author';
@@ -1796,797 +1797,807 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1796
1797
  && currentParagraphText === ''
1797
1798
  && !(hasRevisions && existingPolicy === 'merge-same-author')
1798
1799
  ) {
1799
- if (generateRedlines) {
1800
- markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
1801
- options?._mutationLiveNodes?.push(targetParagraph);
1802
- } else {
1803
- options?._mutationRemovedNodes?.push(targetParagraph);
1804
- targetParagraph.parentNode?.removeChild(targetParagraph);
1805
- }
1806
- normalizeBodySectionOrder(xmlDoc);
1807
- return {
1808
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1809
- hasChanges: true,
1810
- numberingXml: null
1811
- };
1812
- }
1813
- const containingTable = findContainingWordElement(targetParagraph, 'tbl');
1814
- const rawTableStructuralCandidate = !!containingTable
1815
- && !targetEndRef
1816
- && typeof modifiedText === 'string'
1817
- && modifiedText.includes('\n')
1818
- && !isMarkdownTableText(modifiedText);
1819
- const rawTableStructuralDedupeKey = rawTableStructuralCandidate
1820
- ? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
1821
- : null;
1822
- const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
1823
- ? runtimeContext.tableStructuralRedlineKeys
1824
- : null;
1825
- if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
1826
- onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
1827
- return {
1828
- documentXml,
1829
- hasChanges: false,
1830
- numberingXml: null,
1831
- warnings: ['Skipped duplicate table-structural redline in the same turn.']
1832
- };
1833
- }
1834
- const synthesizedTableMarkdown = containingTable
1835
- ? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
1836
- tableElement: containingTable,
1837
- currentParagraphText,
1838
- onInfo,
1839
- onWarn
1840
- })
1841
- : null;
1842
- let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
1843
- const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
1844
- const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
1845
- const explicitRangeParagraphs = targetEndRef
1846
- ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1847
- opType: 'redline',
1848
- targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
1849
- onInfo,
1850
- onWarn
1851
- })
1852
- : (typeof targetText === 'string' && /\r?\n/.test(targetText)
1853
- ? findReconstructionParagraphRange(xmlDoc, targetText)
1854
- : null);
1855
- const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
1856
- if (!useTableScope && hasExplicitRangeScope) {
1857
- const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
1858
- if (insertionEntries && insertionEntries.length > 0) {
1859
- onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
1860
- for (const entry of insertionEntries) {
1861
- onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1862
- }
1863
- const applied = applyExplicitRangeListInsertions({
1864
- xmlDoc,
1865
- explicitRangeParagraphs,
1866
- insertionEntries,
1867
- generateRedlines,
1868
- author
1869
- });
1870
- if (applied) {
1871
- return {
1872
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1873
- hasChanges: true,
1874
- numberingXml: null
1875
- };
1876
- }
1877
- }
1878
- }
1879
- let inferredTableRangeParagraphs = null;
1880
- if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
1881
- inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
1882
- getParagraphText
1883
- });
1884
- if (inferredTableRangeParagraphs?.length > 1) {
1885
- onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
1886
- }
1887
- }
1888
- const targetListInfo = getParagraphListInfo(targetParagraph);
1889
- if (
1890
- targetListInfo &&
1891
- typeof effectiveModifiedText === 'string' &&
1892
- !effectiveModifiedText.includes('\n')
1893
- ) {
1894
- const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
1895
- if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
1896
- onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
1897
- effectiveModifiedText = strippedListPrefix;
1898
- }
1899
- }
1900
- if (useTableScope) {
1901
- onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
1902
- }
1903
-
1904
- const bypassSingleParagraphHeuristics = options.explicitStructuredContent === true;
1905
-
1906
- const adjacencyInsertionCandidate = (
1907
- !bypassSingleParagraphHeuristics
1908
- && !useTableScope
1909
- && !hasExplicitRangeScope
1910
- && targetListInfo
1911
- )
1912
- ? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1913
- : null;
1914
- if (adjacencyInsertionCandidate) {
1915
- onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
1916
- const parent = targetParagraph.parentNode;
1917
- if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
1918
-
1919
- const listParagraph = buildInsertedListParagraph(
1920
- xmlDoc,
1921
- targetParagraph,
1922
- {
1923
- ilvl: targetListInfo.ilvl,
1924
- numId: targetListInfo.numId,
1925
- markerType: 'numbered',
1926
- text: adjacencyInsertionCandidate.text
1927
- },
1928
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1929
- author,
1930
- { generateRedlines }
1931
- );
1932
-
1933
- const insertionPoint = adjacencyInsertionCandidate.position === 'before'
1934
- ? targetParagraph
1935
- : targetParagraph.nextSibling;
1936
- ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
1937
- parent.insertBefore(listParagraph, insertionPoint);
1938
- options?._mutationLiveNodes?.push(listParagraph);
1939
- normalizeBodySectionOrder(xmlDoc);
1940
- return {
1941
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1942
- hasChanges: true,
1943
- numberingXml: null
1944
- };
1945
- }
1946
-
1947
- const plainAdjacencyInsertionCandidate = (
1948
- !bypassSingleParagraphHeuristics
1949
- && !useTableScope
1950
- && !hasExplicitRangeScope
1951
- && !targetListInfo
1952
- )
1953
- ? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1954
- : null;
1955
- if (plainAdjacencyInsertionCandidate) {
1956
- onInfo(
1957
- `[Text] Applying single-paragraph plain adjacency insertion heuristic `
1958
- + `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
1959
- );
1960
- const parent = targetParagraph.parentNode;
1961
- if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
1962
-
1963
- const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
1964
- ? targetParagraph
1965
- : targetParagraph.nextSibling;
1966
-
1967
- for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
1968
- const plainParagraph = await buildInsertedPlainParagraph(
1969
- xmlDoc,
1970
- targetParagraph,
1971
- paragraphText,
1972
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1973
- author,
1974
- { generateRedlines }
1975
- );
1976
- const imported = xmlDoc.importNode(plainParagraph, true);
1977
- ensureParagraphIdsOnImportedNode(imported, operationSession);
1978
- const inserted = parent.insertBefore(imported, insertionPoint);
1979
- options?._mutationLiveNodes?.push(inserted);
1980
- }
1981
-
1982
- normalizeBodySectionOrder(xmlDoc);
1983
- return {
1984
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1985
- hasChanges: true,
1986
- numberingXml: null
1987
- };
1988
- }
1989
-
1990
- const insertionOnlyPlan = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
1991
- ? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
1992
- currentParagraphText,
1993
- onInfo,
1994
- onWarn
1995
- })
1996
- : null;
1997
- if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
1998
- onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
1999
- for (const entry of insertionOnlyPlan.entries) {
2000
- onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
2001
- }
2002
- const parent = targetParagraph.parentNode;
2003
- if (!parent) throw new Error('Target paragraph has no parent for list insertion');
2004
- const insertionPoint = targetParagraph.nextSibling;
2005
- for (const entry of insertionOnlyPlan.entries) {
2006
- const listParagraph = buildInsertedListParagraph(
2007
- xmlDoc,
2008
- targetParagraph,
2009
- { ...entry, numId: insertionOnlyPlan.numId },
2010
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
2011
- author,
2012
- { generateRedlines }
2013
- );
2014
- ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
2015
- parent.insertBefore(listParagraph, insertionPoint);
2016
- options?._mutationLiveNodes?.push(listParagraph);
2017
- }
2018
- normalizeBodySectionOrder(xmlDoc);
2019
- return {
2020
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2021
- hasChanges: true,
2022
- numberingXml: null
2023
- };
2024
- }
2025
-
2026
- const listScopeEdit = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
2027
- ? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
2028
- currentParagraphText,
2029
- onInfo,
2030
- onWarn
2031
- })
2032
- : null;
2033
- const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
2034
- if (useListScope) {
2035
- effectiveModifiedText = listScopeEdit.modifiedText;
2036
- }
2037
-
2038
- if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
2039
- const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
2040
- xmlDoc,
2041
- serializer,
2042
- documentXml,
2043
- operationSession,
2044
- targetParagraph,
2045
- currentParagraphText,
2046
- modifiedText: effectiveModifiedText,
2047
- author,
2048
- runtimeContext,
2049
- revisionIdAllocator,
2050
- generateRedlines,
2051
- onInfo,
2052
- options
2053
- });
2054
- if (explicitHeaderListConversion) return explicitHeaderListConversion;
2055
-
2056
- const listFallback = await trySingleParagraphListStructuralFallback({
2057
- xmlDoc,
2058
- serializer,
2059
- documentXml,
2060
- operationSession,
2061
- targetParagraph,
2062
- currentParagraphText,
2063
- modifiedText: effectiveModifiedText,
2064
- author,
2065
- runtimeContext,
2066
- revisionIdAllocator,
2067
- generateRedlines,
2068
- onInfo,
2069
- options
2070
- });
2071
- if (listFallback) return listFallback;
2072
- }
2073
-
2074
- const originalTextForApply = useListScope
2075
- ? listScopeEdit.originalText
2076
- : (
2077
- explicitRangeParagraphs
2078
- ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
2079
- : (inferredTableRangeParagraphs
2080
- ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1800
+ if (generateRedlines) {
1801
+ markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
1802
+ options?._mutationLiveNodes?.push(targetParagraph);
1803
+ } else {
1804
+ options?._mutationRemovedNodes?.push(targetParagraph);
1805
+ targetParagraph.parentNode?.removeChild(targetParagraph);
1806
+ }
1807
+ normalizeBodySectionOrder(xmlDoc);
1808
+ return {
1809
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1810
+ hasChanges: true,
1811
+ numberingXml: null
1812
+ };
1813
+ }
1814
+ const containingTable = findContainingWordElement(targetParagraph, 'tbl');
1815
+ const rawTableStructuralCandidate = !!containingTable
1816
+ && !targetEndRef
1817
+ && typeof modifiedText === 'string'
1818
+ && modifiedText.includes('\n')
1819
+ && !isMarkdownTableText(modifiedText);
1820
+ const rawTableStructuralDedupeKey = rawTableStructuralCandidate
1821
+ ? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
1822
+ : null;
1823
+ const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
1824
+ ? runtimeContext.tableStructuralRedlineKeys
1825
+ : null;
1826
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
1827
+ onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
1828
+ return {
1829
+ documentXml,
1830
+ hasChanges: false,
1831
+ numberingXml: null,
1832
+ warnings: ['Skipped duplicate table-structural redline in the same turn.']
1833
+ };
1834
+ }
1835
+ const synthesizedTableMarkdown = containingTable
1836
+ ? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
1837
+ tableElement: containingTable,
1838
+ currentParagraphText,
1839
+ onInfo,
1840
+ onWarn
1841
+ })
1842
+ : null;
1843
+ let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
1844
+ const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
1845
+ const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
1846
+ const explicitRangeParagraphs = targetEndRef
1847
+ ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1848
+ opType: 'redline',
1849
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
1850
+ onInfo,
1851
+ onWarn
1852
+ })
1853
+ : (typeof targetText === 'string' && /\r?\n/.test(targetText)
1854
+ ? findReconstructionParagraphRange(xmlDoc, targetText)
1855
+ : null);
1856
+ const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
1857
+ if (!useTableScope && hasExplicitRangeScope) {
1858
+ const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
1859
+ if (insertionEntries && insertionEntries.length > 0) {
1860
+ onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
1861
+ for (const entry of insertionEntries) {
1862
+ onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
1863
+ }
1864
+ const applied = applyExplicitRangeListInsertions({
1865
+ xmlDoc,
1866
+ explicitRangeParagraphs,
1867
+ insertionEntries,
1868
+ generateRedlines,
1869
+ author
1870
+ });
1871
+ if (applied) {
1872
+ return {
1873
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1874
+ hasChanges: true,
1875
+ numberingXml: null
1876
+ };
1877
+ }
1878
+ }
1879
+ }
1880
+ let inferredTableRangeParagraphs = null;
1881
+ if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
1882
+ inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
1883
+ getParagraphText
1884
+ });
1885
+ if (inferredTableRangeParagraphs?.length > 1) {
1886
+ onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
1887
+ }
1888
+ }
1889
+ const targetListInfo = getParagraphListInfo(targetParagraph);
1890
+ if (
1891
+ targetListInfo &&
1892
+ typeof effectiveModifiedText === 'string' &&
1893
+ !effectiveModifiedText.includes('\n')
1894
+ ) {
1895
+ const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
1896
+ if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
1897
+ onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
1898
+ effectiveModifiedText = strippedListPrefix;
1899
+ }
1900
+ }
1901
+ if (useTableScope) {
1902
+ onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
1903
+ }
1904
+
1905
+ const bypassSingleParagraphHeuristics = options.explicitStructuredContent === true;
1906
+
1907
+ const adjacencyInsertionCandidate = (
1908
+ !bypassSingleParagraphHeuristics
1909
+ && !useTableScope
1910
+ && !hasExplicitRangeScope
1911
+ && targetListInfo
1912
+ )
1913
+ ? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1914
+ : null;
1915
+ if (adjacencyInsertionCandidate) {
1916
+ onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
1917
+ const parent = targetParagraph.parentNode;
1918
+ if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
1919
+
1920
+ const listParagraph = buildInsertedListParagraph(
1921
+ xmlDoc,
1922
+ targetParagraph,
1923
+ {
1924
+ ilvl: targetListInfo.ilvl,
1925
+ numId: targetListInfo.numId,
1926
+ markerType: 'numbered',
1927
+ text: adjacencyInsertionCandidate.text
1928
+ },
1929
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1930
+ author,
1931
+ { generateRedlines }
1932
+ );
1933
+
1934
+ const insertionPoint = adjacencyInsertionCandidate.position === 'before'
1935
+ ? targetParagraph
1936
+ : targetParagraph.nextSibling;
1937
+ ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
1938
+ parent.insertBefore(listParagraph, insertionPoint);
1939
+ options?._mutationLiveNodes?.push(listParagraph);
1940
+ normalizeBodySectionOrder(xmlDoc);
1941
+ return {
1942
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1943
+ hasChanges: true,
1944
+ numberingXml: null
1945
+ };
1946
+ }
1947
+
1948
+ const plainAdjacencyInsertionCandidate = (
1949
+ !bypassSingleParagraphHeuristics
1950
+ && !useTableScope
1951
+ && !hasExplicitRangeScope
1952
+ && !targetListInfo
1953
+ )
1954
+ ? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
1955
+ : null;
1956
+ if (plainAdjacencyInsertionCandidate) {
1957
+ onInfo(
1958
+ `[Text] Applying single-paragraph plain adjacency insertion heuristic `
1959
+ + `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
1960
+ );
1961
+ const parent = targetParagraph.parentNode;
1962
+ if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
1963
+
1964
+ const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
1965
+ ? targetParagraph
1966
+ : targetParagraph.nextSibling;
1967
+
1968
+ for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
1969
+ const plainParagraph = await buildInsertedPlainParagraph(
1970
+ xmlDoc,
1971
+ targetParagraph,
1972
+ paragraphText,
1973
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1974
+ author,
1975
+ // A wholly inserted paragraph must track its own paragraph mark; otherwise Word's
1976
+ // Reject All removes the text but leaves an empty paragraph behind.
1977
+ { generateRedlines, trackParagraphMark: true }
1978
+ );
1979
+ const imported = xmlDoc.importNode(plainParagraph, true);
1980
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
1981
+ const inserted = parent.insertBefore(imported, insertionPoint);
1982
+ options?._mutationLiveNodes?.push(inserted);
1983
+ }
1984
+
1985
+ normalizeBodySectionOrder(xmlDoc);
1986
+ return {
1987
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1988
+ hasChanges: true,
1989
+ numberingXml: null
1990
+ };
1991
+ }
1992
+
1993
+ const insertionOnlyPlan = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
1994
+ ? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
1995
+ currentParagraphText,
1996
+ onInfo,
1997
+ onWarn
1998
+ })
1999
+ : null;
2000
+ if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
2001
+ onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
2002
+ for (const entry of insertionOnlyPlan.entries) {
2003
+ onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
2004
+ }
2005
+ const parent = targetParagraph.parentNode;
2006
+ if (!parent) throw new Error('Target paragraph has no parent for list insertion');
2007
+ const insertionPoint = targetParagraph.nextSibling;
2008
+ for (const entry of insertionOnlyPlan.entries) {
2009
+ const listParagraph = buildInsertedListParagraph(
2010
+ xmlDoc,
2011
+ targetParagraph,
2012
+ { ...entry, numId: insertionOnlyPlan.numId },
2013
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
2014
+ author,
2015
+ { generateRedlines }
2016
+ );
2017
+ ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
2018
+ parent.insertBefore(listParagraph, insertionPoint);
2019
+ options?._mutationLiveNodes?.push(listParagraph);
2020
+ }
2021
+ normalizeBodySectionOrder(xmlDoc);
2022
+ return {
2023
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2024
+ hasChanges: true,
2025
+ numberingXml: null
2026
+ };
2027
+ }
2028
+
2029
+ const listScopeEdit = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
2030
+ ? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
2031
+ currentParagraphText,
2032
+ onInfo,
2033
+ onWarn
2034
+ })
2035
+ : null;
2036
+ const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
2037
+ if (useListScope) {
2038
+ effectiveModifiedText = listScopeEdit.modifiedText;
2039
+ }
2040
+
2041
+ if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
2042
+ const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
2043
+ xmlDoc,
2044
+ serializer,
2045
+ documentXml,
2046
+ operationSession,
2047
+ targetParagraph,
2048
+ currentParagraphText,
2049
+ modifiedText: effectiveModifiedText,
2050
+ author,
2051
+ runtimeContext,
2052
+ revisionIdAllocator,
2053
+ generateRedlines,
2054
+ onInfo,
2055
+ options
2056
+ });
2057
+ if (explicitHeaderListConversion) return explicitHeaderListConversion;
2058
+
2059
+ const listFallback = await trySingleParagraphListStructuralFallback({
2060
+ xmlDoc,
2061
+ serializer,
2062
+ documentXml,
2063
+ operationSession,
2064
+ targetParagraph,
2065
+ currentParagraphText,
2066
+ modifiedText: effectiveModifiedText,
2067
+ author,
2068
+ runtimeContext,
2069
+ revisionIdAllocator,
2070
+ generateRedlines,
2071
+ onInfo,
2072
+ options
2073
+ });
2074
+ if (listFallback) return listFallback;
2075
+ }
2076
+
2077
+ const originalTextForApply = useListScope
2078
+ ? listScopeEdit.originalText
2079
+ : (
2080
+ explicitRangeParagraphs
2081
+ ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
2082
+ : (inferredTableRangeParagraphs
2083
+ ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
2081
2084
  : (currentParagraphText || targetText))
2082
2085
  );
2083
- const scopedXml = useTableScope
2084
- ? serializer.serializeToString(containingTable)
2085
- : (
2086
- useListScope
2087
- ? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
2088
- : (
2089
- explicitRangeParagraphs
2090
- ? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
2091
- : (inferredTableRangeParagraphs
2092
- ? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
2093
- : serializer.serializeToString(targetParagraph))
2094
- )
2095
- );
2096
-
2097
- const result = isTableMarkdownEdit
2098
- ? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
2099
- author,
2100
- generateRedlines,
2101
- existingRevisions: options.existingRevisions || 'merge-same-author',
2102
- structuredContent: options.structuredContent !== false,
2103
- explicitStructuredContent: options.explicitStructuredContent === true,
2104
- _revisionIdAllocator: revisionIdAllocator,
2105
- _isolatedTableCell: useTableScope
2106
- })
2107
- : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
2108
- author,
2109
- generateRedlines,
2110
- existingRevisions: options.existingRevisions || 'merge-same-author',
2111
- structuredContent: options.structuredContent !== false,
2112
- explicitStructuredContent: options.explicitStructuredContent === true,
2113
- pairReplacements: options.pairReplacements === true,
2114
- insertionAffinity: options.insertionAffinity || null,
2115
- _revisionIdAllocator: revisionIdAllocator,
2116
- _isolatedTableCell: useTableScope
2117
- });
2118
- if (!result?.hasChanges) {
2119
- return {
2120
- documentXml,
2121
- hasChanges: false,
2122
- numberingXml: null,
2123
- status: result?.status || 'no-op',
2124
- error: result?.error
2125
- };
2126
- }
2127
- if (result.useNativeApi && !result.oxml) {
2128
- const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
2129
- onWarn(`[WARN] ${warning}`);
2130
- return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
2131
- }
2132
- if (typeof result.oxml !== 'string') {
2133
- throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
2134
- }
2135
- const extracted = extractReplacementNodes(result.oxml);
2136
- let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
2137
- let numberingXml = extracted.numberingXml;
2138
- if (numberingXml && runtimeContext?.numberingIdState) {
2139
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
2140
- replacementNodes = normalizedNumbering.replacementNodes;
2141
- numberingXml = normalizedNumbering.numberingXml;
2142
- }
2143
- const scopeNodes = useTableScope
2144
- ? [containingTable]
2145
- : (
2146
- useListScope
2147
- ? listScopeEdit.paragraphs
2148
- : (
2149
- explicitRangeParagraphs
2150
- ? explicitRangeParagraphs
2151
- : (inferredTableRangeParagraphs || [targetParagraph])
2152
- )
2153
- );
2154
- const anchorNode = scopeNodes[0];
2155
- const parent = anchorNode.parentNode;
2156
- for (const node of replacementNodes) {
2157
- const imported = xmlDoc.importNode(node, true);
2158
- ensureParagraphIdsOnImportedNode(imported, operationSession);
2159
- const inserted = parent.insertBefore(imported, anchorNode);
2160
- options?._mutationLiveNodes?.push(inserted);
2161
- }
2162
- for (const scopeNode of scopeNodes) {
2163
- if (scopeNode && scopeNode.parentNode === parent) {
2164
- options?._mutationRemovedNodes?.push(scopeNode);
2165
- parent.removeChild(scopeNode);
2166
- }
2167
- }
2168
- normalizeBodySectionOrder(xmlDoc);
2169
- if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
2170
- tableStructuralDedupes.add(rawTableStructuralDedupeKey);
2171
- }
2172
- if (operationSession?.receiptCollector && numberingXml) {
2173
- const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
2174
- for (const id of numIds) {
2175
- operationSession.receiptCollector.recordNumbering(id);
2176
- }
2177
- }
2178
- return {
2179
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2180
- hasChanges: true,
2181
- numberingXml,
2182
- status: 'ok',
2183
- ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
2184
- };
2185
- }
2186
-
2187
- export async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
2188
- const generateRedlines = options.generateRedlines !== false;
2189
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2190
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2191
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2192
- if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
2193
- const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
2194
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, {
2195
- ...options,
2196
- onInfo,
2197
- onWarn
2198
- });
2199
- if (resolved?.error || !resolved?.paragraph) {
2200
- return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2201
- }
2202
- const targetParagraph = resolved.paragraph;
2203
- const paragraphXml = serializer.serializeToString(targetParagraph);
2204
- const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
2205
- generateRedlines,
2206
- author,
2207
- _revisionIdAllocator: revisionIdAllocator
2208
- });
2209
- if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
2210
- const { replacementNodes } = extractReplacementNodes(highlightedXml);
2211
- const parent = targetParagraph.parentNode;
2212
- for (const node of replacementNodes) {
2213
- const imported = xmlDoc.importNode(node, true);
2214
- ensureParagraphIdsOnImportedNode(imported, operationSession);
2215
- const inserted = parent.insertBefore(imported, targetParagraph);
2216
- options?._mutationLiveNodes?.push(inserted);
2217
- }
2218
- options?._mutationRemovedNodes?.push(targetParagraph);
2219
- parent.removeChild(targetParagraph);
2220
- normalizeBodySectionOrder(xmlDoc);
2221
- return {
2222
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2223
- hasChanges: true
2224
- };
2225
- }
2226
-
2227
- export async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
2228
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2229
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2230
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2231
- if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
2232
- if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
2233
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, {
2234
- ...options,
2235
- onInfo,
2236
- onWarn
2237
- });
2238
- if (resolved?.error || !resolved?.paragraph) {
2239
- return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2240
- }
2241
- const targetParagraph = resolved.paragraph;
2242
- const paragraphXml = serializer.serializeToString(targetParagraph);
2243
- const commentAnchor = typeof textToComment === 'string' && textToComment.length > 0
2244
- ? textToComment
2245
- : getParagraphText(targetParagraph);
2246
- const commentResult = injectCommentsIntoOoxml(paragraphXml, [{
2247
- paragraphIndex: 1,
2248
- textToFind: commentAnchor,
2249
- commentContent
2250
- }], { author, commentIdAllocator: options.commentIdAllocator });
2251
- if (commentResult.status === 'error' || commentResult.error || !commentResult.commentsApplied) {
2252
- return {
2253
- documentXml,
2254
- hasChanges: false,
2255
- commentsXml: null,
2256
- status: 'error',
2257
- error: commentResult.error || {
2258
- code: 'ANCHOR_INSERTION_FAILED',
2259
- message: 'The comment anchor was not applied to the resolved paragraph.'
2260
- },
2261
- warnings: commentResult.warnings || []
2262
- };
2263
- }
2264
- const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
2265
- const parent = targetParagraph.parentNode;
2266
- for (const node of replacementNodes) {
2267
- const imported = xmlDoc.importNode(node, true);
2268
- ensureParagraphIdsOnImportedNode(imported, operationSession);
2269
- const inserted = parent.insertBefore(imported, targetParagraph);
2270
- options?._mutationLiveNodes?.push(inserted);
2271
- }
2272
- options?._mutationRemovedNodes?.push(targetParagraph);
2273
- parent.removeChild(targetParagraph);
2274
- normalizeBodySectionOrder(xmlDoc);
2275
- if (operationSession?.receiptCollector && Array.isArray(commentResult.placedComments)) {
2276
- for (const c of commentResult.placedComments) {
2277
- operationSession.receiptCollector.recordComment(c.id);
2278
- }
2279
- }
2280
- return {
2281
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2282
- hasChanges: true,
2283
- commentsXml: commentResult.commentsXml || null,
2284
- warnings: commentResult.warnings || [],
2285
- resolvedAnchor: commentResult.resolvedAnchors?.[0] || null
2286
- };
2287
- }
2288
-
2289
- function splitRunAtTextOffset(xmlDoc, run, localOffset, revisionIdAllocator) {
2290
- const run1 = run.cloneNode(true);
2291
- const run2 = run.cloneNode(true);
2292
- refreshRunPropertyChangeIds(run2, revisionIdAllocator);
2293
-
2294
- const isTextPiece = (node) => {
2295
- if (node.nodeType !== 1) return false;
2296
- const ln = node.localName || node.nodeName.replace(/^w:/, '');
2297
- return ['t', 'tab', 'br', 'noBreakHyphen', 'delText'].includes(ln);
2298
- };
2299
-
2300
- Array.from(run1.childNodes).forEach(c => { if (isTextPiece(c)) run1.removeChild(c); });
2301
- Array.from(run2.childNodes).forEach(c => { if (isTextPiece(c)) run2.removeChild(c); });
2302
-
2303
- let fullText = '';
2304
- for (const child of Array.from(run.childNodes)) {
2305
- if (child.nodeType !== 1) continue;
2306
- const ln = child.localName || child.nodeName.replace(/^w:/, '');
2307
- if (ln === 't') fullText += child.textContent || '';
2308
- else if (ln === 'tab') fullText += '\t';
2309
- else if (ln === 'br') fullText += '\n';
2310
- else if (ln === 'noBreakHyphen') fullText += '\u2011';
2311
- }
2312
-
2313
- const text1 = fullText.slice(0, localOffset);
2314
- const text2 = fullText.slice(localOffset);
2315
-
2316
- const appendPieces = (targetRun, text) => {
2317
- const parts = text.split(/(\t|\n|\u2011)/);
2318
- for (const part of parts) {
2319
- if (!part) continue;
2320
- if (part === '\t') {
2321
- targetRun.appendChild(createWordElement(xmlDoc, 'w:tab'));
2322
- } else if (part === '\n') {
2323
- targetRun.appendChild(createWordElement(xmlDoc, 'w:br'));
2324
- } else if (part === '\u2011') {
2325
- targetRun.appendChild(createWordElement(xmlDoc, 'w:noBreakHyphen'));
2326
- } else {
2327
- const tEl = createWordElement(xmlDoc, 'w:t');
2328
- if (/^\s|\s$/.test(part)) tEl.setAttribute('xml:space', 'preserve');
2329
- tEl.textContent = part;
2330
- targetRun.appendChild(tEl);
2331
- }
2332
- }
2333
- };
2334
-
2335
- appendPieces(run1, text1);
2336
- appendPieces(run2, text2);
2337
-
2338
- const parent = run.parentNode;
2339
- parent.insertBefore(run1, run);
2340
- parent.insertBefore(run2, run);
2341
- parent.removeChild(run);
2342
-
2343
- return [run1, run2];
2344
- }
2345
-
2346
- export async function applyFormattingToParagraphByExactText(
2347
- documentXml,
2348
- targetText,
2349
- textToFormat,
2350
- properties = {},
2351
- author,
2352
- targetRef = null,
2353
- runtimeContext = null,
2354
- options = {}
2355
- ) {
2356
- const generateRedlines = options.generateRedlines !== false;
2357
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2358
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2359
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2360
- if (!xmlDoc) {
2361
- return {
2362
- documentXml,
2363
- hasChanges: false,
2364
- status: 'error',
2365
- error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
2366
- };
2367
- }
2368
- const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
2369
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'format', runtimeContext, {
2370
- ...options,
2371
- onInfo,
2372
- onWarn
2373
- });
2374
- if (resolved?.error || !resolved?.paragraph) {
2375
- return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2376
- }
2377
- const targetParagraph = resolved.paragraph;
2378
-
2379
- const collectVisibleRuns = () => {
2380
- const runEntries = [];
2381
- const allRuns = Array.from(targetParagraph.getElementsByTagNameNS(NS_W, 'r'));
2382
- for (const run of allRuns) {
2383
- let parent = run.parentNode;
2384
- let isDel = false;
2385
- while (parent && parent !== targetParagraph) {
2386
- if (parent.nodeType === 1 && (parent.localName === 'del' || parent.nodeName === 'w:del')) {
2387
- isDel = true;
2388
- break;
2389
- }
2390
- parent = parent.parentNode;
2391
- }
2392
- if (isDel) continue;
2393
-
2394
- let runText = '';
2395
- for (const child of Array.from(run.childNodes)) {
2396
- if (child.nodeType !== 1) continue;
2397
- const ln = child.localName || child.nodeName.replace(/^w:/, '');
2398
- if (ln === 't') runText += child.textContent || '';
2399
- else if (ln === 'tab') runText += '\t';
2400
- else if (ln === 'br') runText += '\n';
2401
- else if (ln === 'noBreakHyphen') runText += '\u2011';
2402
- }
2403
- if (runText.length > 0) {
2404
- runEntries.push({ run, text: runText });
2405
- }
2406
- }
2407
- return runEntries;
2408
- };
2409
-
2410
- let runEntries = collectVisibleRuns();
2411
- let fullParaText = '';
2412
- let spanRanges = [];
2413
- for (const entry of runEntries) {
2414
- const start = fullParaText.length;
2415
- fullParaText += entry.text;
2416
- const end = fullParaText.length;
2417
- spanRanges.push({ ...entry, start, end });
2418
- }
2419
-
2420
- const targetDescriptor = options.targetDescriptor;
2421
- const occurrence = targetDescriptor?.occurrence ?? null;
2422
- let matchStart = -1;
2423
- if (occurrence != null && occurrence > 0) {
2424
- let count = 0;
2425
- let offset = 0;
2426
- while (offset <= fullParaText.length - textToFormat.length) {
2427
- const idx = fullParaText.indexOf(textToFormat, offset);
2428
- if (idx === -1) break;
2429
- count++;
2430
- if (count === occurrence) {
2431
- matchStart = idx;
2432
- break;
2433
- }
2434
- offset = idx + 1;
2435
- }
2436
- } else {
2437
- matchStart = fullParaText.indexOf(textToFormat);
2438
- }
2439
-
2440
- if (matchStart === -1) {
2441
- return {
2442
- documentXml,
2443
- hasChanges: false,
2444
- status: 'error',
2445
- error: {
2446
- code: 'TARGET_NOT_FOUND',
2447
- message: `Text to format was not found in target paragraph: "${textToFormat}".`
2448
- }
2449
- };
2450
- }
2451
- const matchEnd = matchStart + textToFormat.length;
2452
-
2453
- // Split runs at matchStart boundary
2454
- let currentRuns = [];
2455
- for (const span of spanRanges) {
2456
- if (span.start < matchStart && span.end > matchStart) {
2457
- const localOffset = matchStart - span.start;
2458
- const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
2459
- currentRuns.push({ run: r1, start: span.start, end: matchStart, text: span.text.slice(0, localOffset) });
2460
- currentRuns.push({ run: r2, start: matchStart, end: span.end, text: span.text.slice(localOffset) });
2461
- } else {
2462
- currentRuns.push(span);
2463
- }
2464
- }
2465
-
2466
- // Split runs at matchEnd boundary
2467
- const finalRuns = [];
2468
- for (const span of currentRuns) {
2469
- if (span.start < matchEnd && span.end > matchEnd) {
2470
- const localOffset = matchEnd - span.start;
2471
- const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
2472
- finalRuns.push({ run: r1, start: span.start, end: matchEnd, text: span.text.slice(0, localOffset) });
2473
- finalRuns.push({ run: r2, start: matchEnd, end: span.end, text: span.text.slice(localOffset) });
2474
- } else {
2475
- finalRuns.push(span);
2476
- }
2477
- }
2478
-
2479
- const targetRuns = finalRuns.filter(span => span.start >= matchStart && span.end <= matchEnd);
2480
-
2481
- let anyChanged = false;
2482
- for (const entry of targetRuns) {
2483
- const rPr = entry.run.getElementsByTagNameNS(NS_W, 'rPr')[0] || null;
2484
- if (checkRunPropertiesChanged(rPr, properties)) {
2485
- anyChanged = true;
2486
- break;
2487
- }
2488
- }
2489
-
2490
- if (!anyChanged) {
2491
- return { documentXml, hasChanges: false, status: 'no-op' };
2492
- }
2493
-
2494
- for (const entry of targetRuns) {
2495
- const run = entry.run;
2496
- let rPr = run.getElementsByTagNameNS(NS_W, 'rPr')[0];
2497
- if (!rPr) {
2498
- rPr = createWordElement(xmlDoc, 'w:rPr');
2499
- run.insertBefore(rPr, run.firstChild);
2500
- }
2501
-
2502
- let insAncestor = run.parentNode;
2503
- let insideSameAuthorIns = false;
2504
- while (insAncestor && insAncestor !== targetParagraph) {
2505
- if (insAncestor.nodeType === 1 && (insAncestor.localName === 'ins' || insAncestor.nodeName === 'w:ins')) {
2506
- const insAuthor = insAncestor.getAttribute('w:author') || insAncestor.getAttributeNS(NS_W, 'author');
2507
- if (insAuthor === (author || getDefaultAuthor())) {
2508
- insideSameAuthorIns = true;
2509
- }
2510
- break;
2511
- }
2512
- insAncestor = insAncestor.parentNode;
2513
- }
2514
-
2515
- const coalesce = insideSameAuthorIns && options.formattingRevisionPolicy === 'coalesce-own-insertion';
2516
-
2517
- if (generateRedlines && !coalesce) {
2518
- snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
2519
- }
2520
-
2521
- applyCharacterFormatToRPr(xmlDoc, rPr, properties);
2522
- }
2523
-
2524
- normalizeBodySectionOrder(xmlDoc);
2525
- ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
2526
- options?._mutationLiveNodes?.push(targetParagraph);
2527
-
2528
- return {
2529
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2530
- hasChanges: true,
2531
- status: 'ok'
2532
- };
2533
- }
2534
-
2535
- export async function applyParagraphFormatToParagraphByExactText(
2536
- documentXml,
2537
- targetText,
2538
- properties = {},
2539
- author,
2540
- targetRef = null,
2541
- runtimeContext = null,
2542
- options = {}
2543
- ) {
2544
- const generateRedlines = options.generateRedlines !== false;
2545
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2546
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2547
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2548
- if (!xmlDoc) {
2549
- return {
2550
- documentXml,
2551
- hasChanges: false,
2552
- status: 'error',
2553
- error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
2554
- };
2555
- }
2556
- if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
2557
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'paragraph-format', runtimeContext, {
2558
- ...options,
2559
- onInfo,
2560
- onWarn
2561
- });
2562
- if (resolved?.error || !resolved?.paragraph) {
2563
- return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2564
- }
2565
- const targetParagraph = resolved.paragraph;
2566
-
2567
- let pPr = targetParagraph.getElementsByTagNameNS(NS_W, 'pPr')[0];
2568
- if (!pPr) {
2569
- pPr = createWordElement(xmlDoc, 'w:pPr');
2570
- targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
2571
- }
2572
-
2573
- const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
2574
- if (!hasChanges) {
2575
- return { documentXml, hasChanges: false, status: 'no-op' };
2576
- }
2577
-
2578
- if (generateRedlines) {
2579
- snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
2580
- }
2581
-
2582
- applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
2583
- normalizeBodySectionOrder(xmlDoc);
2584
- ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
2585
- options?._mutationLiveNodes?.push(targetParagraph);
2586
-
2587
- return {
2588
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2589
- hasChanges: true,
2590
- status: 'ok'
2591
- };
2592
- }
2086
+ const scopedXml = useTableScope
2087
+ ? serializer.serializeToString(containingTable)
2088
+ : (
2089
+ useListScope
2090
+ ? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
2091
+ : (
2092
+ explicitRangeParagraphs
2093
+ ? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
2094
+ : (inferredTableRangeParagraphs
2095
+ ? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
2096
+ : serializer.serializeToString(targetParagraph))
2097
+ )
2098
+ );
2099
+
2100
+ const result = isTableMarkdownEdit
2101
+ ? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
2102
+ author,
2103
+ generateRedlines,
2104
+ existingRevisions: options.existingRevisions || 'merge-same-author',
2105
+ structuredContent: options.structuredContent !== false,
2106
+ explicitStructuredContent: options.explicitStructuredContent === true,
2107
+ _revisionIdAllocator: revisionIdAllocator,
2108
+ _isolatedTableCell: useTableScope
2109
+ })
2110
+ : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
2111
+ author,
2112
+ generateRedlines,
2113
+ existingRevisions: options.existingRevisions || 'merge-same-author',
2114
+ structuredContent: options.structuredContent !== false,
2115
+ explicitStructuredContent: options.explicitStructuredContent === true,
2116
+ pairReplacements: options.pairReplacements === true,
2117
+ insertionAffinity: options.insertionAffinity || null,
2118
+ _revisionIdAllocator: revisionIdAllocator,
2119
+ _isolatedTableCell: useTableScope
2120
+ });
2121
+ if (!result?.hasChanges) {
2122
+ return {
2123
+ documentXml,
2124
+ hasChanges: false,
2125
+ numberingXml: null,
2126
+ status: result?.status || 'no-op',
2127
+ error: result?.error
2128
+ };
2129
+ }
2130
+ if (result.useNativeApi && !result.oxml) {
2131
+ const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
2132
+ onWarn(`[WARN] ${warning}`);
2133
+ return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
2134
+ }
2135
+ if (typeof result.oxml !== 'string') {
2136
+ throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
2137
+ }
2138
+ const extracted = extractReplacementNodes(result.oxml);
2139
+ let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
2140
+ let numberingXml = extracted.numberingXml;
2141
+ if (numberingXml && runtimeContext?.numberingIdState && !useTableScope) {
2142
+ const sourceParagraphs = useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]);
2143
+ const continued = reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, runtimeContext.numberingIdState);
2144
+ if (continued) {
2145
+ replacementNodes = continued.replacementNodes;
2146
+ numberingXml = null;
2147
+ }
2148
+ }
2149
+ if (numberingXml && runtimeContext?.numberingIdState) {
2150
+ const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
2151
+ replacementNodes = normalizedNumbering.replacementNodes;
2152
+ numberingXml = normalizedNumbering.numberingXml;
2153
+ }
2154
+ const scopeNodes = useTableScope
2155
+ ? [containingTable]
2156
+ : (
2157
+ useListScope
2158
+ ? listScopeEdit.paragraphs
2159
+ : (
2160
+ explicitRangeParagraphs
2161
+ ? explicitRangeParagraphs
2162
+ : (inferredTableRangeParagraphs || [targetParagraph])
2163
+ )
2164
+ );
2165
+ const anchorNode = scopeNodes[0];
2166
+ const parent = anchorNode.parentNode;
2167
+ for (const node of replacementNodes) {
2168
+ const imported = xmlDoc.importNode(node, true);
2169
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
2170
+ const inserted = parent.insertBefore(imported, anchorNode);
2171
+ options?._mutationLiveNodes?.push(inserted);
2172
+ }
2173
+ for (const scopeNode of scopeNodes) {
2174
+ if (scopeNode && scopeNode.parentNode === parent) {
2175
+ options?._mutationRemovedNodes?.push(scopeNode);
2176
+ parent.removeChild(scopeNode);
2177
+ }
2178
+ }
2179
+ normalizeBodySectionOrder(xmlDoc);
2180
+ if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
2181
+ tableStructuralDedupes.add(rawTableStructuralDedupeKey);
2182
+ }
2183
+ if (operationSession?.receiptCollector && numberingXml) {
2184
+ const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
2185
+ for (const id of numIds) {
2186
+ operationSession.receiptCollector.recordNumbering(id);
2187
+ }
2188
+ }
2189
+ return {
2190
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2191
+ hasChanges: true,
2192
+ numberingXml,
2193
+ status: 'ok',
2194
+ ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
2195
+ };
2196
+ }
2197
+
2198
+ export async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
2199
+ const generateRedlines = options.generateRedlines !== false;
2200
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2201
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2202
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2203
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
2204
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
2205
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, {
2206
+ ...options,
2207
+ onInfo,
2208
+ onWarn
2209
+ });
2210
+ if (resolved?.error || !resolved?.paragraph) {
2211
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2212
+ }
2213
+ const targetParagraph = resolved.paragraph;
2214
+ const paragraphXml = serializer.serializeToString(targetParagraph);
2215
+ const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
2216
+ generateRedlines,
2217
+ author,
2218
+ _revisionIdAllocator: revisionIdAllocator
2219
+ });
2220
+ if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
2221
+ const { replacementNodes } = extractReplacementNodes(highlightedXml);
2222
+ const parent = targetParagraph.parentNode;
2223
+ for (const node of replacementNodes) {
2224
+ const imported = xmlDoc.importNode(node, true);
2225
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
2226
+ const inserted = parent.insertBefore(imported, targetParagraph);
2227
+ options?._mutationLiveNodes?.push(inserted);
2228
+ }
2229
+ options?._mutationRemovedNodes?.push(targetParagraph);
2230
+ parent.removeChild(targetParagraph);
2231
+ normalizeBodySectionOrder(xmlDoc);
2232
+ return {
2233
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2234
+ hasChanges: true
2235
+ };
2236
+ }
2237
+
2238
+ export async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
2239
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2240
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2241
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2242
+ if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
2243
+ if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
2244
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, {
2245
+ ...options,
2246
+ onInfo,
2247
+ onWarn
2248
+ });
2249
+ if (resolved?.error || !resolved?.paragraph) {
2250
+ return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2251
+ }
2252
+ const targetParagraph = resolved.paragraph;
2253
+ const paragraphXml = serializer.serializeToString(targetParagraph);
2254
+ const commentAnchor = typeof textToComment === 'string' && textToComment.length > 0
2255
+ ? textToComment
2256
+ : getParagraphText(targetParagraph);
2257
+ const commentResult = injectCommentsIntoOoxml(paragraphXml, [{
2258
+ paragraphIndex: 1,
2259
+ textToFind: commentAnchor,
2260
+ commentContent
2261
+ }], { author, commentIdAllocator: options.commentIdAllocator });
2262
+ if (commentResult.status === 'error' || commentResult.error || !commentResult.commentsApplied) {
2263
+ return {
2264
+ documentXml,
2265
+ hasChanges: false,
2266
+ commentsXml: null,
2267
+ status: 'error',
2268
+ error: commentResult.error || {
2269
+ code: 'ANCHOR_INSERTION_FAILED',
2270
+ message: 'The comment anchor was not applied to the resolved paragraph.'
2271
+ },
2272
+ warnings: commentResult.warnings || []
2273
+ };
2274
+ }
2275
+ const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
2276
+ const parent = targetParagraph.parentNode;
2277
+ for (const node of replacementNodes) {
2278
+ const imported = xmlDoc.importNode(node, true);
2279
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
2280
+ const inserted = parent.insertBefore(imported, targetParagraph);
2281
+ options?._mutationLiveNodes?.push(inserted);
2282
+ }
2283
+ options?._mutationRemovedNodes?.push(targetParagraph);
2284
+ parent.removeChild(targetParagraph);
2285
+ normalizeBodySectionOrder(xmlDoc);
2286
+ if (operationSession?.receiptCollector && Array.isArray(commentResult.placedComments)) {
2287
+ for (const c of commentResult.placedComments) {
2288
+ operationSession.receiptCollector.recordComment(c.id);
2289
+ }
2290
+ }
2291
+ return {
2292
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2293
+ hasChanges: true,
2294
+ commentsXml: commentResult.commentsXml || null,
2295
+ warnings: commentResult.warnings || [],
2296
+ resolvedAnchor: commentResult.resolvedAnchors?.[0] || null
2297
+ };
2298
+ }
2299
+
2300
+ function splitRunAtTextOffset(xmlDoc, run, localOffset, revisionIdAllocator) {
2301
+ const run1 = run.cloneNode(true);
2302
+ const run2 = run.cloneNode(true);
2303
+ refreshRunPropertyChangeIds(run2, revisionIdAllocator);
2304
+
2305
+ const isTextPiece = (node) => {
2306
+ if (node.nodeType !== 1) return false;
2307
+ const ln = node.localName || node.nodeName.replace(/^w:/, '');
2308
+ return ['t', 'tab', 'br', 'noBreakHyphen', 'delText'].includes(ln);
2309
+ };
2310
+
2311
+ Array.from(run1.childNodes).forEach(c => { if (isTextPiece(c)) run1.removeChild(c); });
2312
+ Array.from(run2.childNodes).forEach(c => { if (isTextPiece(c)) run2.removeChild(c); });
2313
+
2314
+ let fullText = '';
2315
+ for (const child of Array.from(run.childNodes)) {
2316
+ if (child.nodeType !== 1) continue;
2317
+ const ln = child.localName || child.nodeName.replace(/^w:/, '');
2318
+ if (ln === 't') fullText += child.textContent || '';
2319
+ else if (ln === 'tab') fullText += '\t';
2320
+ else if (ln === 'br') fullText += '\n';
2321
+ else if (ln === 'noBreakHyphen') fullText += '\u2011';
2322
+ }
2323
+
2324
+ const text1 = fullText.slice(0, localOffset);
2325
+ const text2 = fullText.slice(localOffset);
2326
+
2327
+ const appendPieces = (targetRun, text) => {
2328
+ const parts = text.split(/(\t|\n|\u2011)/);
2329
+ for (const part of parts) {
2330
+ if (!part) continue;
2331
+ if (part === '\t') {
2332
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:tab'));
2333
+ } else if (part === '\n') {
2334
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:br'));
2335
+ } else if (part === '\u2011') {
2336
+ targetRun.appendChild(createWordElement(xmlDoc, 'w:noBreakHyphen'));
2337
+ } else {
2338
+ const tEl = createWordElement(xmlDoc, 'w:t');
2339
+ if (/^\s|\s$/.test(part)) tEl.setAttribute('xml:space', 'preserve');
2340
+ tEl.textContent = part;
2341
+ targetRun.appendChild(tEl);
2342
+ }
2343
+ }
2344
+ };
2345
+
2346
+ appendPieces(run1, text1);
2347
+ appendPieces(run2, text2);
2348
+
2349
+ const parent = run.parentNode;
2350
+ parent.insertBefore(run1, run);
2351
+ parent.insertBefore(run2, run);
2352
+ parent.removeChild(run);
2353
+
2354
+ return [run1, run2];
2355
+ }
2356
+
2357
+ export async function applyFormattingToParagraphByExactText(
2358
+ documentXml,
2359
+ targetText,
2360
+ textToFormat,
2361
+ properties = {},
2362
+ author,
2363
+ targetRef = null,
2364
+ runtimeContext = null,
2365
+ options = {}
2366
+ ) {
2367
+ const generateRedlines = options.generateRedlines !== false;
2368
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2369
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2370
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2371
+ if (!xmlDoc) {
2372
+ return {
2373
+ documentXml,
2374
+ hasChanges: false,
2375
+ status: 'error',
2376
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
2377
+ };
2378
+ }
2379
+ const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
2380
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'format', runtimeContext, {
2381
+ ...options,
2382
+ onInfo,
2383
+ onWarn
2384
+ });
2385
+ if (resolved?.error || !resolved?.paragraph) {
2386
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2387
+ }
2388
+ const targetParagraph = resolved.paragraph;
2389
+
2390
+ const collectVisibleRuns = () => {
2391
+ const runEntries = [];
2392
+ const allRuns = Array.from(targetParagraph.getElementsByTagNameNS(NS_W, 'r'));
2393
+ for (const run of allRuns) {
2394
+ let parent = run.parentNode;
2395
+ let isDel = false;
2396
+ while (parent && parent !== targetParagraph) {
2397
+ if (parent.nodeType === 1 && (parent.localName === 'del' || parent.nodeName === 'w:del')) {
2398
+ isDel = true;
2399
+ break;
2400
+ }
2401
+ parent = parent.parentNode;
2402
+ }
2403
+ if (isDel) continue;
2404
+
2405
+ let runText = '';
2406
+ for (const child of Array.from(run.childNodes)) {
2407
+ if (child.nodeType !== 1) continue;
2408
+ const ln = child.localName || child.nodeName.replace(/^w:/, '');
2409
+ if (ln === 't') runText += child.textContent || '';
2410
+ else if (ln === 'tab') runText += '\t';
2411
+ else if (ln === 'br') runText += '\n';
2412
+ else if (ln === 'noBreakHyphen') runText += '\u2011';
2413
+ }
2414
+ if (runText.length > 0) {
2415
+ runEntries.push({ run, text: runText });
2416
+ }
2417
+ }
2418
+ return runEntries;
2419
+ };
2420
+
2421
+ let runEntries = collectVisibleRuns();
2422
+ let fullParaText = '';
2423
+ let spanRanges = [];
2424
+ for (const entry of runEntries) {
2425
+ const start = fullParaText.length;
2426
+ fullParaText += entry.text;
2427
+ const end = fullParaText.length;
2428
+ spanRanges.push({ ...entry, start, end });
2429
+ }
2430
+
2431
+ const targetDescriptor = options.targetDescriptor;
2432
+ const occurrence = targetDescriptor?.occurrence ?? null;
2433
+ let matchStart = -1;
2434
+ if (occurrence != null && occurrence > 0) {
2435
+ let count = 0;
2436
+ let offset = 0;
2437
+ while (offset <= fullParaText.length - textToFormat.length) {
2438
+ const idx = fullParaText.indexOf(textToFormat, offset);
2439
+ if (idx === -1) break;
2440
+ count++;
2441
+ if (count === occurrence) {
2442
+ matchStart = idx;
2443
+ break;
2444
+ }
2445
+ offset = idx + 1;
2446
+ }
2447
+ } else {
2448
+ matchStart = fullParaText.indexOf(textToFormat);
2449
+ }
2450
+
2451
+ if (matchStart === -1) {
2452
+ return {
2453
+ documentXml,
2454
+ hasChanges: false,
2455
+ status: 'error',
2456
+ error: {
2457
+ code: 'TARGET_NOT_FOUND',
2458
+ message: `Text to format was not found in target paragraph: "${textToFormat}".`
2459
+ }
2460
+ };
2461
+ }
2462
+ const matchEnd = matchStart + textToFormat.length;
2463
+
2464
+ // Split runs at matchStart boundary
2465
+ let currentRuns = [];
2466
+ for (const span of spanRanges) {
2467
+ if (span.start < matchStart && span.end > matchStart) {
2468
+ const localOffset = matchStart - span.start;
2469
+ const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
2470
+ currentRuns.push({ run: r1, start: span.start, end: matchStart, text: span.text.slice(0, localOffset) });
2471
+ currentRuns.push({ run: r2, start: matchStart, end: span.end, text: span.text.slice(localOffset) });
2472
+ } else {
2473
+ currentRuns.push(span);
2474
+ }
2475
+ }
2476
+
2477
+ // Split runs at matchEnd boundary
2478
+ const finalRuns = [];
2479
+ for (const span of currentRuns) {
2480
+ if (span.start < matchEnd && span.end > matchEnd) {
2481
+ const localOffset = matchEnd - span.start;
2482
+ const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
2483
+ finalRuns.push({ run: r1, start: span.start, end: matchEnd, text: span.text.slice(0, localOffset) });
2484
+ finalRuns.push({ run: r2, start: matchEnd, end: span.end, text: span.text.slice(localOffset) });
2485
+ } else {
2486
+ finalRuns.push(span);
2487
+ }
2488
+ }
2489
+
2490
+ const targetRuns = finalRuns.filter(span => span.start >= matchStart && span.end <= matchEnd);
2491
+
2492
+ let anyChanged = false;
2493
+ for (const entry of targetRuns) {
2494
+ const rPr = entry.run.getElementsByTagNameNS(NS_W, 'rPr')[0] || null;
2495
+ if (checkRunPropertiesChanged(rPr, properties)) {
2496
+ anyChanged = true;
2497
+ break;
2498
+ }
2499
+ }
2500
+
2501
+ if (!anyChanged) {
2502
+ return { documentXml, hasChanges: false, status: 'no-op' };
2503
+ }
2504
+
2505
+ for (const entry of targetRuns) {
2506
+ const run = entry.run;
2507
+ let rPr = run.getElementsByTagNameNS(NS_W, 'rPr')[0];
2508
+ if (!rPr) {
2509
+ rPr = createWordElement(xmlDoc, 'w:rPr');
2510
+ run.insertBefore(rPr, run.firstChild);
2511
+ }
2512
+
2513
+ let insAncestor = run.parentNode;
2514
+ let insideSameAuthorIns = false;
2515
+ while (insAncestor && insAncestor !== targetParagraph) {
2516
+ if (insAncestor.nodeType === 1 && (insAncestor.localName === 'ins' || insAncestor.nodeName === 'w:ins')) {
2517
+ const insAuthor = insAncestor.getAttribute('w:author') || insAncestor.getAttributeNS(NS_W, 'author');
2518
+ if (insAuthor === (author || getDefaultAuthor())) {
2519
+ insideSameAuthorIns = true;
2520
+ }
2521
+ break;
2522
+ }
2523
+ insAncestor = insAncestor.parentNode;
2524
+ }
2525
+
2526
+ const coalesce = insideSameAuthorIns && options.formattingRevisionPolicy === 'coalesce-own-insertion';
2527
+
2528
+ if (generateRedlines && !coalesce) {
2529
+ snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
2530
+ }
2531
+
2532
+ applyCharacterFormatToRPr(xmlDoc, rPr, properties);
2533
+ }
2534
+
2535
+ normalizeBodySectionOrder(xmlDoc);
2536
+ ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
2537
+ options?._mutationLiveNodes?.push(targetParagraph);
2538
+
2539
+ return {
2540
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2541
+ hasChanges: true,
2542
+ status: 'ok'
2543
+ };
2544
+ }
2545
+
2546
+ export async function applyParagraphFormatToParagraphByExactText(
2547
+ documentXml,
2548
+ targetText,
2549
+ properties = {},
2550
+ author,
2551
+ targetRef = null,
2552
+ runtimeContext = null,
2553
+ options = {}
2554
+ ) {
2555
+ const generateRedlines = options.generateRedlines !== false;
2556
+ const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
2557
+ const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
2558
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
2559
+ if (!xmlDoc) {
2560
+ return {
2561
+ documentXml,
2562
+ hasChanges: false,
2563
+ status: 'error',
2564
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
2565
+ };
2566
+ }
2567
+ if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
2568
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'paragraph-format', runtimeContext, {
2569
+ ...options,
2570
+ onInfo,
2571
+ onWarn
2572
+ });
2573
+ if (resolved?.error || !resolved?.paragraph) {
2574
+ return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
2575
+ }
2576
+ const targetParagraph = resolved.paragraph;
2577
+
2578
+ let pPr = targetParagraph.getElementsByTagNameNS(NS_W, 'pPr')[0];
2579
+ if (!pPr) {
2580
+ pPr = createWordElement(xmlDoc, 'w:pPr');
2581
+ targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
2582
+ }
2583
+
2584
+ const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
2585
+ if (!hasChanges) {
2586
+ return { documentXml, hasChanges: false, status: 'no-op' };
2587
+ }
2588
+
2589
+ if (generateRedlines) {
2590
+ snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
2591
+ }
2592
+
2593
+ applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
2594
+ normalizeBodySectionOrder(xmlDoc);
2595
+ ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
2596
+ options?._mutationLiveNodes?.push(targetParagraph);
2597
+
2598
+ return {
2599
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2600
+ hasChanges: true,
2601
+ status: 'ok'
2602
+ };
2603
+ }