@ansonlai/docx-redline-js 0.5.2 → 0.5.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.
@@ -20,7 +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';
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';
24
37
  import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
25
38
  import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
26
39
  import { injectCommentsIntoOoxml } from './comment-engine.js';
@@ -30,10 +43,11 @@ import {
30
43
  createParagraphFingerprint,
31
44
  isMarkdownTableText,
32
45
  findContainingWordElement,
33
- resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
34
- resolveParagraphRangeByRefs,
35
- validateParagraphBoundaryMutation
36
- } from '../core/paragraph-targeting.js';
46
+ resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
47
+ resolveParagraphRangeByRefs,
48
+ getDocumentParagraphNodes,
49
+ validateParagraphBoundaryMutation
50
+ } from '../core/paragraph-targeting.js';
37
51
  import {
38
52
  synthesizeExpandedListScopeEdit,
39
53
  planListInsertionOnlyEdit,
@@ -70,9 +84,14 @@ import {
70
84
  deriveSingleParagraphListAdjacencyInsertion,
71
85
  deriveSingleParagraphPlainAdjacencyInsertion
72
86
  } from './operation-heuristics.js';
73
- import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
74
-
75
- const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
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';
76
95
 
77
96
  function getCommentIdsInElement(element) {
78
97
  const ids = new Set();
@@ -150,9 +169,60 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
150
169
  };
151
170
  }
152
171
 
153
- function getParagraphText(paragraph) {
154
- return getParagraphTextFromOxml(paragraph);
155
- }
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
+ }
156
226
 
157
227
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
158
228
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
@@ -166,16 +236,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
166
236
  if (options?._resolutionCapture && resolved?.paragraph) {
167
237
  const paragraph = resolved.paragraph;
168
238
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
169
- Object.assign(options._resolutionCapture, {
170
- resolvedBy: resolved.resolvedBy,
171
- resolvedTarget: {
239
+ Object.assign(options._resolutionCapture, {
240
+ resolvedBy: resolved.resolvedBy,
241
+ resolvedTarget: {
172
242
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
173
243
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
174
244
  text: metadata?.text ?? getParagraphText(paragraph),
175
245
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
176
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
177
- }
178
- });
246
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
247
+ targetTextMatch: describeTargetTextMatch(
248
+ metadata?.text ?? getParagraphText(paragraph),
249
+ options?.targetDescriptor?.exactText ?? targetText
250
+ )
251
+ }
252
+ });
179
253
  }
180
254
  return resolved;
181
255
  } catch (error) {
@@ -204,14 +278,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
204
278
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
205
279
  Object.assign(options._resolutionCapture, {
206
280
  resolvedBy: resolved.resolvedBy,
207
- resolvedTarget: {
281
+ resolvedTarget: {
208
282
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
209
283
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
210
284
  text: metadata?.text ?? getParagraphText(paragraph),
211
285
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
212
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
213
- }
214
- });
286
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
287
+ targetTextMatch: describeTargetTextMatch(
288
+ metadata?.text ?? getParagraphText(paragraph),
289
+ options?.targetDescriptor?.exactText ?? targetText
290
+ )
291
+ }
292
+ });
215
293
  }
216
294
  return resolved;
217
295
  }
@@ -248,7 +326,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
248
326
  removeProofErrNodes(targetParagraph);
249
327
  }
250
328
 
