@ansonlai/docx-redline-js 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +28 -13
  3. package/core/list-targeting.js +12 -10
  4. package/dist/docx-redline-js.esm.js +837 -549
  5. package/dist/docx-redline-js.esm.js.map +4 -4
  6. package/dist/docx-redline-js.esm.min.js +93 -90
  7. package/dist/docx-redline-js.esm.min.js.map +4 -4
  8. package/dist/docx-redline.bundle.cjs +837 -549
  9. package/dist/docx-redline.bundle.cjs.map +4 -4
  10. package/dist/docx-redline.bundle.js +837 -549
  11. package/dist/docx-redline.bundle.js.map +4 -4
  12. package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
  13. package/docs/schemas/document-operations.schema.json +168 -168
  14. package/engine/oxml-engine.js +215 -195
  15. package/engine/reconstruction-mode.js +183 -183
  16. package/index.d.ts +4 -0
  17. package/index.js +36 -1
  18. package/node/cli.js +2 -1
  19. package/orchestration/list-structural-fallback.js +530 -518
  20. package/package.json +1 -1
  21. package/pipeline/list-generation.js +203 -168
  22. package/pipeline/serialization.js +111 -94
  23. package/services/batch-operation-orchestrator.js +6 -0
  24. package/services/document-inspection.js +10 -7
  25. package/services/document-operation-applier.js +2 -0
  26. package/services/document-operation-contract.js +553 -537
  27. package/services/document-operation-mutations.js +1140 -1011
  28. package/services/numbering-helpers.js +108 -0
  29. package/services/operation-batch-compiler.js +440 -436
  30. package/services/operation-heuristics.js +197 -173
  31. package/services/revision-comment-management.js +601 -594
  32. package/services/standalone-operation-runner.d.ts +429 -427
  33. package/services/table-reconciliation.js +48 -46
@@ -20,20 +20,20 @@ import {
20
20
  } from '../engine/rpr-helpers.js';
21
21
  import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
22
22
  import { getDefaultAuthor } from '../adapters/config.js';
23
- import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
24
- import {
25
- getParagraphRestorationRefusal,
26
- inspectForeignDeletedParagraphTarget
27
- } from '../core/paragraph-revision-safety.js';
28
- import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
29
- import { validateRedlineOoxml } from '../core/redline-validation.js';
30
- import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
31
- import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
32
- import {
33
- getRunContentPieces,
34
- getRunTextLength,
35
- splitTrackChangeCarrier
36
- } from '../engine/surgical-run-splitting.js';
23
+ import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
24
+ import {
25
+ getParagraphRestorationRefusal,
26
+ inspectForeignDeletedParagraphTarget
27
+ } from '../core/paragraph-revision-safety.js';
28
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
29
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
30
+ import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
31
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
32
+ import {
33
+ getRunContentPieces,
34
+ getRunTextLength,
35
+ splitTrackChangeCarrier
36
+ } from '../engine/surgical-run-splitting.js';
37
37
  import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
38
38
  import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
39
39
  import { injectCommentsIntoOoxml } from './comment-engine.js';