251
- function getDirectWordChild(element, localName) {
329
+ function getDirectWordChild(element, localName) {
252
330
  if (!element) return null;
253
331
  return Array.from(element.childNodes || []).find(
254
332
  node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
@@ -313,7 +391,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
313
391
 
314
392
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
315
393
  if (anchorPPr) {
316
- paragraph.appendChild(anchorPPr.cloneNode(true));
394
+ paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
317
395
  }
318
396
  ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
319
397
 
@@ -329,7 +407,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
329
407
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
330
408
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
331
409
  if (anchorRunPr) {
332
- run.appendChild(anchorRunPr.cloneNode(true));
410
+ run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
333
411
  }
334
412
 
335
413
  const textNode = createWordElement(xmlDoc, 'w:t');
@@ -402,18 +480,21 @@ function applyExplicitRangeListInsertions({
402
480
  normalizeBodySectionOrder(xmlDoc);
403
481
  return true;
404
482
  }
405
- function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
483
+ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
406
484
  const generateRedlines = options.generateRedlines !== false;
407
485
  const paragraph = createWordElement(xmlDoc, 'w:p');
408
486
  const run = createWordElement(xmlDoc, 'w:r');
409
487
  const textNode = createWordElement(xmlDoc, 'w:t');
410
488
  const safeText = String(text || '');
411
489
  if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
412
- textNode.textContent = safeText;
413
- run.appendChild(textNode);
414
-
415
- if (generateRedlines) {
416
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
490
+ textNode.textContent = safeText;
491
+ run.appendChild(textNode);
492
+
493
+ if (generateRedlines) {
494
+ if (options.trackParagraphMark === true) {
495
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
496
+ }
497
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
417
498
  const ins = createWordElement(xmlDoc, 'w:ins');
418
499
  ins.setAttribute('w:id', String(metadata.id));
419
500
  ins.setAttribute('w:author', metadata.author);
@@ -430,12 +511,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
430
511
  function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
431
512
  const paragraph = createWordElement(xmlDoc, 'w:p');
432
513
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
433
- if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
514
+ if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
434
515
 
435
516
  const run = createWordElement(xmlDoc, 'w:r');
436
517
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
437
518
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
438
- if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
519
+ if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
439
520
 
440
521
  const textNode = createWordElement(xmlDoc, 'w:t');
441
522
  textNode.textContent = '';
@@ -444,13 +525,19 @@ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
444
525
  return paragraph;
445
526
  }
446
527
 
447
- function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
448
- const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
449
- const pPr = getDirectWordChild(paragraph, 'pPr');
450
- if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
451
-
452
- const ins = createWordElement(xmlDoc, 'w:ins');
453
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
528
+ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
529
+ const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
530
+ const pPr = options.sanitizeParagraphProperties === true
531
+ ? createSanitizedRestorationPPr(xmlDoc, paragraph)
532
+ : clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
533
+ if (pPr) wrappedParagraph.appendChild(pPr);
534
+ if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
535
+ if (options.trackParagraphMark === true) {
536
+ markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
537
+ }
538
+
539
+ const ins = createWordElement(xmlDoc, 'w:ins');
540
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
454
541
  ins.setAttribute('w:id', String(metadata.id));
455
542
  ins.setAttribute('w:author', metadata.author);
456
543
  ins.setAttribute('w:date', metadata.date);
@@ -491,9 +578,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
491
578
  return buildFallbackInsertedPlainParagraph(
492
579
  xmlDoc,
493
580
  text,
494
- revisionMetadata,
495
- author,
496
- { generateRedlines }
581
+ revisionMetadata,
582
+ author,
583
+ { ...options, generateRedlines }
497
584
  );
498
585
  }
499
586
 
@@ -501,10 +588,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
501
588
  return sourceParagraph;
502
589
  }
503
590
 
504
- return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
591
+ return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
505
592
  }
506
593
 
507
- function collectNumberingIdsFromNodes(nodes) {
594
+ function collectNumberingIdsFromNodes(nodes) {
508
595
  const ids = new Set();
509
596
  for (const node of nodes || []) {
510
597
  const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
@@ -516,7 +603,700 @@ function collectNumberingIdsFromNodes(nodes) {
516
603
  }
517
604
  }
518
605
  return Array.from(ids);
519
- }
606
+ }
607
+
608
+ function directDeletionCarrierText(carrier) {
609
+ return Array.from(carrier?.childNodes || []).reduce((text, child) => {
610
+ return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
611
+ ? getRunContentPieces(child).map(piece => piece.text).join('')
612
+ : '');
613
+ }, '');
614
+ }
615
+
616
+ function findOccurrenceOffset(text, needle, occurrence) {
617
+ let from = 0;
618
+ let found = -1;
619
+ for (let index = 0; index < occurrence; index++) {
620
+ found = text.indexOf(needle, from);
621
+ if (found < 0) return -1;
622
+ from = found + Math.max(needle.length, 1);
623
+ }
624
+ return found;
625
+ }
626
+
627
+ function countNonOverlappingOccurrences(text, needle) {
628
+ if (!needle) return 0;
629
+ let count = 0;
630
+ let from = 0;
631
+ while (from <= text.length) {
632
+ const found = text.indexOf(needle, from);
633
+ if (found < 0) break;
634
+ count += 1;
635
+ from = found + needle.length;
636
+ }
637
+ return count;
638
+ }
639
+
640
+ function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
641
+ const unsafeParagraphNames = new Set([
642
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference',
643
+ 'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
644
+ 'moveToRangeStart', 'moveToRangeEnd'
645
+ ]);
646
+ if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
647
+ return true;
648
+ }
649
+ for (const child of Array.from(carrier?.childNodes || [])) {
650
+ if (child.nodeType !== 1) continue;
651
+ if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
652
+ for (const runChild of Array.from(child.childNodes || [])) {
653
+ if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
654
+ if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
655
+ }
656
+ }
657
+ return false;
658
+ }
659
+
660
+ function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
661
+ let offset = 0;
662
+ for (const child of Array.from(carrier?.childNodes || [])) {
663
+ if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
664
+ const length = getRunTextLength(getRunContentPieces(child));
665
+ if (localOffset <= offset + length) {
666
+ return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
667
+ }
668
+ offset += length;
669
+ }
670
+ return null;
671
+ }
672
+
673
+ const RESTORATION_PPR_ALLOWLIST = new Set([
674
+ 'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
675
+ 'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
676
+ ]);
677
+
678
+ function wordAttribute(node, localName) {
679
+ return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
680
+ }
681
+
682
+ function normalizedAuthor(author) {
683
+ return String(author || '').trim().toLowerCase();
684
+ }
685
+
686
+ function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
687
+ let cursor = paragraph?.[direction] || null;
688
+ while (cursor) {
689
+ if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
690
+ cursor = cursor[direction] || null;
691
+ }
692
+ return null;
693
+ }
694
+
695
+ function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
696
+ const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
697
+ const pPr = createWordElement(xmlDoc, 'w:pPr');
698
+ if (!sourcePPr) return pPr;
699
+ for (const child of Array.from(sourcePPr.childNodes || [])) {
700
+ if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
701
+ if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
702
+ pPr.appendChild(xmlDoc.importNode(child, true));
703
+ }
704
+ return pPr;
705
+ }
706
+
707
+ function collectRestorationAnchorWarnings(sourceParagraph) {
708
+ const warnings = [];
709
+ const bookmarkNames = new Set();
710
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
711
+ const name = wordAttribute(node, 'name') || '(unnamed)';
712
+ bookmarkNames.add(name);
713
+ }
714
+ for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
715
+
716
+ const commentIds = new Set();
717
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
718
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
719
+ const id = wordAttribute(node, 'id');
720
+ if (id !== '') commentIds.add(id);
721
+ }
722
+ }
723
+ for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
724
+ return warnings;
725
+ }
726
+
727
+ function removeRestorationAnchors(paragraph) {
728
+ const anchorNames = [
729
+ 'bookmarkStart', 'bookmarkEnd',
730
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference'
731
+ ];
732
+ for (const localName of anchorNames) {
733
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
734
+ node.parentNode?.removeChild(node);
735
+ }
736
+ }
737
+ for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
738
+ const meaningful = Array.from(run.childNodes || []).some(child => (
739
+ child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
740
+ ));
741
+ if (!meaningful) run.parentNode?.removeChild(run);
742
+ }
743
+ }
744
+
745
+ function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
746
+ const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
747
+ const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
748
+ const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
749
+ if (!rejectedParagraph) return null;
750
+
751
+ const paragraph = createWordElement(xmlDoc, 'w:p');
752
+ paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
753
+ for (const child of Array.from(rejectedParagraph.childNodes || [])) {
754
+ if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
755
+ paragraph.appendChild(xmlDoc.importNode(child, true));
756
+ }
757
+ removeRestorationAnchors(paragraph);
758
+ return paragraph;
759
+ }
760
+
761
+ async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
762
+ const originalText = extractCanonicalParagraphText(template);
763
+ if (originalText === modifiedText) return template;
764
+ const result = await applyRedlineToOxml(
765
+ serializer.serializeToString(template),
766
+ originalText,
767
+ modifiedText,
768
+ {
769
+ author,
770
+ generateRedlines: false,
771
+ structuredContent: false
772
+ }
773
+ );
774
+ if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
775
+ const extracted = extractReplacementNodes(result.oxml);
776
+ const paragraph = (extracted.replacementNodes || []).find(node => (
777
+ node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
778
+ ));
779
+ return paragraph ? xmlDoc.importNode(paragraph, true) : null;
780
+ }
781
+
782
+ function allocateFreshParagraphId(xmlDoc, operationSession) {
783
+ const used = new Set(getDocumentParagraphNodes(xmlDoc)
784
+ .map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
785
+ .filter(Boolean)
786
+ .map(value => value.toUpperCase()));
787
+ let candidate = null;
788
+ do {
789
+ candidate = operationSession?.generateParagraphId?.()
790
+ || (0x40000000 + used.size + 1).toString(16).toUpperCase();
791
+ } while (used.has(candidate.toUpperCase()));
792
+ return candidate;
793
+ }
794
+
795
+ function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
796
+ const root = xmlDoc.documentElement;
797
+ if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
798
+ return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
799
+ paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
800
+ sanitizeParagraphProperties: true,
801
+ trackParagraphMark: true
802
+ });
803
+ }
804
+
805
+ function isInsertedParagraphByAuthor(paragraph, author) {
806
+ const pPr = getDirectWordChild(paragraph, 'pPr');
807
+ const rPr = getDirectWordChild(pPr, 'rPr');
808
+ const marker = getDirectWordChild(rPr, 'ins');
809
+ return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
810
+ }
811
+
812
+ function followingParagraphBlock(lastSource, count) {
813
+ const paragraphs = [];
814
+ let cursor = lastSource;
815
+ for (let i = 0; i < count; i++) {
816
+ cursor = directWordParagraphSibling(cursor, 'nextSibling');
817
+ if (!cursor) return [];
818
+ paragraphs.push(cursor);
819
+ }
820
+ return paragraphs;
821
+ }
822
+
823
+ function paragraphTextVector(oxml) {
824
+ const parsed = parseOoxmlSafe(oxml, 'application/xml');
825
+ if (parsed.error || !parsed.doc) return null;
826
+ return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
827
+ }
828
+
829
+ function sameTextVector(left, right) {
830
+ return Array.isArray(left) && Array.isArray(right)
831
+ && left.length === right.length
832
+ && left.every((value, index) => value === right[index]);
833
+ }
834
+
835
+ function lifecycleTextVector(oxml, action) {
836
+ const result = action === 'accept'
837
+ ? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
838
+ : rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
839
+ return paragraphTextVector(result.oxml);
840
+ }
841
+
842
+ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
843
+ const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
844
+ if (parsed.error || !parsed.doc) return null;
845
+ const expectedDoc = parsed.doc;
846
+ const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
847
+ const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
848
+ if (!lastSource?.parentNode) return null;
849
+
850
+ for (const index of [...existingIndexes].sort((a, b) => b - a)) {
851
+ const paragraph = originalParagraphs[index];
852
+ paragraph?.parentNode?.removeChild(paragraph);
853
+ }
854
+ const insertionPoint = lastSource.nextSibling;
855
+ for (const template of templates) {
856
+ lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
857
+ }
858
+ return createSerializer().serializeToString(expectedDoc);
859
+ }
860
+
861
+ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
862
+ const baselineValidation = validateRedlineOoxml(beforeXml);
863
+ const outputValidation = validateRedlineOoxml(outputXml);
864
+ const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
865
+ const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
866
+ validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
867
+ .filter(issue => issue.severity === 'error')
868
+ ));
869
+ const generatedErrors = validationErrors(generatedIssues);
870
+ if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
871
+ return {
872
+ valid: false,
873
+ stage: 'validation',
874
+ code: 'GENERATED_OOXML_INVALID',
875
+ generatedIssues,
876
+ envelopeIssues,
877
+ message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
878
+ };
879
+ }
880
+
881
+ const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
882
+ if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
883
+
884
+ const comparisons = [
885
+ ['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
886
+ ['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
887
+ ['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
888
+ ];
889
+ for (const [stage, expected, actual] of comparisons) {
890
+ if (!sameTextVector(expected, actual)) {
891
+ return {
892
+ valid: false,
893
+ stage,
894
+ expected,
895
+ actual,
896
+ message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
897
+ };
898
+ }
899
+ }
900
+ return { valid: true };
901
+ }
902
+
903
+ /**
904
+ * Inserts a new tracked run at an exact offset inside text visible only in the
905
+ * rejected view of a wholly deleted paragraph. The foreign deletion is split,
906
+ * never rewritten or re-authored.
907
+ */
908
+ export async function insertIntoRejectedDeletedText(
909
+ documentXml,
910
+ targetText,
911
+ anchor,
912
+ modified,
913
+ author,
914
+ targetRef = null,
915
+ runtimeContext = null,
916
+ options = {}
917
+ ) {
918
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
919
+ if (!xmlDoc) {
920
+ return {
921
+ documentXml,
922
+ hasChanges: false,
923
+ status: 'error',
924
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
925
+ };
926
+ }
927
+
928
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
929
+ if (resolved?.error || !resolved?.paragraph) {
930
+ return {
931
+ documentXml,
932
+ hasChanges: false,
933
+ status: 'error',
934
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
935
+ };
936
+ }
937
+
938
+ const paragraph = resolved.paragraph;
939
+ const state = inspectForeignDeletedParagraphTarget(paragraph, author);
940
+ if (!state.matches) {
941
+ return {
942
+ documentXml,
943
+ hasChanges: false,
944
+ status: 'error',
945
+ error: {
946
+ code: 'REJECTED_INSERTION_STATE_REQUIRED',
947
+ message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
948
+ }
949
+ };
950
+ }
951
+ const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
952
+ if (structuralRefusal) {
953
+ return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
954
+ }
955
+ if (/\r|\n/.test(modified)) {
956
+ return {
957
+ documentXml,
958
+ hasChanges: false,
959
+ status: 'error',
960
+ error: {
961
+ code: 'UNSAFE_PARAGRAPH_BOUNDARY',
962
+ message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
963
+ }
964
+ };
965
+ }
966
+
967
+ const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
968
+ if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
969
+ return {
970
+ documentXml,
971
+ hasChanges: false,
972
+ status: 'error',
973
+ error: {
974
+ code: 'AMBIGUOUS_ANCHOR',
975
+ message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
976
+ }
977
+ };
978
+ }
979
+ const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
980
+ if (anchorStart < 0) {
981
+ return {
982
+ documentXml,
983
+ hasChanges: false,
984
+ status: 'error',
985
+ error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
986
+ };
987
+ }
988
+ const insertionOffset = anchorStart + anchor.offset;
989
+ const carriers = Array.from(paragraph.childNodes || []).filter(
990
+ node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
991
+ );
992
+ if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
993
+ return {
994
+ documentXml,
995
+ hasChanges: false,
996
+ status: 'error',
997
+ error: {
998
+ code: 'UNSAFE_REVISION_BOUNDARY',
999
+ message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
1000
+ }
1001
+ };
1002
+ }
1003
+ let carrierStart = 0;
1004
+ let targetCarrier = null;
1005
+ let carrierOffset = 0;
1006
+ for (const carrier of carriers) {
1007
+ const length = directDeletionCarrierText(carrier).length;
1008
+ if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
1009
+ targetCarrier = carrier;
1010
+ carrierOffset = insertionOffset - carrierStart;
1011
+ break;
1012
+ }
1013
+ carrierStart += length;
1014
+ }
1015
+ if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
1016
+ return {
1017
+ documentXml,
1018
+ hasChanges: false,
1019
+ status: 'error',
1020
+ error: {
1021
+ code: 'UNSAFE_REVISION_NESTING',
1022
+ message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
1023
+ }
1024
+ };
1025
+ }
1026
+
1027
+ const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
1028
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
1029
+ xmlDoc,
1030
+ targetCarrier,
1031
+ carrierOffset,
1032
+ options._revisionIdAllocator || null
1033
+ );
1034
+ const insertion = createWordElement(xmlDoc, 'w:ins');
1035
+ const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
1036
+ insertion.setAttribute('w:id', String(metadata.id));
1037
+ insertion.setAttribute('w:author', metadata.author);
1038
+ insertion.setAttribute('w:date', metadata.date);
1039
+ const run = createWordElement(xmlDoc, 'w:r');
1040
+ if (runProperties) run.appendChild(runProperties);
1041
+ const textNode = createWordElement(xmlDoc, 'w:t');
1042
+ if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
1043
+ textNode.textContent = modified;
1044
+ run.appendChild(textNode);
1045
+ insertion.appendChild(run);
1046
+
1047
+ const parent = targetCarrier.parentNode;
1048
+ if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
1049
+ parent.insertBefore(insertion, targetCarrier);
1050
+ if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
1051
+ options?._mutationRemovedNodes?.push(targetCarrier);
1052
+ parent.removeChild(targetCarrier);
1053
+ options?._mutationLiveNodes?.push(paragraph, insertion);
1054
+ operationSession?.invalidateParagraphMetadata?.();
1055
+
1056
+ const outputXml = serializer.serializeToString(xmlDoc);
1057
+ const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
1058
+ const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
1059
+ if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
1060
+ return {
1061
+ documentXml,
1062
+ hasChanges: false,
1063
+ status: 'error',
1064
+ error: {
1065
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
1066
+ message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
1067
+ }
1068
+ };
1069
+ }
1070
+ return { documentXml: outputXml, hasChanges: true, status: 'ok' };
1071
+ }
1072
+
1073
+ /**
1074
+ * Materializes an explicit counterproposal for one or more paragraphs wholly
1075
+ * deleted (content and paragraph mark) by another author.
1076
+ */
1077
+ export async function restoreDeletedParagraphByExactText(
1078
+ documentXml,
1079
+ targetText,
1080
+ modified,
1081
+ author,
1082
+ targetRef = null,
1083
+ targetEndRef = null,
1084
+ runtimeContext = null,
1085
+ options = {}
1086
+ ) {
1087
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
1088
+ if (!xmlDoc) {
1089
+ return {
1090
+ documentXml,
1091
+ hasChanges: false,
1092
+ status: 'error',
1093
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
1094
+ };
1095
+ }
1096
+
1097
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
1098
+ if (resolved?.error || !resolved?.paragraph) {
1099
+ return {
1100
+ documentXml,
1101
+ hasChanges: false,
1102
+ status: 'error',
1103
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
1104
+ };
1105
+ }
1106
+
1107
+ const firstSource = resolved.paragraph;
1108
+ let sourceParagraphs = [firstSource];
1109
+ if (targetEndRef) {
1110
+ sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
1111
+ opType: 'restore',
1112
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
1113
+ onInfo: options.onInfo,
1114
+ onWarn: options.onWarn
1115
+ });
1116
+ } else if (options.targetEndDescriptor) {
1117
+ const endResolved = resolveTargetParagraph(
1118
+ xmlDoc,
1119
+ options.targetEndDescriptor.text,
1120
+ options.targetEndDescriptor.index,
1121
+ 'restore',
1122
+ runtimeContext,
1123
+ { ...options, targetDescriptor: options.targetEndDescriptor }
1124
+ );
1125
+ const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
1126
+ const startIndex = allParagraphs.indexOf(firstSource);
1127
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
1128
+ sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
1129
+ ? allParagraphs.slice(startIndex, endIndex + 1)
1130
+ : null;
1131
+ }
1132
+ if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
1133
+ return {
1134
+ documentXml,
1135
+ hasChanges: false,
1136
+ status: 'error',
1137
+ error: {
1138
+ code: 'RESTORATION_COUNT_MISMATCH',
1139
+ message: 'The restoration range could not be resolved as a contiguous paragraph block.'
1140
+ }
1141
+ };
1142
+ }
1143
+
1144
+ const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
1145
+ if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
1146
+ return {
1147
+ documentXml,
1148
+ hasChanges: false,
1149
+ status: 'error',
1150
+ error: {
1151
+ code: 'RESTORATION_COUNT_MISMATCH',
1152
+ message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
1153
+ }
1154
+ };
1155
+ }
1156
+
1157
+ const parent = firstSource.parentNode;
1158
+ if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
1159
+ return {
1160
+ documentXml,
1161
+ hasChanges: false,
1162
+ status: 'error',
1163
+ error: {
1164
+ code: 'UNSAFE_PARAGRAPH_PLACEMENT',
1165
+ message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
1166
+ }
1167
+ };
1168
+ }
1169
+
1170
+ for (const paragraph of sourceParagraphs) {
1171
+ const structuralRefusal = getParagraphRestorationRefusal(paragraph);
1172
+ if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
1173
+ return {
1174
+ documentXml,
1175
+ hasChanges: false,
1176
+ status: 'error',
1177
+ error: structuralRefusal
1178
+ };
1179
+ }
1180
+ const state = inspectForeignDeletedParagraphTarget(paragraph, author);
1181
+ if (!state.matches) {
1182
+ return {
1183
+ documentXml,
1184
+ hasChanges: false,
1185
+ status: 'error',
1186
+ error: {
1187
+ code: 'RESTORATION_STATE_REQUIRED',
1188
+ message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
1189
+ ? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
1190
+ : 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
1191
+ ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
1192
+ }
1193
+ };
1194
+ }
1195
+ const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
1196
+ if (refusal) {
1197
+ return {
1198
+ documentXml,
1199
+ hasChanges: false,
1200
+ status: 'error',
1201
+ error: refusal
1202
+ };
1203
+ }
1204
+ }
1205
+
1206
+ const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
1207
+ const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
1208
+ const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
1209
+ && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
1210
+ if (
1211
+ hasExistingRestoration
1212
+ && existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
1213
+ ) {
1214
+ return {
1215
+ documentXml,
1216
+ hasChanges: false,
1217
+ status: 'no-op',
1218
+ warnings: ['An identical same-author paragraph restoration already exists.']
1219
+ };
1220
+ }
1221
+
1222
+ const beforeXml = serializer.serializeToString(xmlDoc);
1223
+ const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
1224
+ const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
1225
+ const existingIndexes = hasExistingRestoration
1226
+ ? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
1227
+ : [];
1228
+ const templates = [];
1229
+ const warnings = [];
1230
+ for (let index = 0; index < sourceParagraphs.length; index++) {
1231
+ const source = sourceParagraphs[index];
1232
+ warnings.push(...collectRestorationAnchorWarnings(source));
1233
+ const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
1234
+ const edited = template
1235
+ ? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
1236
+ : null;
1237
+ if (!edited) {
1238
+ return {
1239
+ documentXml,
1240
+ hasChanges: false,
1241
+ status: 'error',
1242
+ error: {
1243
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
1244
+ message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
1245
+ }
1246
+ };
1247
+ }
1248
+ templates.push(edited);
1249
+ }
1250
+
1251
+ const insertedParagraphs = templates.map(template => {
1252
+ const paragraph = xmlDoc.importNode(template, true);
1253
+ return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
1254
+ });
1255
+ const insertionPoint = lastSource.nextSibling;
1256
+ for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
1257
+ if (hasExistingRestoration) {
1258
+ for (const paragraph of existingBlock) {
1259
+ options?._mutationRemovedNodes?.push(paragraph);
1260
+ paragraph.parentNode?.removeChild(paragraph);
1261
+ }
1262
+ }
1263
+ options?._mutationLiveNodes?.push(...insertedParagraphs);
1264
+ normalizeBodySectionOrder(xmlDoc);
1265
+
1266
+ const outputXml = serializer.serializeToString(xmlDoc);
1267
+ const oracle = verifyParagraphRestorationLifecycle(
1268
+ beforeXml,
1269
+ outputXml,
1270
+ sourceStartIndex,
1271
+ existingIndexes,
1272
+ templates,
1273
+ insertedParagraphs
1274
+ );
1275
+ if (!oracle.valid) {
1276
+ return {
1277
+ documentXml,
1278
+ hasChanges: false,
1279
+ status: 'error',
1280
+ error: {
1281
+ code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
1282
+ message: oracle.message,
1283
+ stage: oracle.stage,
1284
+ ...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
1285
+ ...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
1286
+ ...(oracle.expected ? { expected: oracle.expected } : {}),
1287
+ ...(oracle.actual ? { actual: oracle.actual } : {})
1288
+ }
1289
+ };
1290
+ }
1291
+
1292
+ return {
1293
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
1294
+ hasChanges: true,
1295
+ status: 'ok',
1296
+ numberingXml: null,
1297
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
1298
+ };
1299
+ }
520
1300
 