@@ -42,12 +42,13 @@ import {
42
42
  getParagraphId,
43
43
  createParagraphFingerprint,
44
44
  isMarkdownTableText,
45
+ normalizeWhitespaceForTargeting,
45
46
  findContainingWordElement,
46
- resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
47
- resolveParagraphRangeByRefs,
48
- getDocumentParagraphNodes,
49
- validateParagraphBoundaryMutation
50
- } from '../core/paragraph-targeting.js';
47
+ resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
48
+ resolveParagraphRangeByRefs,
49
+ getDocumentParagraphNodes,
50
+ validateParagraphBoundaryMutation
51
+ } from '../core/paragraph-targeting.js';
51
52
  import {
52
53
  synthesizeExpandedListScopeEdit,
53
54
  planListInsertionOnlyEdit,
@@ -70,6 +71,7 @@ import {
70
71
  import {
71
72
  reserveNextNumberingIdPair,
72
73
  remapNumberingPayloadForDocument,
74
+ reuseSourceListNumbering,
73
75
  overwriteParagraphNumIds,
74
76
  extractFirstParagraphNumId,
75
77
  buildExplicitDecimalMultilevelNumberingXml
@@ -81,17 +83,20 @@ import {
81
83
  import { prepareRevisionAllocator } from './document-operation-session.js';
82
84
  import {
83
85
  buildExplicitRangeInsertionEntries,
86
+ deriveParagraphThenTableAppend,
84
87
  deriveSingleParagraphListAdjacencyInsertion,
85
88
  deriveSingleParagraphPlainAdjacencyInsertion
86
89
  } from './operation-heuristics.js';
87
- import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
88
- import {
89
- acceptTrackedChangesInOoxml,
90
- rejectTrackedChangesInOoxml
91
- } from './revision-comment-management.js';
92
-
93
- const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
94
- const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
90
+ import { generateTableOoxml } from './table-reconciliation.js';
91
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
92
+ import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
93
+ import {
94
+ acceptTrackedChangesInOoxml,
95
+ rejectTrackedChangesInOoxml
96
+ } from './revision-comment-management.js';
97
+
98
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
99
+ const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
95
100
 
96
101
  function getCommentIdsInElement(element) {
97
102
  const ids = new Set();
@@ -169,111 +174,111 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
169
174
  };
170
175
  }
171
176
 
172
- function getParagraphText(paragraph) {
173
- return getParagraphTextFromOxml(paragraph);
174
- }
175
-
176
- function escapeInvisibleText(value, maxLength = 96) {
177
- const text = String(value ?? '');
178
- const bounded = text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
179
- return bounded
180
- .replace(/\\/g, '\\\\')
181
- .replace(/\r/g, '\\r')
182
- .replace(/\n/g, '\\n')
183
- .replace(/\t/g, '\\t')
184
- .replace(/\u00a0/g, '\\u00A0')
185
- .replace(/\u202f/g, '\\u202F')
186
- .replace(/\u2060/g, '\\u2060');
187
- }
188
-
189
- function codePointLabel(value, offset) {
190
- if (offset >= value.length) return 'END';
191
- return `U+${value.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
192
- }
193
-
194
- function describeTargetTextMatch(actualText, requestedText) {
195
- const actual = String(actualText ?? '');
196
- const requested = String(requestedText ?? '');
197
- if (actual === requested) return { mode: 'exact' };
198
-
199
- const differences = [];
200
- const limit = Math.min(actual.length, requested.length);
201
- for (let offset = 0; offset < limit && differences.length < 8; offset++) {
202
- if (actual[offset] === requested[offset]) continue;
203
- differences.push({
204
- offset,
205
- sourceCodePoint: codePointLabel(actual, offset),
206
- requestedCodePoint: codePointLabel(requested, offset)
207
- });
208
- }
209
- if (actual.length !== requested.length && differences.length < 8) {
210
- differences.push({
211
- offset: limit,
212
- sourceCodePoint: codePointLabel(actual, limit),
213
- requestedCodePoint: codePointLabel(requested, limit)
214
- });
215
- }
216
-
217
- const spaceEquivalent = actual.length === requested.length
218
- && actual.replace(/\u00a0/g, ' ') === requested.replace(/\u00a0/g, ' ');
219
- return {
220
- mode: spaceEquivalent ? 'space_equivalent' : 'normalized',
221
- differences,
222
- sourceExcerpt: escapeInvisibleText(actual),
223
- requestedExcerpt: escapeInvisibleText(requested)
224
- };
225
- }
226
-
227
- function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
228
- const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
229
- ? 'rejected'
230
- : 'accepted';
231
- const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
232
- const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
233
- const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
234
- const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
235
- const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
236
- const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
237
- text,
238
- index,
239
- paragraphId,
240
- inTable,
241
- revisionView
242
- });
243
- return {
244
- index,
245
- paragraphId,
246
- text,
247
- fingerprint,
248
- revisionView,
249
- inTable,
250
- targetTextMatch: describeTargetTextMatch(
251
- text,
252
- options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
253
- )
254
- };
255
- }
256
-
257
- function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
177
+ function getParagraphText(paragraph) {
178
+ return getParagraphTextFromOxml(paragraph);
179
+ }
180
+
181
+ function escapeInvisibleText(value, maxLength = 96) {
182
+ const text = String(value ?? '');
183
+ const bounded = text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
184
+ return bounded
185
+ .replace(/\\/g, '\\\\')
186
+ .replace(/\r/g, '\\r')
187
+ .replace(/\n/g, '\\n')
188
+ .replace(/\t/g, '\\t')
189
+ .replace(/\u00a0/g, '\\u00A0')
190
+ .replace(/\u202f/g, '\\u202F')
191
+ .replace(/\u2060/g, '\\u2060');
192
+ }
193
+
194
+ function codePointLabel(value, offset) {
195
+ if (offset >= value.length) return 'END';
196
+ return `U+${value.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
197
+ }
198
+
199
+ function describeTargetTextMatch(actualText, requestedText) {
200
+ const actual = String(actualText ?? '');
201
+ const requested = String(requestedText ?? '');
202
+ if (actual === requested) return { mode: 'exact' };
203
+
204
+ const differences = [];
205
+ const limit = Math.min(actual.length, requested.length);
206
+ for (let offset = 0; offset < limit && differences.length < 8; offset++) {
207
+ if (actual[offset] === requested[offset]) continue;
208
+ differences.push({
209
+ offset,
210
+ sourceCodePoint: codePointLabel(actual, offset),
211
+ requestedCodePoint: codePointLabel(requested, offset)
212
+ });
213
+ }
214
+ if (actual.length !== requested.length && differences.length < 8) {
215
+ differences.push({
216
+ offset: limit,
217
+ sourceCodePoint: codePointLabel(actual, limit),
218
+ requestedCodePoint: codePointLabel(requested, limit)
219
+ });
220
+ }
221
+
222
+ const spaceEquivalent = actual.length === requested.length
223
+ && actual.replace(/\u00a0/g, ' ') === requested.replace(/\u00a0/g, ' ');
224
+ return {
225
+ mode: spaceEquivalent ? 'space_equivalent' : 'normalized',
226
+ differences,
227
+ sourceExcerpt: escapeInvisibleText(actual),
228
+ requestedExcerpt: escapeInvisibleText(requested)
229
+ };
230
+ }
231
+
232
+ function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
233
+ const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
234
+ ? 'rejected'
235
+ : 'accepted';
236
+ const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
237
+ const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
238
+ const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
239
+ const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
240
+ const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
241
+ const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
242
+ text,
243
+ index,
244
+ paragraphId,
245
+ inTable,
246
+ revisionView
247
+ });
248
+ return {
249
+ index,
250
+ paragraphId,
251
+ text,
252
+ fingerprint,
253
+ revisionView,
254
+ inTable,
255
+ targetTextMatch: describeTargetTextMatch(
256
+ text,
257
+ options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
258
+ )
259
+ };
260
+ }
261
+
262
+ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
258
263
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
259
264
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
260
265
  const session = options?._documentOperationSession || null;
261
- const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
262
-
263
- if (options?._compiledSourceId && options?._sourceTargetRegistry) {
264
- const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
265
- if (resolved?.error || !resolved?.paragraph) return resolved;
266
- resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
267
- if (options?._resolutionCapture) {
268
- const paragraph = resolved.paragraph;
269
- const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
270
- Object.assign(options._resolutionCapture, {
271
- resolvedBy: resolved.resolvedBy,
272
- resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
273
- });
274
- }
275
- return resolved;
276
- }
266
+ const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
267
+
268
+ if (options?._compiledSourceId && options?._sourceTargetRegistry) {
269
+ const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
270
+ if (resolved?.error || !resolved?.paragraph) return resolved;
271
+ resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
272
+ if (options?._resolutionCapture) {
273
+ const paragraph = resolved.paragraph;
274
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
275
+ Object.assign(options._resolutionCapture, {
276
+ resolvedBy: resolved.resolvedBy,
277
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
278
+ });
279
+ }
280
+ return resolved;
281
+ }
277
282
 
278
283
  if (options?.targetDescriptor?.captureRef) {
279
284
  try {
@@ -281,10 +286,10 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
281
286
  if (options?._resolutionCapture && resolved?.paragraph) {
282
287
  const paragraph = resolved.paragraph;
283
288
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
284
- Object.assign(options._resolutionCapture, {
285
- resolvedBy: resolved.resolvedBy,
286
- resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
287
- });
289
+ Object.assign(options._resolutionCapture, {
290
+ resolvedBy: resolved.resolvedBy,
291
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
292
+ });
288
293
  }
289
294
  return resolved;
290
295
  } catch (error) {
@@ -311,10 +316,10 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
311
316
  if (options?._resolutionCapture && resolved?.paragraph) {
312
317
  const paragraph = resolved.paragraph;
313
318
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
314
- Object.assign(options._resolutionCapture, {
315
- resolvedBy: resolved.resolvedBy,
316
- resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
317
- });
319
+ Object.assign(options._resolutionCapture, {
320
+ resolvedBy: resolved.resolvedBy,
321
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
322
+ });
318
323
  }
319
324
  return resolved;
320
325
  }
@@ -351,7 +356,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
351
356
  removeProofErrNodes(targetParagraph);
352
357
  }
353
358
 
354
- function getDirectWordChild(element, localName) {
359
+ function getDirectWordChild(element, localName) {
355
360
  if (!element) return null;
356
361
  return Array.from(element.childNodes || []).find(
357
362
  node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
@@ -416,7 +421,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
416
421
 
417
422
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
418
423
  if (anchorPPr) {
419
- paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
424
+ paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
420
425
  }
421
426
  ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
422
427
 
@@ -432,7 +437,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
432
437
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
433
438
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
434
439
  if (anchorRunPr) {
435
- run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
440
+ run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
436
441
  }
437
442
 
438
443
  const textNode = createWordElement(xmlDoc, 'w:t');
@@ -505,21 +510,21 @@ function applyExplicitRangeListInsertions({
505
510
  normalizeBodySectionOrder(xmlDoc);
506
511
  return true;
507
512
  }
508
- function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
513
+ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
509
514
  const generateRedlines = options.generateRedlines !== false;
510
515
  const paragraph = createWordElement(xmlDoc, 'w:p');
511
516
  const run = createWordElement(xmlDoc, 'w:r');
512
517
  const textNode = createWordElement(xmlDoc, 'w:t');
513
518
  const safeText = String(text || '');
514
519
  if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
515
- textNode.textContent = safeText;
516
- run.appendChild(textNode);
517
-
518
- if (generateRedlines) {
519
- if (options.trackParagraphMark === true) {
520
- markParagraphMarkInserted(xmlDoc, paragraph, author);
521
- }
522
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
520
+ textNode.textContent = safeText;
521
+ run.appendChild(textNode);
522
+
523
+ if (generateRedlines) {
524
+ if (options.trackParagraphMark === true) {
525
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
526
+ }
527
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
523
528
  const ins = createWordElement(xmlDoc, 'w:ins');
524
529
  ins.setAttribute('w:id', String(metadata.id));
525
530
  ins.setAttribute('w:author', metadata.author);
@@ -536,12 +541,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
536
541
  function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
537
542
  const paragraph = createWordElement(xmlDoc, 'w:p');
538
543
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
539
- if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
544
+ if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
540
545
 
541
546
  const run = createWordElement(xmlDoc, 'w:r');
542
547
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
543
548
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
544
- if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
549
+ if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
545
550
 
546
551
  const textNode = createWordElement(xmlDoc, 'w:t');
547
552
  textNode.textContent = '';
@@ -550,19 +555,19 @@ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
550
555
  return paragraph;
551
556
  }
552
557
 
553
- function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
554
- const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
555
- const pPr = options.sanitizeParagraphProperties === true
556
- ? createSanitizedRestorationPPr(xmlDoc, paragraph)
557
- : clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
558
- if (pPr) wrappedParagraph.appendChild(pPr);
559
- if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
560
- if (options.trackParagraphMark === true) {
561
- markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
562
- }
563
-
564
- const ins = createWordElement(xmlDoc, 'w:ins');
565
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
558
+ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
559
+ const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
560
+ const pPr = options.sanitizeParagraphProperties === true
561
+ ? createSanitizedRestorationPPr(xmlDoc, paragraph)
562
+ : clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
563
+ if (pPr) wrappedParagraph.appendChild(pPr);
564
+ if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
565
+ if (options.trackParagraphMark === true) {
566
+ markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
567
+ }
568
+
569
+ const ins = createWordElement(xmlDoc, 'w:ins');
570
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
566
571
  ins.setAttribute('w:id', String(metadata.id));
567
572
  ins.setAttribute('w:author', metadata.author);
568
573
  ins.setAttribute('w:date', metadata.date);
@@ -603,9 +608,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
603
608
  return buildFallbackInsertedPlainParagraph(
604
609
  xmlDoc,
605
610
  text,
606
- revisionMetadata,
607
- author,
608
- { ...options, generateRedlines }
611
+ revisionMetadata,
612
+ author,
613
+ { ...options, generateRedlines }
609
614
  );
610
615
  }
611
616
 
@@ -613,10 +618,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
613
618
  return sourceParagraph;
614
619
  }
615
620
 
616
- return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
621
+ return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
617
622
  }
618
623
 
619
- function collectNumberingIdsFromNodes(nodes) {
624
+ function collectNumberingIdsFromNodes(nodes) {
620
625
  const ids = new Set();
621
626
  for (const node of nodes || []) {
622
627
  const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
@@ -628,719 +633,719 @@ function collectNumberingIdsFromNodes(nodes) {
628
633
  }
629
634
  }
630
635
  return Array.from(ids);
631
- }
632
-
633
- function directDeletionCarrierText(carrier) {
634
- return Array.from(carrier?.childNodes || []).reduce((text, child) => {
635
- return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
636
- ? getRunContentPieces(child).map(piece => piece.text).join('')
637
- : '');
638
- }, '');
639
- }
640
-
641
- function findOccurrenceOffset(text, needle, occurrence) {
642
- let from = 0;
643
- let found = -1;
644
- for (let index = 0; index < occurrence; index++) {
645
- found = text.indexOf(needle, from);
646
- if (found < 0) return -1;
647
- from = found + Math.max(needle.length, 1);
648
- }
649
- return found;
650
- }
651
-
652
- function countNonOverlappingOccurrences(text, needle) {
653
- if (!needle) return 0;
654
- let count = 0;
655
- let from = 0;
656
- while (from <= text.length) {
657
- const found = text.indexOf(needle, from);
658
- if (found < 0) break;
659
- count += 1;
660
- from = found + needle.length;
661
- }
662
- return count;
663
- }
664
-
665
- function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
666
- const unsafeParagraphNames = new Set([
667
- 'commentRangeStart', 'commentRangeEnd', 'commentReference',
668
- 'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
669
- 'moveToRangeStart', 'moveToRangeEnd'
670
- ]);
671
- if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
672
- return true;
673
- }
674
- for (const child of Array.from(carrier?.childNodes || [])) {
675
- if (child.nodeType !== 1) continue;
676
- if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
677
- for (const runChild of Array.from(child.childNodes || [])) {
678
- if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
679
- if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
680
- }
681
- }
682
- return false;
683
- }
684
-
685
- function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
686
- let offset = 0;
687
- for (const child of Array.from(carrier?.childNodes || [])) {
688
- if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
689
- const length = getRunTextLength(getRunContentPieces(child));
690
- if (localOffset <= offset + length) {
691
- return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
692
- }
693
- offset += length;
694
- }
695
- return null;
696
- }
697
-
698
- const RESTORATION_PPR_ALLOWLIST = new Set([
699
- 'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
700
- 'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
701
- ]);
702
-
703
- function wordAttribute(node, localName) {
704
- return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
705
- }
706
-
707
- function normalizedAuthor(author) {
708
- return String(author || '').trim().toLowerCase();
709
- }
710
-
711
- function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
712
- let cursor = paragraph?.[direction] || null;
713
- while (cursor) {
714
- if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
715
- cursor = cursor[direction] || null;
716
- }
717
- return null;
718
- }
719
-
720
- function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
721
- const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
722
- const pPr = createWordElement(xmlDoc, 'w:pPr');
723
- if (!sourcePPr) return pPr;
724
- for (const child of Array.from(sourcePPr.childNodes || [])) {
725
- if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
726
- if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
727
- pPr.appendChild(xmlDoc.importNode(child, true));
728
- }
729
- return pPr;
730
- }
731
-
732
- function collectRestorationAnchorWarnings(sourceParagraph) {
733
- const warnings = [];
734
- const bookmarkNames = new Set();
735
- for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
736
- const name = wordAttribute(node, 'name') || '(unnamed)';
737
- bookmarkNames.add(name);
738
- }
739
- for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
740
-
741
- const commentIds = new Set();
742
- for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
743
- for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
744
- const id = wordAttribute(node, 'id');
745
- if (id !== '') commentIds.add(id);
746
- }
747
- }
748
- for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
749
- return warnings;
750
- }
751
-
752
- function removeRestorationAnchors(paragraph) {
753
- const anchorNames = [
754
- 'bookmarkStart', 'bookmarkEnd',
755
- 'commentRangeStart', 'commentRangeEnd', 'commentReference'
756
- ];
757
- for (const localName of anchorNames) {
758
- for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
759
- node.parentNode?.removeChild(node);
760
- }
761
- }
762
- for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
763
- const meaningful = Array.from(run.childNodes || []).some(child => (
764
- child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
765
- ));
766
- if (!meaningful) run.parentNode?.removeChild(run);
767
- }
768
- }
769
-
770
- function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
771
- const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
772
- const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
773
- const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
774
- if (!rejectedParagraph) return null;
775
-
776
- const paragraph = createWordElement(xmlDoc, 'w:p');
777
- paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
778
- for (const child of Array.from(rejectedParagraph.childNodes || [])) {
779
- if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
780
- paragraph.appendChild(xmlDoc.importNode(child, true));
781
- }
782
- removeRestorationAnchors(paragraph);
783
- return paragraph;
784
- }
785
-
786
- async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
787
- const originalText = extractCanonicalParagraphText(template);
788
- if (originalText === modifiedText) return template;
789
- const result = await applyRedlineToOxml(
790
- serializer.serializeToString(template),
791
- originalText,
792
- modifiedText,
793
- {
794
- author,
795
- generateRedlines: false,
796
- structuredContent: false
797
- }
798
- );
799
- if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
800
- const extracted = extractReplacementNodes(result.oxml);
801
- const paragraph = (extracted.replacementNodes || []).find(node => (
802
- node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
803
- ));
804
- return paragraph ? xmlDoc.importNode(paragraph, true) : null;
805
- }
806
-
807
- function allocateFreshParagraphId(xmlDoc, operationSession) {
808
- const used = new Set(getDocumentParagraphNodes(xmlDoc)
809
- .map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
810
- .filter(Boolean)
811
- .map(value => value.toUpperCase()));
812
- let candidate = null;
813
- do {
814
- candidate = operationSession?.generateParagraphId?.()
815
- || (0x40000000 + used.size + 1).toString(16).toUpperCase();
816
- } while (used.has(candidate.toUpperCase()));
817
- return candidate;
818
- }
819
-
820
- function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
821
- const root = xmlDoc.documentElement;
822
- if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
823
- return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
824
- paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
825
- sanitizeParagraphProperties: true,
826
- trackParagraphMark: true
827
- });
828
- }
829
-
830
- function isInsertedParagraphByAuthor(paragraph, author) {
831
- const pPr = getDirectWordChild(paragraph, 'pPr');
832
- const rPr = getDirectWordChild(pPr, 'rPr');
833
- const marker = getDirectWordChild(rPr, 'ins');
834
- return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
835
- }
836
-
837
- function followingParagraphBlock(lastSource, count) {
838
- const paragraphs = [];
839
- let cursor = lastSource;
840
- for (let i = 0; i < count; i++) {
841
- cursor = directWordParagraphSibling(cursor, 'nextSibling');
842
- if (!cursor) return [];
843
- paragraphs.push(cursor);
844
- }
845
- return paragraphs;
846
- }
847
-
848
- function paragraphTextVector(oxml) {
849
- const parsed = parseOoxmlSafe(oxml, 'application/xml');
850
- if (parsed.error || !parsed.doc) return null;
851
- return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
852
- }
853
-
854
- function sameTextVector(left, right) {
855
- return Array.isArray(left) && Array.isArray(right)
856
- && left.length === right.length
857
- && left.every((value, index) => value === right[index]);
858
- }
859
-
860
- function lifecycleTextVector(oxml, action) {
861
- const result = action === 'accept'
862
- ? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
863
- : rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
864
- return paragraphTextVector(result.oxml);
865
- }
866
-
867
- function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
868
- const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
869
- if (parsed.error || !parsed.doc) return null;
870
- const expectedDoc = parsed.doc;
871
- const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
872
- const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
873
- if (!lastSource?.parentNode) return null;
874
-
875
- for (const index of [...existingIndexes].sort((a, b) => b - a)) {
876
- const paragraph = originalParagraphs[index];
877
- paragraph?.parentNode?.removeChild(paragraph);
878
- }
879
- const insertionPoint = lastSource.nextSibling;
880
- for (const template of templates) {
881
- lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
882
- }
883
- return createSerializer().serializeToString(expectedDoc);
884
- }
885
-
886
- function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
887
- const baselineValidation = validateRedlineOoxml(beforeXml);
888
- const outputValidation = validateRedlineOoxml(outputXml);
889
- const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
890
- const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
891
- validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
892
- .filter(issue => issue.severity === 'error')
893
- ));
894
- const generatedErrors = validationErrors(generatedIssues);
895
- if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
896
- return {
897
- valid: false,
898
- stage: 'validation',
899
- code: 'GENERATED_OOXML_INVALID',
900
- generatedIssues,
901
- envelopeIssues,
902
- message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
903
- };
904
- }
905
-
906
- const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
907
- if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
908
-
909
- const comparisons = [
910
- ['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
911
- ['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
912
- ['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
913
- ];
914
- for (const [stage, expected, actual] of comparisons) {
915
- if (!sameTextVector(expected, actual)) {
916
- return {
917
- valid: false,
918
- stage,
919
- expected,
920
- actual,
921
- message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
922
- };
923
- }
924
- }
925
- return { valid: true };
926
- }
927
-
928
- /**
929
- * Inserts a new tracked run at an exact offset inside text visible only in the
930
- * rejected view of a wholly deleted paragraph. The foreign deletion is split,
931
- * never rewritten or re-authored.
932
- */
933
- export async function insertIntoRejectedDeletedText(
934
- documentXml,
935
- targetText,
936
- anchor,
937
- modified,
938
- author,
939
- targetRef = null,
940
- runtimeContext = null,
941
- options = {}
942
- ) {
943
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
944
- if (!xmlDoc) {
945
- return {
946
- documentXml,
947
- hasChanges: false,
948
- status: 'error',
949
- error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
950
- };
951
- }
952
-
953
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
954
- if (resolved?.error || !resolved?.paragraph) {
955
- return {
956
- documentXml,
957
- hasChanges: false,
958
- status: 'error',
959
- error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
960
- };
961
- }
962
-
963
- const paragraph = resolved.paragraph;
964
- const state = inspectForeignDeletedParagraphTarget(paragraph, author);
965
- if (!state.matches) {
966
- return {
967
- documentXml,
968
- hasChanges: false,
969
- status: 'error',
970
- error: {
971
- code: 'REJECTED_INSERTION_STATE_REQUIRED',
972
- message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
973
- }
974
- };
975
- }
976
- const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
977
- if (structuralRefusal) {
978
- return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
979
- }
980
- if (/\r|\n/.test(modified)) {
981
- return {
982
- documentXml,
983
- hasChanges: false,
984
- status: 'error',
985
- error: {
986
- code: 'UNSAFE_PARAGRAPH_BOUNDARY',
987
- message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
988
- }
989
- };
990
- }
991
-
992
- const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
993
- if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
994
- return {
995
- documentXml,
996
- hasChanges: false,
997
- status: 'error',
998
- error: {
999
- code: 'AMBIGUOUS_ANCHOR',
1000
- message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
1001
- }
1002
- };
1003
- }
1004
- const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
1005
- if (anchorStart < 0) {
1006
- return {
1007
- documentXml,
1008
- hasChanges: false,
1009
- status: 'error',
1010
- error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
1011
- };
1012
- }
1013
- const insertionOffset = anchorStart + anchor.offset;
1014
- const carriers = Array.from(paragraph.childNodes || []).filter(
1015
- node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
1016
- );
1017
- if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
1018
- return {
1019
- documentXml,
1020
- hasChanges: false,
1021
- status: 'error',
1022
- error: {
1023
- code: 'UNSAFE_REVISION_BOUNDARY',
1024
- message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
1025
- }
1026
- };
1027
- }
1028
- let carrierStart = 0;
1029
- let targetCarrier = null;
1030
- let carrierOffset = 0;
1031
- for (const carrier of carriers) {
1032
- const length = directDeletionCarrierText(carrier).length;
1033
- if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
1034
- targetCarrier = carrier;
1035
- carrierOffset = insertionOffset - carrierStart;
1036
- break;
1037
- }
1038
- carrierStart += length;
1039
- }
1040
- if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
1041
- return {
1042
- documentXml,
1043
- hasChanges: false,
1044
- status: 'error',
1045
- error: {
1046
- code: 'UNSAFE_REVISION_NESTING',
1047
- message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
1048
- }
1049
- };
1050
- }
1051
-
1052
- const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
1053
- const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
1054
- xmlDoc,
1055
- targetCarrier,
1056
- carrierOffset,
1057
- options._revisionIdAllocator || null
1058
- );
1059
- const insertion = createWordElement(xmlDoc, 'w:ins');
1060
- const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
1061
- insertion.setAttribute('w:id', String(metadata.id));
1062
- insertion.setAttribute('w:author', metadata.author);
1063
- insertion.setAttribute('w:date', metadata.date);
1064
- const run = createWordElement(xmlDoc, 'w:r');
1065
- if (runProperties) run.appendChild(runProperties);
1066
- const textNode = createWordElement(xmlDoc, 'w:t');
1067
- if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
1068
- textNode.textContent = modified;
1069
- run.appendChild(textNode);
1070
- insertion.appendChild(run);
1071
-
1072
- const parent = targetCarrier.parentNode;
1073
- if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
1074
- parent.insertBefore(insertion, targetCarrier);
1075
- if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
1076
- options?._mutationRemovedNodes?.push(targetCarrier);
1077
- parent.removeChild(targetCarrier);
1078
- options?._mutationLiveNodes?.push(paragraph, insertion);
1079
- operationSession?.invalidateParagraphMetadata?.();
1080
-
1081
- const outputXml = serializer.serializeToString(xmlDoc);
1082
- const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
1083
- const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
1084
- if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
1085
- return {
1086
- documentXml,
1087
- hasChanges: false,
1088
- status: 'error',
1089
- error: {
1090
- code: 'PATCH_ROUNDTRIP_MISMATCH',
1091
- message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
1092
- }
1093
- };
1094
- }
1095
- return { documentXml: outputXml, hasChanges: true, status: 'ok' };
1096
- }
1097
-
1098
- /**
1099
- * Materializes an explicit counterproposal for one or more paragraphs wholly
1100
- * deleted (content and paragraph mark) by another author.
1101
- */
1102
- export async function restoreDeletedParagraphByExactText(
1103
- documentXml,
1104
- targetText,
1105
- modified,
1106
- author,
1107
- targetRef = null,
1108
- targetEndRef = null,
1109
- runtimeContext = null,
1110
- options = {}
1111
- ) {
1112
- const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1113
- if (!xmlDoc) {
1114
- return {
1115
- documentXml,
1116
- hasChanges: false,
1117
- status: 'error',
1118
- error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
1119
- };
1120
- }
1121
-
1122
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
1123
- if (resolved?.error || !resolved?.paragraph) {
1124
- return {
1125
- documentXml,
1126
- hasChanges: false,
1127
- status: 'error',
1128
- error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
1129
- };
1130
- }
1131
-
1132
- const firstSource = resolved.paragraph;
1133
- let sourceParagraphs = [firstSource];
1134
- if (options._compiledSourceEndId) {
1135
- const endResolved = resolveTargetParagraph(
1136
- xmlDoc,
1137
- options.targetEndDescriptor?.text || '',
1138
- targetEndRef,
1139
- 'restore',
1140
- runtimeContext,
1141
- { ...options, _compiledSourceId: options._compiledSourceEndId }
1142
- );
1143
- const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
1144
- const startIndex = allParagraphs.indexOf(firstSource);
1145
- const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
1146
- sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
1147
- ? allParagraphs.slice(startIndex, endIndex + 1)
1148
- : null;
1149
- } else if (targetEndRef) {
1150
- sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1151
- opType: 'restore',
1152
- targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
1153
- onInfo: options.onInfo,
1154
- onWarn: options.onWarn
1155
- });
1156
- } else if (options.targetEndDescriptor) {
1157
- const endResolved = resolveTargetParagraph(
1158
- xmlDoc,
1159
- options.targetEndDescriptor.text,
1160
- options.targetEndDescriptor.index,
1161
- 'restore',
1162
- runtimeContext,
1163
- {
1164
- ...options,
1165
- targetDescriptor: options.targetEndDescriptor,
1166
- _compiledSourceId: options._compiledSourceEndId || null
1167
- }
1168
- );
1169
- const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
1170
- const startIndex = allParagraphs.indexOf(firstSource);
1171
- const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
1172
- sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
1173
- ? allParagraphs.slice(startIndex, endIndex + 1)
1174
- : null;
1175
- }
1176
- if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
1177
- return {
1178
- documentXml,
1179
- hasChanges: false,
1180
- status: 'error',
1181
- error: {
1182
- code: 'RESTORATION_COUNT_MISMATCH',
1183
- message: 'The restoration range could not be resolved as a contiguous paragraph block.'
1184
- }
1185
- };
1186
- }
1187
-
1188
- const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
1189
- if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
1190
- return {
1191
- documentXml,
1192
- hasChanges: false,
1193
- status: 'error',
1194
- error: {
1195
- code: 'RESTORATION_COUNT_MISMATCH',
1196
- message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
1197
- }
1198
- };
1199
- }
1200
-
1201
- const parent = firstSource.parentNode;
1202
- if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
1203
- return {
1204
- documentXml,
1205
- hasChanges: false,
1206
- status: 'error',
1207
- error: {
1208
- code: 'UNSAFE_PARAGRAPH_PLACEMENT',
1209
- message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
1210
- }
1211
- };
1212
- }
1213
-
1214
- for (const paragraph of sourceParagraphs) {
1215
- const structuralRefusal = getParagraphRestorationRefusal(paragraph);
1216
- if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
1217
- return {
1218
- documentXml,
1219
- hasChanges: false,
1220
- status: 'error',
1221
- error: structuralRefusal
1222
- };
1223
- }
1224
- const state = inspectForeignDeletedParagraphTarget(paragraph, author);
1225
- if (!state.matches) {
1226
- return {
1227
- documentXml,
1228
- hasChanges: false,
1229
- status: 'error',
1230
- error: {
1231
- code: 'RESTORATION_STATE_REQUIRED',
1232
- message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
1233
- ? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
1234
- : 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
1235
- ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
1236
- }
1237
- };
1238
- }
1239
- const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
1240
- if (refusal) {
1241
- return {
1242
- documentXml,
1243
- hasChanges: false,
1244
- status: 'error',
1245
- error: refusal
1246
- };
1247
- }
1248
- }
1249
-
1250
- const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
1251
- const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
1252
- const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
1253
- && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
1254
- if (
1255
- hasExistingRestoration
1256
- && existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
1257
- ) {
1258
- return {
1259
- documentXml,
1260
- hasChanges: false,
1261
- status: 'no-op',
1262
- warnings: ['An identical same-author paragraph restoration already exists.']
1263
- };
1264
- }
1265
-
1266
- const beforeXml = serializer.serializeToString(xmlDoc);
1267
- const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
1268
- const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
1269
- const existingIndexes = hasExistingRestoration
1270
- ? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
1271
- : [];
1272
- const templates = [];
1273
- const warnings = [];
1274
- for (let index = 0; index < sourceParagraphs.length; index++) {
1275
- const source = sourceParagraphs[index];
1276
- warnings.push(...collectRestorationAnchorWarnings(source));
1277
- const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
1278
- const edited = template
1279
- ? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
1280
- : null;
1281
- if (!edited) {
1282
- return {
1283
- documentXml,
1284
- hasChanges: false,
1285
- status: 'error',
1286
- error: {
1287
- code: 'PATCH_ROUNDTRIP_MISMATCH',
1288
- message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
1289
- }
1290
- };
1291
- }
1292
- templates.push(edited);
1293
- }
1294
-
1295
- const insertedParagraphs = templates.map(template => {
1296
- const paragraph = xmlDoc.importNode(template, true);
1297
- return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
1298
- });
1299
- const insertionPoint = lastSource.nextSibling;
1300
- for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
1301
- if (hasExistingRestoration) {
1302
- for (const paragraph of existingBlock) {
1303
- options?._mutationRemovedNodes?.push(paragraph);
1304
- paragraph.parentNode?.removeChild(paragraph);
1305
- }
1306
- }
1307
- options?._mutationLiveNodes?.push(...insertedParagraphs);
1308
- normalizeBodySectionOrder(xmlDoc);
1309
-
1310
- const outputXml = serializer.serializeToString(xmlDoc);
1311
- const oracle = verifyParagraphRestorationLifecycle(
1312
- beforeXml,
1313
- outputXml,
1314
- sourceStartIndex,
1315
- existingIndexes,
1316
- templates,
1317
- insertedParagraphs
1318
- );
1319
- if (!oracle.valid) {
1320
- return {
1321
- documentXml,
1322
- hasChanges: false,
1323
- status: 'error',
1324
- error: {
1325
- code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
1326
- message: oracle.message,
1327
- stage: oracle.stage,
1328
- ...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
1329
- ...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
1330
- ...(oracle.expected ? { expected: oracle.expected } : {}),
1331
- ...(oracle.actual ? { actual: oracle.actual } : {})
1332
- }
1333
- };
1334
- }
1335
-
1336
- return {
1337
- documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1338
- hasChanges: true,
1339
- status: 'ok',
1340
- numberingXml: null,
1341
- ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
1342
- };
1343
- }
636
+ }
637
+
638
+ function directDeletionCarrierText(carrier) {
639
+ return Array.from(carrier?.childNodes || []).reduce((text, child) => {
640
+ return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
641
+ ? getRunContentPieces(child).map(piece => piece.text).join('')
642
+ : '');
643
+ }, '');
644
+ }
645
+
646
+ function findOccurrenceOffset(text, needle, occurrence) {
647
+ let from = 0;
648
+ let found = -1;
649
+ for (let index = 0; index < occurrence; index++) {
650
+ found = text.indexOf(needle, from);
651
+ if (found < 0) return -1;
652
+ from = found + Math.max(needle.length, 1);
653
+ }
654
+ return found;
655
+ }
656
+
657
+ function countNonOverlappingOccurrences(text, needle) {
658
+ if (!needle) return 0;
659
+ let count = 0;
660
+ let from = 0;
661
+ while (from <= text.length) {
662
+ const found = text.indexOf(needle, from);
663
+ if (found < 0) break;
664
+ count += 1;
665
+ from = found + needle.length;
666
+ }
667
+ return count;
668
+ }
669
+
670
+ function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
671
+ const unsafeParagraphNames = new Set([
672
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference',
673
+ 'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
674
+ 'moveToRangeStart', 'moveToRangeEnd'
675
+ ]);
676
+ if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
677
+ return true;
678
+ }
679
+ for (const child of Array.from(carrier?.childNodes || [])) {
680
+ if (child.nodeType !== 1) continue;
681
+ if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
682
+ for (const runChild of Array.from(child.childNodes || [])) {
683
+ if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
684
+ if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
685
+ }
686
+ }
687
+ return false;
688
+ }
689
+
690
+ function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
691
+ let offset = 0;
692
+ for (const child of Array.from(carrier?.childNodes || [])) {
693
+ if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
694
+ const length = getRunTextLength(getRunContentPieces(child));
695
+ if (localOffset <= offset + length) {
696
+ return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
697
+ }
698
+ offset += length;
699
+ }
700
+ return null;
701
+ }
702
+
703
+ const RESTORATION_PPR_ALLOWLIST = new Set([
704
+ 'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
705
+ 'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
706
+ ]);
707
+
708
+ function wordAttribute(node, localName) {
709
+ return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
710
+ }
711
+
712
+ function normalizedAuthor(author) {
713
+ return String(author || '').trim().toLowerCase();
714
+ }
715
+
716
+ function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
717
+ let cursor = paragraph?.[direction] || null;
718
+ while (cursor) {
719
+ if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
720
+ cursor = cursor[direction] || null;
721
+ }
722
+ return null;
723
+ }
724
+
725
+ function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
726
+ const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
727
+ const pPr = createWordElement(xmlDoc, 'w:pPr');
728
+ if (!sourcePPr) return pPr;
729
+ for (const child of Array.from(sourcePPr.childNodes || [])) {
730
+ if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
731
+ if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
732
+ pPr.appendChild(xmlDoc.importNode(child, true));
733
+ }
734
+ return pPr;
735
+ }
736
+
737
+ function collectRestorationAnchorWarnings(sourceParagraph) {
738
+ const warnings = [];
739
+ const bookmarkNames = new Set();
740
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
741
+ const name = wordAttribute(node, 'name') || '(unnamed)';
742
+ bookmarkNames.add(name);
743
+ }
744
+ for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
745
+
746
+ const commentIds = new Set();
747
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
748
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
749
+ const id = wordAttribute(node, 'id');
750
+ if (id !== '') commentIds.add(id);
751
+ }
752
+ }
753
+ for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
754
+ return warnings;
755
+ }
756
+
757
+ function removeRestorationAnchors(paragraph) {
758
+ const anchorNames = [
759
+ 'bookmarkStart', 'bookmarkEnd',
760
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference'
761
+ ];
762
+ for (const localName of anchorNames) {
763
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
764
+ node.parentNode?.removeChild(node);
765
+ }
766
+ }
767
+ for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
768
+ const meaningful = Array.from(run.childNodes || []).some(child => (
769
+ child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
770
+ ));
771
+ if (!meaningful) run.parentNode?.removeChild(run);
772
+ }
773
+ }
774
+
775
+ function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
776
+ const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
777
+ const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
778
+ const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
779
+ if (!rejectedParagraph) return null;
780
+
781
+ const paragraph = createWordElement(xmlDoc, 'w:p');
782
+ paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
783
+ for (const child of Array.from(rejectedParagraph.childNodes || [])) {
784
+ if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
785
+ paragraph.appendChild(xmlDoc.importNode(child, true));
786
+ }
787
+ removeRestorationAnchors(paragraph);
788
+ return paragraph;
789
+ }
790
+
791
+ async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
792
+ const originalText = extractCanonicalParagraphText(template);
793
+ if (originalText === modifiedText) return template;
794
+ const result = await applyRedlineToOxml(
795
+ serializer.serializeToString(template),
796
+ originalText,
797
+ modifiedText,
798
+ {
799
+ author,
800
+ generateRedlines: false,
801
+ structuredContent: false
802
+ }
803
+ );
804
+ if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
805
+ const extracted = extractReplacementNodes(result.oxml);
806
+ const paragraph = (extracted.replacementNodes || []).find(node => (
807
+ node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
808
+ ));
809
+ return paragraph ? xmlDoc.importNode(paragraph, true) : null;
810
+ }
811
+
812
+ function allocateFreshParagraphId(xmlDoc, operationSession) {
813
+ const used = new Set(getDocumentParagraphNodes(xmlDoc)
814
+ .map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
815
+ .filter(Boolean)
816
+ .map(value => value.toUpperCase()));
817
+ let candidate = null;
818
+ do {
819
+ candidate = operationSession?.generateParagraphId?.()
820
+ || (0x40000000 + used.size + 1).toString(16).toUpperCase();
821
+ } while (used.has(candidate.toUpperCase()));
822
+ return candidate;
823
+ }
824
+
825
+ function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
826
+ const root = xmlDoc.documentElement;
827
+ if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
828
+ return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
829
+ paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
830
+ sanitizeParagraphProperties: true,
831
+ trackParagraphMark: true
832
+ });
833
+ }
834
+
835
+ function isInsertedParagraphByAuthor(paragraph, author) {
836
+ const pPr = getDirectWordChild(paragraph, 'pPr');
837
+ const rPr = getDirectWordChild(pPr, 'rPr');
838
+ const marker = getDirectWordChild(rPr, 'ins');
839
+ return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
840
+ }
841
+
842
+ function followingParagraphBlock(lastSource, count) {
843
+ const paragraphs = [];
844
+ let cursor = lastSource;
845
+ for (let i = 0; i < count; i++) {
846
+ cursor = directWordParagraphSibling(cursor, 'nextSibling');
847
+ if (!cursor) return [];
848
+ paragraphs.push(cursor);
849
+ }
850
+ return paragraphs;
851
+ }
852
+
853
+ function paragraphTextVector(oxml) {
854
+ const parsed = parseOoxmlSafe(oxml, 'application/xml');
855
+ if (parsed.error || !parsed.doc) return null;
856
+ return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
857
+ }
858
+
859
+ function sameTextVector(left, right) {
860
+ return Array.isArray(left) && Array.isArray(right)
861
+ && left.length === right.length
862
+ && left.every((value, index) => value === right[index]);
863
+ }
864
+
865
+ function lifecycleTextVector(oxml, action) {
866
+ const result = action === 'accept'
867
+ ? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
868
+ : rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
869
+ return paragraphTextVector(result.oxml);
870
+ }
871
+
872
+ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
873
+ const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
874
+ if (parsed.error || !parsed.doc) return null;
875
+ const expectedDoc = parsed.doc;
876
+ const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
877
+ const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
878
+ if (!lastSource?.parentNode) return null;
879
+
880
+ for (const index of [...existingIndexes].sort((a, b) => b - a)) {
881
+ const paragraph = originalParagraphs[index];
882
+ paragraph?.parentNode?.removeChild(paragraph);
883
+ }
884
+ const insertionPoint = lastSource.nextSibling;
885
+ for (const template of templates) {
886
+ lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
887
+ }
888
+ return createSerializer().serializeToString(expectedDoc);
889
+ }
890
+
891
+ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
892
+ const baselineValidation = validateRedlineOoxml(beforeXml);
893
+ const outputValidation = validateRedlineOoxml(outputXml);
894
+ const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
895
+ const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
896
+ validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
897
+ .filter(issue => issue.severity === 'error')
898
+ ));
899
+ const generatedErrors = validationErrors(generatedIssues);
900
+ if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
901
+ return {
902
+ valid: false,
903
+ stage: 'validation',
904
+ code: 'GENERATED_OOXML_INVALID',
905
+ generatedIssues,
906
+ envelopeIssues,
907
+ message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
908
+ };
909
+ }
910
+
911
+ const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
912
+ if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
913
+
914
+ const comparisons = [
915
+ ['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
916
+ ['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
917
+ ['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
918
+ ];
919
+ for (const [stage, expected, actual] of comparisons) {
920
+ if (!sameTextVector(expected, actual)) {
921
+ return {
922
+ valid: false,
923
+ stage,
924
+ expected,
925
+ actual,
926
+ message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
927
+ };
928
+ }
929
+ }
930
+ return { valid: true };
931
+ }
932
+
933
+ /**
934
+ * Inserts a new tracked run at an exact offset inside text visible only in the
935
+ * rejected view of a wholly deleted paragraph. The foreign deletion is split,
936
+ * never rewritten or re-authored.
937
+ */
938
+ export async function insertIntoRejectedDeletedText(
939
+ documentXml,
940
+ targetText,
941
+ anchor,
942
+ modified,
943
+ author,
944
+ targetRef = null,
945
+ runtimeContext = null,
946
+ options = {}
947
+ ) {
948
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
949
+ if (!xmlDoc) {
950
+ return {
951
+ documentXml,
952
+ hasChanges: false,
953
+ status: 'error',
954
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
955
+ };
956
+ }
957
+
958
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
959
+ if (resolved?.error || !resolved?.paragraph) {
960
+ return {
961
+ documentXml,
962
+ hasChanges: false,
963
+ status: 'error',
964
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
965
+ };
966
+ }
967
+
968
+ const paragraph = resolved.paragraph;
969
+ const state = inspectForeignDeletedParagraphTarget(paragraph, author);
970
+ if (!state.matches) {
971
+ return {
972
+ documentXml,
973
+ hasChanges: false,
974
+ status: 'error',
975
+ error: {
976
+ code: 'REJECTED_INSERTION_STATE_REQUIRED',
977
+ message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
978
+ }
979
+ };
980
+ }
981
+ const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
982
+ if (structuralRefusal) {
983
+ return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
984
+ }
985
+ if (/\r|\n/.test(modified)) {
986
+ return {
987
+ documentXml,
988
+ hasChanges: false,
989
+ status: 'error',
990
+ error: {
991
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
992
+ message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
993
+ }
994
+ };
995
+ }
996
+
997
+ const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
998
+ if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
999
+ return {
1000
+ documentXml,
1001
+ hasChanges: false,
1002
+ status: 'error',
1003
+ error: {
1004
+ code: 'AMBIGUOUS_ANCHOR',
1005
+ message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
1006
+ }
1007
+ };
1008
+ }
1009
+ const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
1010
+ if (anchorStart < 0) {
1011
+ return {
1012
+ documentXml,
1013
+ hasChanges: false,
1014
+ status: 'error',
1015
+ error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
1016
+ };
1017
+ }
1018
+ const insertionOffset = anchorStart + anchor.offset;
1019
+ const carriers = Array.from(paragraph.childNodes || []).filter(
1020
+ node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
1021
+ );
1022
+ if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
1023
+ return {
1024
+ documentXml,
1025
+ hasChanges: false,
1026
+ status: 'error',
1027
+ error: {
1028
+ code: 'UNSAFE_REVISION_BOUNDARY',
1029
+ message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
1030
+ }
1031
+ };
1032
+ }
1033
+ let carrierStart = 0;
1034
+ let targetCarrier = null;
1035
+ let carrierOffset = 0;
1036
+ for (const carrier of carriers) {
1037
+ const length = directDeletionCarrierText(carrier).length;
1038
+ if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
1039
+ targetCarrier = carrier;
1040
+ carrierOffset = insertionOffset - carrierStart;
1041
+ break;
1042
+ }
1043
+ carrierStart += length;
1044
+ }
1045
+ if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
1046
+ return {
1047
+ documentXml,
1048
+ hasChanges: false,
1049
+ status: 'error',
1050
+ error: {
1051
+ code: 'UNSAFE_REVISION_NESTING',
1052
+ message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
1053
+ }
1054
+ };
1055
+ }
1056
+
1057
+ const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
1058
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
1059
+ xmlDoc,
1060
+ targetCarrier,
1061
+ carrierOffset,
1062
+ options._revisionIdAllocator || null
1063
+ );
1064
+ const insertion = createWordElement(xmlDoc, 'w:ins');
1065
+ const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
1066
+ insertion.setAttribute('w:id', String(metadata.id));
1067
+ insertion.setAttribute('w:author', metadata.author);
1068
+ insertion.setAttribute('w:date', metadata.date);
1069
+ const run = createWordElement(xmlDoc, 'w:r');
1070
+ if (runProperties) run.appendChild(runProperties);
1071
+ const textNode = createWordElement(xmlDoc, 'w:t');
1072
+ if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
1073
+ textNode.textContent = modified;
1074
+ run.appendChild(textNode);
1075
+ insertion.appendChild(run);
1076
+
1077
+ const parent = targetCarrier.parentNode;
1078
+ if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
1079
+ parent.insertBefore(insertion, targetCarrier);
1080
+ if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
1081
+ options?._mutationRemovedNodes?.push(targetCarrier);
1082
+ parent.removeChild(targetCarrier);
1083
+ options?._mutationLiveNodes?.push(paragraph, insertion);
1084
+ operationSession?.invalidateParagraphMetadata?.();
1085
+
1086
+ const outputXml = serializer.serializeToString(xmlDoc);
1087
+ const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
1088
+ const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
1089
+ if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
1090
+ return {
1091
+ documentXml,
1092
+ hasChanges: false,
1093
+ status: 'error',
1094
+ error: {
1095
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
1096
+ message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
1097
+ }
1098
+ };
1099
+ }
1100
+ return { documentXml: outputXml, hasChanges: true, status: 'ok' };
1101
+ }
1102
+
1103
+ /**
1104
+ * Materializes an explicit counterproposal for one or more paragraphs wholly
1105
+ * deleted (content and paragraph mark) by another author.
1106
+ */
1107
+ export async function restoreDeletedParagraphByExactText(
1108
+ documentXml,
1109
+ targetText,
1110
+ modified,
1111
+ author,
1112
+ targetRef = null,
1113
+ targetEndRef = null,
1114
+ runtimeContext = null,
1115
+ options = {}
1116
+ ) {
1117
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1118
+ if (!xmlDoc) {
1119
+ return {
1120
+ documentXml,
1121
+ hasChanges: false,
1122
+ status: 'error',
1123
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
1124
+ };
1125
+ }
1126
+
1127
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
1128
+ if (resolved?.error || !resolved?.paragraph) {
1129
+ return {
1130
+ documentXml,
1131
+ hasChanges: false,
1132
+ status: 'error',
1133
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
1134
+ };
1135
+ }
1136
+
1137
+ const firstSource = resolved.paragraph;
1138
+ let sourceParagraphs = [firstSource];
1139
+ if (options._compiledSourceEndId) {
1140
+ const endResolved = resolveTargetParagraph(
1141
+ xmlDoc,
1142
+ options.targetEndDescriptor?.text || '',
1143
+ targetEndRef,
1144
+ 'restore',
1145
+ runtimeContext,
1146
+ { ...options, _compiledSourceId: options._compiledSourceEndId }
1147
+ );
1148
+ const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
1149
+ const startIndex = allParagraphs.indexOf(firstSource);
1150
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
1151
+ sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
1152
+ ? allParagraphs.slice(startIndex, endIndex + 1)
1153
+ : null;
1154
+ } else if (targetEndRef) {
1155
+ sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1156
+ opType: 'restore',
1157
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
1158
+ onInfo: options.onInfo,
1159
+ onWarn: options.onWarn
1160
+ });
1161
+ } else if (options.targetEndDescriptor) {
1162
+ const endResolved = resolveTargetParagraph(
1163
+ xmlDoc,
1164
+ options.targetEndDescriptor.text,
1165
+ options.targetEndDescriptor.index,
1166
+ 'restore',
1167
+ runtimeContext,
1168
+ {
1169
+ ...options,
1170
+ targetDescriptor: options.targetEndDescriptor,
1171
+ _compiledSourceId: options._compiledSourceEndId || null
1172
+ }
1173
+ );
1174
+ const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
1175
+ const startIndex = allParagraphs.indexOf(firstSource);
1176
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
1177
+ sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
1178
+ ? allParagraphs.slice(startIndex, endIndex + 1)
1179
+ : null;
1180
+ }
1181
+ if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
1182
+ return {
1183
+ documentXml,
1184
+ hasChanges: false,
1185
+ status: 'error',
1186
+ error: {
1187
+ code: 'RESTORATION_COUNT_MISMATCH',
1188
+ message: 'The restoration range could not be resolved as a contiguous paragraph block.'
1189
+ }
1190
+ };
1191
+ }
1192
+
1193
+ const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
1194
+ if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
1195
+ return {
1196
+ documentXml,
1197
+ hasChanges: false,
1198
+ status: 'error',
1199
+ error: {
1200
+ code: 'RESTORATION_COUNT_MISMATCH',
1201
+ message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
1202
+ }
1203
+ };
1204
+ }
1205
+
1206
+ const parent = firstSource.parentNode;
1207
+ if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
1208
+ return {
1209
+ documentXml,
1210
+ hasChanges: false,
1211
+ status: 'error',
1212
+ error: {
1213
+ code: 'UNSAFE_PARAGRAPH_PLACEMENT',
1214
+ message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
1215
+ }
1216
+ };
1217
+ }
1218
+
1219
+ for (const paragraph of sourceParagraphs) {
1220
+ const structuralRefusal = getParagraphRestorationRefusal(paragraph);
1221
+ if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
1222
+ return {
1223
+ documentXml,
1224
+ hasChanges: false,
1225
+ status: 'error',
1226
+ error: structuralRefusal
1227
+ };
1228
+ }
1229
+ const state = inspectForeignDeletedParagraphTarget(paragraph, author);
1230
+ if (!state.matches) {
1231
+ return {
1232
+ documentXml,
1233
+ hasChanges: false,
1234
+ status: 'error',
1235
+ error: {
1236
+ code: 'RESTORATION_STATE_REQUIRED',
1237
+ message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
1238
+ ? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
1239
+ : 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
1240
+ ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
1241
+ }
1242
+ };
1243
+ }
1244
+ const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
1245
+ if (refusal) {
1246
+ return {
1247
+ documentXml,
1248
+ hasChanges: false,
1249
+ status: 'error',
1250
+ error: refusal
1251
+ };
1252
+ }
1253
+ }
1254
+
1255
+ const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
1256
+ const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
1257
+ const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
1258
+ && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
1259
+ if (
1260
+ hasExistingRestoration
1261
+ && existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
1262
+ ) {
1263
+ return {
1264
+ documentXml,
1265
+ hasChanges: false,
1266
+ status: 'no-op',
1267
+ warnings: ['An identical same-author paragraph restoration already exists.']
1268
+ };
1269
+ }
1270
+
1271
+ const beforeXml = serializer.serializeToString(xmlDoc);
1272
+ const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
1273
+ const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
1274
+ const existingIndexes = hasExistingRestoration
1275
+ ? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
1276
+ : [];
1277
+ const templates = [];
1278
+ const warnings = [];
1279
+ for (let index = 0; index < sourceParagraphs.length; index++) {
1280
+ const source = sourceParagraphs[index];
1281
+ warnings.push(...collectRestorationAnchorWarnings(source));
1282
+ const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
1283
+ const edited = template
1284
+ ? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
1285
+ : null;
1286
+ if (!edited) {
1287
+ return {
1288
+ documentXml,
1289
+ hasChanges: false,
1290
+ status: 'error',
1291
+ error: {
1292
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
1293
+ message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
1294
+ }
1295
+ };
1296
+ }
1297
+ templates.push(edited);
1298
+ }
1299
+
1300
+ const insertedParagraphs = templates.map(template => {
1301
+ const paragraph = xmlDoc.importNode(template, true);
1302
+ return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
1303
+ });
1304
+ const insertionPoint = lastSource.nextSibling;
1305
+ for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
1306
+ if (hasExistingRestoration) {
1307
+ for (const paragraph of existingBlock) {
1308
+ options?._mutationRemovedNodes?.push(paragraph);
1309
+ paragraph.parentNode?.removeChild(paragraph);
1310
+ }
1311
+ }
1312
+ options?._mutationLiveNodes?.push(...insertedParagraphs);
1313
+ normalizeBodySectionOrder(xmlDoc);
1314
+
1315
+ const outputXml = serializer.serializeToString(xmlDoc);
1316
+ const oracle = verifyParagraphRestorationLifecycle(
1317
+ beforeXml,
1318
+ outputXml,
1319
+ sourceStartIndex,
1320
+ existingIndexes,
1321
+ templates,
1322
+ insertedParagraphs
1323
+ );
1324
+ if (!oracle.valid) {
1325
+ return {
1326
+ documentXml,
1327
+ hasChanges: false,
1328
+ status: 'error',
1329
+ error: {
1330
+ code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
1331
+ message: oracle.message,
1332
+ stage: oracle.stage,
1333
+ ...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
1334
+ ...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
1335
+ ...(oracle.expected ? { expected: oracle.expected } : {}),
1336
+ ...(oracle.actual ? { actual: oracle.actual } : {})
1337
+ }
1338
+ };
1339
+ }
1340
+
1341
+ return {
1342
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1343
+ hasChanges: true,
1344
+ status: 'ok',
1345
+ numberingXml: null,
1346
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
1347
+ };
1348
+ }
1344
1349
 