521
1301
  async function tryExplicitDecimalHeaderListConversion({
522
1302
  xmlDoc,
@@ -819,8 +1599,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
819
1599
  error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
820
1600
  };
821
1601
  }
822
- const targetParagraph = resolved.paragraph;
823
- preprocessRedlineTargetParagraph(targetParagraph);
1602
+ const targetParagraph = resolved.paragraph;
1603
+ if (typeof modifiedText === 'string' && modifiedText.length > 0) {
1604
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
1605
+ if (resurrectionTarget.matches) {
1606
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
1607
+ return {
1608
+ documentXml,
1609
+ hasChanges: false,
1610
+ numberingXml: null,
1611
+ status: 'error',
1612
+ error: {
1613
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
1614
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
1615
+ ownerAuthor
1616
+ }
1617
+ };
1618
+ }
1619
+ }
1620
+ preprocessRedlineTargetParagraph(targetParagraph);
824
1621
  const currentParagraphText = getParagraphText(targetParagraph);
825
1622
  if (modifiedText === '') {
826
1623
  const commentIds = getCommentIdsInElement(targetParagraph);
@@ -1235,8 +2032,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1235
2032
  ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1236
2033
  : (inferredTableRangeParagraphs
1237
2034
  ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1238
- : (currentParagraphText || targetText))
1239
- );
2035
+ : (currentParagraphText || targetText))
2036
+ );
1240
2037
  const scopedXml = useTableScope
1241
2038
  ? serializer.serializeToString(containingTable)
1242
2039
  : (