1345
1350
  async function tryExplicitDecimalHeaderListConversion({
1346
1351
  xmlDoc,
@@ -1643,25 +1648,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1643
1648
  error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
1644
1649
  };
1645
1650
  }
1646
- const targetParagraph = resolved.paragraph;
1647
- if (typeof modifiedText === 'string' && modifiedText.length > 0) {
1648
- const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
1649
- if (resurrectionTarget.matches) {
1650
- const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
1651
- return {
1652
- documentXml,
1653
- hasChanges: false,
1654
- numberingXml: null,
1655
- status: 'error',
1656
- error: {
1657
- code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
1658
- message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
1659
- ownerAuthor
1660
- }
1661
- };
1662
- }
1663
- }
1664
- preprocessRedlineTargetParagraph(targetParagraph);
1651
+ const targetParagraph = resolved.paragraph;
1652
+ if (typeof modifiedText === 'string' && modifiedText.length > 0) {
1653
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
1654
+ if (resurrectionTarget.matches) {
1655
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
1656
+ return {
1657
+ documentXml,
1658
+ hasChanges: false,
1659
+ numberingXml: null,
1660
+ status: 'error',
1661
+ error: {
1662
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
1663
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
1664
+ ownerAuthor
1665
+ }
1666
+ };
1667
+ }
1668
+ }
1669
+ preprocessRedlineTargetParagraph(targetParagraph);
1665
1670
  const currentParagraphText = getParagraphText(targetParagraph);
1666
1671
  if (modifiedText === '') {
1667
1672
  const commentIds = getCommentIdsInElement(targetParagraph);
@@ -1682,7 +1687,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1682
1687
  }
1683
1688
  }
1684
1689
  const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
1685
- if (!boundaryCheck.valid) {
1690
+ if (!boundaryCheck.valid) {
1686
1691
  return {
1687
1692
  documentXml,
1688
1693
  hasChanges: false,
@@ -1691,111 +1696,111 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1691
1696
  error: {
1692
1697
  code: boundaryCheck.code,
1693
1698
  message: boundaryCheck.message
1694
- }
1695
- };
1696
- }
1697
- const hasRevisions = containsTrackedChanges(targetParagraph);
1698
- const existingPolicy = options.existingRevisions || 'merge-same-author';
1699
- if (
1700
- hasRevisions
1701
- && existingPolicy !== 'accept-all-first'
1702
- && existingPolicy !== 'accept-all-first-keep-normalized'
1703
- ) {
1704
- if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
1705
- const authors = getTrackedChangeAuthors(targetParagraph);
1706
- const opAuthor = String(author || '').trim().toLowerCase();
1707
- const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
1708
- if (!allSame && existingPolicy === 'merge-same-author') {
1709
- return {
1710
- documentXml,
1711
- hasChanges: false,
1712
- numberingXml: null,
1713
- status: 'error',
1714
- error: {
1715
- code: 'EXISTING_REVISIONS',
1716
- message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
1717
- revisionAuthors: authors,
1718
- currentPolicy: existingPolicy
1719
- }
1720
- };
1721
- }
1722
- const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
1723
- if (mergeCommentIds.length > 0) {
1724
- const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
1725
- return {
1726
- documentXml,
1727
- hasChanges: false,
1728
- numberingXml: null,
1729
- status: 'error',
1730
- error: {
1731
- code: 'COMMENTED_CONTENT_MERGE',
1732
- message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
1733
- commentIds: mergeCommentIds,
1734
- ...(comments.length > 0 ? { comments } : {})
1735
- }
1736
- };
1737
- }
1738
- if (!allSame) {
1739
- const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
1740
- || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
1741
- if (hasMove) {
1742
- return {
1743
- documentXml,
1744
- hasChanges: false,
1745
- numberingXml: null,
1746
- status: 'error',
1747
- error: {
1748
- code: 'UNSAFE_REVISION_NESTING',
1749
- message: 'Cross-author slicing does not support pending move revisions.'
1750
- }
1751
- };
1752
- }
1753
- }
1754
- } else {
1755
- const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
1756
- const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
1757
- || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
1758
- if (hasDel) {
1759
- return {
1760
- documentXml,
1761
- hasChanges: false,
1762
- numberingXml: null,
1763
- status: 'error',
1764
- error: {
1765
- code: 'UNSAFE_REVISION_NESTING',
1766
- message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
1767
- }
1768
- };
1769
- }
1770
- if (hasMove) {
1771
- return {
1772
- documentXml,
1773
- hasChanges: false,
1774
- numberingXml: null,
1775
- status: 'error',
1776
- error: {
1777
- code: 'UNSAFE_REVISION_NESTING',
1778
- message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
1779
- }
1780
- };
1781
- }
1782
- return {
1783
- documentXml,
1784
- hasChanges: false,
1785
- numberingXml: null,
1786
- status: 'error',
1787
- error: {
1788
- code: 'EXISTING_REVISIONS',
1789
- message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
1790
- }
1791
- };
1792
- }
1793
- }
1794
- if (
1795
- modifiedText === ''
1796
- && currentParagraphText === ''
1797
- && !(hasRevisions && existingPolicy === 'merge-same-author')
1798
- ) {
1699
+ }
1700
+ };
1701
+ }
1702
+ const hasRevisions = containsTrackedChanges(targetParagraph);
1703
+ const existingPolicy = options.existingRevisions || 'merge-same-author';
1704
+ if (
1705
+ hasRevisions
1706
+ && existingPolicy !== 'accept-all-first'
1707
+ && existingPolicy !== 'accept-all-first-keep-normalized'
1708
+ ) {
1709
+ if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
1710
+ const authors = getTrackedChangeAuthors(targetParagraph);
1711
+ const opAuthor = String(author || '').trim().toLowerCase();
1712
+ const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
1713
+ if (!allSame && existingPolicy === 'merge-same-author') {
1714
+ return {
1715
+ documentXml,
1716
+ hasChanges: false,
1717
+ numberingXml: null,
1718
+ status: 'error',
1719
+ error: {
1720
+ code: 'EXISTING_REVISIONS',
1721
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
1722
+ revisionAuthors: authors,
1723
+ currentPolicy: existingPolicy
1724
+ }
1725
+ };
1726
+ }
1727
+ const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
1728
+ if (mergeCommentIds.length > 0) {
1729
+ const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
1730
+ return {
1731
+ documentXml,
1732
+ hasChanges: false,
1733
+ numberingXml: null,
1734
+ status: 'error',
1735
+ error: {
1736
+ code: 'COMMENTED_CONTENT_MERGE',
1737
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
1738
+ commentIds: mergeCommentIds,
1739
+ ...(comments.length > 0 ? { comments } : {})
1740
+ }
1741
+ };
1742
+ }
1743
+ if (!allSame) {
1744
+ const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
1745
+ || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
1746
+ if (hasMove) {
1747
+ return {
1748
+ documentXml,
1749
+ hasChanges: false,
1750
+ numberingXml: null,
1751
+ status: 'error',
1752
+ error: {
1753
+ code: 'UNSAFE_REVISION_NESTING',
1754
+ message: 'Cross-author slicing does not support pending move revisions.'
1755
+ }
1756
+ };
1757
+ }
1758
+ }
1759
+ } else {
1760
+ const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
1761
+ const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
1762
+ || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
1763
+ if (hasDel) {
1764
+ return {
1765
+ documentXml,
1766
+ hasChanges: false,
1767
+ numberingXml: null,
1768
+ status: 'error',
1769
+ error: {
1770
+ code: 'UNSAFE_REVISION_NESTING',
1771
+ message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
1772
+ }
1773
+ };
1774
+ }
1775
+ if (hasMove) {
1776
+ return {
1777
+ documentXml,
1778
+ hasChanges: false,
1779
+ numberingXml: null,
1780
+ status: 'error',
1781
+ error: {
1782
+ code: 'UNSAFE_REVISION_NESTING',
1783
+ message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
1784
+ }
1785
+ };
1786
+ }
1787
+ return {
1788
+ documentXml,
1789
+ hasChanges: false,
1790
+ numberingXml: null,
1791
+ status: 'error',
1792
+ error: {
1793
+ code: 'EXISTING_REVISIONS',
1794
+ message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
1795
+ }
1796
+ };
1797
+ }
1798
+ }
1799
+ if (
1800
+ modifiedText === ''
1801
+ && currentParagraphText === ''
1802
+ && !(hasRevisions && existingPolicy === 'merge-same-author')
1803
+ ) {
1799
1804
  if (generateRedlines) {
1800
1805
  markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
1801
1806
  options?._mutationLiveNodes?.push(targetParagraph);
@@ -1944,6 +1949,102 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1944
1949
  };
1945
1950
  }
1946
1951
 
1952
+ // Retained paragraph followed by a Markdown table: keep the source paragraph in place (so its own
1953
+ // content, formatting and revisions are untouched), apply any requested inline formatting to it,
1954
+ // and insert the table after it instead of rewriting the paragraph as a deletion plus a copy.
1955
+ const tableAppendCandidate = (
1956
+ !useTableScope
1957
+ && !hasExplicitRangeScope
1958
+ && !targetListInfo
1959
+ && !containingTable
1960
+ && !targetEndRef
1961
+ )
1962
+ ? deriveParagraphThenTableAppend(effectiveModifiedText)
1963
+ : null;
1964
+ const tableAppendParagraph = tableAppendCandidate
1965
+ ? preprocessMarkdown(tableAppendCandidate.paragraphMarkdown)
1966
+ : null;
1967
+ if (
1968
+ tableAppendCandidate
1969
+ && normalizeWhitespaceForTargeting(tableAppendParagraph.cleanText) === normalizeWhitespaceForTargeting(currentParagraphText)
1970
+ ) {
1971
+ const tableData = parseMarkdownTable(tableAppendCandidate.tableText);
1972
+ if (tableData.headers.length > 0 || tableData.rows.length > 0) {
1973
+ onInfo('[Table] Applying retained-paragraph table append heuristic.');
1974
+ const parent = targetParagraph.parentNode;
1975
+ if (!parent) throw new Error('Target paragraph has no parent for table append');
1976
+ const followingNode = targetParagraph.nextSibling;
1977
+ if (tableAppendParagraph.formatHints.length > 0) {
1978
+ const formatted = await applyRedlineToOxml(
1979
+ serializer.serializeToString(targetParagraph),
1980
+ currentParagraphText || targetText,
1981
+ tableAppendCandidate.paragraphMarkdown,
1982
+ {
1983
+ author,
1984
+ generateRedlines,
1985
+ existingRevisions: options.existingRevisions || 'merge-same-author',
1986
+ structuredContent: false,
1987
+ _revisionIdAllocator: revisionIdAllocator
1988
+ }
1989
+ );
1990
+ if (formatted?.status === 'error') {
1991
+ return {
1992
+ documentXml,
1993
+ hasChanges: false,
1994
+ numberingXml: null,
1995
+ status: 'error',
1996
+ error: formatted.error
1997
+ };
1998
+ }
1999
+ if (formatted?.hasChanges) {
2000
+ if (typeof formatted.oxml !== 'string') {
2001
+ throw new Error('Reconciliation engine did not return OOXML for a changed paragraph format');
2002
+ }
2003
+ for (const node of extractReplacementNodes(formatted.oxml).replacementNodes) {
2004
+ const imported = xmlDoc.importNode(node, true);
2005
+ ensureParagraphIdsOnImportedNode(imported, operationSession);
2006
+ options?._mutationLiveNodes?.push(parent.insertBefore(imported, targetParagraph));
2007
+ }
2008
+ options?._mutationRemovedNodes?.push(targetParagraph);
2009
+ parent.removeChild(targetParagraph);
2010
+ }
2011
+ }
2012
+ const tableOoxml = generateTableOoxml(tableData, {
2013
+ generateRedlines,
2014
+ author,
2015
+ revisionIdAllocator,
2016
+ trackAsBlock: true
2017
+ });
2018
+ for (const node of extractReplacementNodes(tableOoxml).replacementNodes) {
2019
+ options?._mutationLiveNodes?.push(parent.insertBefore(xmlDoc.importNode(node, true), followingNode));
2020
+ }
2021
+ // Word cannot end the body (or a container) with a table: it would add an untracked paragraph
2022
+ // there that Reject All leaves behind. Supply the paragraph ourselves with a tracked-inserted
2023
+ // mark so Reject All removes it and the source paragraph count is restored.
2024
+ const endsContainer = !followingNode
2025
+ || (followingNode.nodeType === 1 && followingNode.namespaceURI === NS_W && followingNode.localName === 'sectPr');
2026
+ if (endsContainer) {
2027
+ const trailing = createWordElement(xmlDoc, 'w:p');
2028
+ const trailingPPr = clonePropertiesWithoutRevisionHistory(getDirectWordChild(targetParagraph, 'pPr'));
2029
+ if (trailingPPr) {
2030
+ for (const child of Array.from(trailingPPr.childNodes)) {
2031
+ if (child.nodeType === 1 && ['sectPr', 'numPr'].includes(child.localName)) trailingPPr.removeChild(child);
2032
+ }
2033
+ trailing.appendChild(trailingPPr);
2034
+ }
2035
+ if (generateRedlines) markParagraphMarkInserted(xmlDoc, trailing, author);
2036
+ ensureParagraphIdsOnImportedNode(trailing, operationSession);
2037
+ options?._mutationLiveNodes?.push(parent.insertBefore(trailing, followingNode));
2038
+ }
2039
+ normalizeBodySectionOrder(xmlDoc);
2040
+ return {
2041
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
2042
+ hasChanges: true,
2043
+ numberingXml: null
2044
+ };
2045
+ }
2046
+ }
2047
+
1947
2048
  const plainAdjacencyInsertionCandidate = (
1948
2049
  !bypassSingleParagraphHeuristics
1949
2050
  && !useTableScope
@@ -1971,7 +2072,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1971
2072
  paragraphText,
1972
2073
  generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
1973
2074
  author,
1974
- { generateRedlines }
2075
+ // A wholly inserted paragraph must track its own paragraph mark; otherwise Word's
2076
+ // Reject All removes the text but leaves an empty paragraph behind.
2077
+ { generateRedlines, trackParagraphMark: true }
1975
2078
  );
1976
2079
  const imported = xmlDoc.importNode(plainParagraph, true);
1977
2080
  ensureParagraphIdsOnImportedNode(imported, operationSession);
@@ -2078,8 +2181,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2078
2181
  ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
2079
2182
  : (inferredTableRangeParagraphs
2080
2183
  ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
2081
- : (currentParagraphText || targetText))
2082
- );
2184
+ : (currentParagraphText || targetText))
2185
+ );
2083
2186
  const scopedXml = useTableScope
2084
2187
  ? serializer.serializeToString(containingTable)
2085
2188
  : (
@@ -2112,6 +2215,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2112
2215
  explicitStructuredContent: options.explicitStructuredContent === true,
2113
2216
  pairReplacements: options.pairReplacements === true,
2114
2217
  insertionAffinity: options.insertionAffinity || null,
2218
+ literalTextEdit: options.literalTextEdit === true,
2115
2219
  _revisionIdAllocator: revisionIdAllocator,
2116
2220
  _isolatedTableCell: useTableScope
2117
2221
  });
@@ -2135,6 +2239,14 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
2135
2239
  const extracted = extractReplacementNodes(result.oxml);
2136
2240
  let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
2137
2241
  let numberingXml = extracted.numberingXml;
2242
+ if (numberingXml && runtimeContext?.numberingIdState && !useTableScope) {
2243
+ const sourceParagraphs = useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]);
2244
+ const continued = reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, runtimeContext.numberingIdState);
2245
+ if (continued) {
2246
+ replacementNodes = continued.replacementNodes;
2247
+ numberingXml = null;
2248
+ }
2249
+ }
2138
2250
  if (numberingXml && runtimeContext?.numberingIdState) {
2139
2251
  const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
2140
2252
  replacementNodes = normalizedNumbering.replacementNodes;
@@ -2418,7 +2530,12 @@ export async function applyFormattingToParagraphByExactText(
2418
2530
  }
2419
2531
 
2420
2532
  const targetDescriptor = options.targetDescriptor;
2421
- const occurrence = targetDescriptor?.occurrence ?? null;
2533
+ // `textOccurrence` is the dedicated in-paragraph selector. Legacy calls without it keep
2534
+ // reading `target.occurrence` (which also selects the N-th matching paragraph).
2535
+ const explicitTextOccurrence = Number.isInteger(options.textOccurrence) && options.textOccurrence > 0
2536
+ ? options.textOccurrence
2537
+ : null;
2538
+ const occurrence = explicitTextOccurrence ?? targetDescriptor?.occurrence ?? null;
2422
2539
  let matchStart = -1;
2423
2540
  if (occurrence != null && occurrence > 0) {
2424
2541
  let count = 0;
@@ -2437,6 +2554,18 @@ export async function applyFormattingToParagraphByExactText(
2437
2554
  matchStart = fullParaText.indexOf(textToFormat);
2438
2555
  }
2439
2556
 
2557
+ if (matchStart === -1 && explicitTextOccurrence != null && fullParaText.includes(textToFormat)) {
2558
+ return {
2559
+ documentXml,
2560
+ hasChanges: false,
2561
+ status: 'error',
2562
+ error: {
2563
+ code: 'PATCH_SOURCE_NOT_FOUND',
2564
+ message: `Occurrence ${explicitTextOccurrence} of text to format was not found in target paragraph: "${textToFormat}".`
2565
+ }
2566
+ };
2567
+ }
2568
+
2440
2569
  if (matchStart === -1) {
2441
2570
  return {
2442
2571
  documentXml,