@ansonlai/docx-redline-js 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +82 -667
  2. package/ARCHITECTURE.md +51 -4
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +176 -39
  5. package/core/paragraph-revision-safety.js +10 -8
  6. package/core/paragraph-targeting.js +14 -2
  7. package/core/redline-validation.js +7 -4
  8. package/core/revision-cloning.js +21 -0
  9. package/core/validation-delta.js +23 -0
  10. package/dist/docx-redline-js.esm.js +275 -45
  11. package/dist/docx-redline-js.esm.js.map +3 -3
  12. package/dist/docx-redline-js.esm.min.js +82 -82
  13. package/dist/docx-redline-js.esm.min.js.map +4 -4
  14. package/docs/AGENT_FAST_START.md +59 -0
  15. package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
  16. package/docs/TESTING.md +20 -1
  17. package/docs/schemas/document-operations.schema.json +16 -2
  18. package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
  19. package/engine/oxml-engine.js +80 -13
  20. package/engine/run-builders.js +5 -15
  21. package/engine/surgical-mode.js +148 -3
  22. package/engine/surgical-run-splitting.js +19 -7
  23. package/engine/surgical-spans.js +2 -1
  24. package/index.d.ts +17 -1
  25. package/node/cli.js +235 -36
  26. package/node/docx-document.js +137 -83
  27. package/node/index.d.ts +6 -2
  28. package/package.json +10 -3
  29. package/pipeline/diff-engine.js +15 -0
  30. package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
  31. package/services/batch-operation-orchestrator.js +215 -120
  32. package/services/document-inspection.js +5 -3
  33. package/services/document-operation-applier.js +99 -36
  34. package/services/document-operation-contract.js +50 -6
  35. package/services/document-operation-mutations.js +404 -41
  36. package/services/document-operation-session.js +4 -0
  37. package/services/error-recovery.js +174 -0
  38. package/services/operation-batch-compiler.js +394 -0
  39. package/services/operation-preflight.js +91 -72
  40. package/services/standalone-operation-runner.d.ts +35 -1
  41. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
  42. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
  43. package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
  44. package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
  45. package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
  46. package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
  47. package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
  48. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
  49. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
  50. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
  51. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
  52. package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
  53. package/docs/test-comparison-dashboard.html +0 -4338
  54. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
  55. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
  56. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
  57. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
  58. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
  59. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
@@ -27,6 +27,13 @@ import {
27
27
  } from '../core/paragraph-revision-safety.js';
28
28
  import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
29
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';
30
37
  import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
31
38
  import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
32
39
  import { injectCommentsIntoOoxml } from './comment-engine.js';
@@ -162,15 +169,91 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
162
169
  };
163
170
  }
164
171
 
165
- function getParagraphText(paragraph) {
166
- return getParagraphTextFromOxml(paragraph);
167
- }
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
+ }
168
226
 
169
- function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
227
+ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
170
228
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
171
229
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
172
230
  const session = options?._documentOperationSession || null;
173
- const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
231
+ const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
232
+
233
+ if (options?._compiledSourceId && options?._sourceTargetRegistry) {
234
+ const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
235
+ if (resolved?.error || !resolved?.paragraph) return resolved;
236
+ resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
237
+ if (options?._resolutionCapture) {
238
+ const paragraph = resolved.paragraph;
239
+ const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
240
+ Object.assign(options._resolutionCapture, {
241
+ resolvedBy: resolved.resolvedBy,
242
+ resolvedTarget: {
243
+ index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
244
+ paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
245
+ text: metadata?.text ?? getParagraphText(paragraph),
246
+ fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
247
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
248
+ targetTextMatch: describeTargetTextMatch(
249
+ metadata?.text ?? getParagraphText(paragraph),
250
+ options?.targetDescriptor?.exactText ?? targetText
251
+ )
252
+ }
253
+ });
254
+ }
255
+ return resolved;
256
+ }
174
257
 
175
258
  if (options?.targetDescriptor?.captureRef) {
176
259
  try {
@@ -178,16 +261,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
178
261
  if (options?._resolutionCapture && resolved?.paragraph) {
179
262
  const paragraph = resolved.paragraph;
180
263
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
181
- Object.assign(options._resolutionCapture, {
182
- resolvedBy: resolved.resolvedBy,
183
- resolvedTarget: {
264
+ Object.assign(options._resolutionCapture, {
265
+ resolvedBy: resolved.resolvedBy,
266
+ resolvedTarget: {
184
267
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
185
268
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
186
269
  text: metadata?.text ?? getParagraphText(paragraph),
187
270
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
188
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
189
- }
190
- });
271
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
272
+ targetTextMatch: describeTargetTextMatch(
273
+ metadata?.text ?? getParagraphText(paragraph),
274
+ options?.targetDescriptor?.exactText ?? targetText
275
+ )
276
+ }
277
+ });
191
278
  }
192
279
  return resolved;
193
280
  } catch (error) {
@@ -216,14 +303,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
216
303
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
217
304
  Object.assign(options._resolutionCapture, {
218
305
  resolvedBy: resolved.resolvedBy,
219
- resolvedTarget: {
306
+ resolvedTarget: {
220
307
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
221
308
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
222
309
  text: metadata?.text ?? getParagraphText(paragraph),
223
310
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
224
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
225
- }
226
- });
311
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
312
+ targetTextMatch: describeTargetTextMatch(
313
+ metadata?.text ?? getParagraphText(paragraph),
314
+ options?.targetDescriptor?.exactText ?? targetText
315
+ )
316
+ }
317
+ });
227
318
  }
228
319
  return resolved;
229
320
  }
@@ -260,7 +351,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
260
351
  removeProofErrNodes(targetParagraph);
261
352
  }
262
353
 
263
- function getDirectWordChild(element, localName) {
354
+ function getDirectWordChild(element, localName) {
264
355
  if (!element) return null;
265
356
  return Array.from(element.childNodes || []).find(
266
357
  node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
@@ -325,7 +416,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
325
416
 
326
417
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
327
418
  if (anchorPPr) {
328
- paragraph.appendChild(anchorPPr.cloneNode(true));
419
+ paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
329
420
  }
330
421
  ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
331
422
 
@@ -341,7 +432,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
341
432
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
342
433
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
343
434
  if (anchorRunPr) {
344
- run.appendChild(anchorRunPr.cloneNode(true));
435
+ run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
345
436
  }
346
437
 
347
438
  const textNode = createWordElement(xmlDoc, 'w:t');
@@ -445,12 +536,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
445
536
  function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
446
537
  const paragraph = createWordElement(xmlDoc, 'w:p');
447
538
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
448
- if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
539
+ if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
449
540
 
450
541
  const run = createWordElement(xmlDoc, 'w:r');
451
542
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
452
543
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
453
- if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
544
+ if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
454
545
 
455
546
  const textNode = createWordElement(xmlDoc, 'w:t');
456
547
  textNode.textContent = '';
@@ -463,7 +554,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
463
554
  const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
464
555
  const pPr = options.sanitizeParagraphProperties === true
465
556
  ? createSanitizedRestorationPPr(xmlDoc, paragraph)
466
- : getDirectWordChild(paragraph, 'pPr')?.cloneNode(true);
557
+ : clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
467
558
  if (pPr) wrappedParagraph.appendChild(pPr);
468
559
  if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
469
560
  if (options.trackParagraphMark === true) {
@@ -539,6 +630,71 @@ function collectNumberingIdsFromNodes(nodes) {
539
630
  return Array.from(ids);
540
631
  }
541
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
+
542
698
  const RESTORATION_PPR_ALLOWLIST = new Set([
543
699
  'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
544
700
  'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
@@ -678,13 +834,13 @@ function isInsertedParagraphByAuthor(paragraph, author) {
678
834
  return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
679
835
  }
680
836
 
681
- function precedingParagraphBlock(firstSource, count) {
837
+ function followingParagraphBlock(lastSource, count) {
682
838
  const paragraphs = [];
683
- let cursor = firstSource;
839
+ let cursor = lastSource;
684
840
  for (let i = 0; i < count; i++) {
685
- cursor = directWordParagraphSibling(cursor, 'previousSibling');
841
+ cursor = directWordParagraphSibling(cursor, 'nextSibling');
686
842
  if (!cursor) return [];
687
- paragraphs.unshift(cursor);
843
+ paragraphs.push(cursor);
688
844
  }
689
845
  return paragraphs;
690
846
  }
@@ -713,26 +869,37 @@ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingI
713
869
  if (parsed.error || !parsed.doc) return null;
714
870
  const expectedDoc = parsed.doc;
715
871
  const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
716
- const source = originalParagraphs[sourceStartIndex] || null;
717
- if (!source?.parentNode) return null;
872
+ const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
873
+ if (!lastSource?.parentNode) return null;
718
874
 
719
875
  for (const index of [...existingIndexes].sort((a, b) => b - a)) {
720
876
  const paragraph = originalParagraphs[index];
721
877
  paragraph?.parentNode?.removeChild(paragraph);
722
878
  }
879
+ const insertionPoint = lastSource.nextSibling;
723
880
  for (const template of templates) {
724
- source.parentNode.insertBefore(expectedDoc.importNode(template, true), source);
881
+ lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
725
882
  }
726
883
  return createSerializer().serializeToString(expectedDoc);
727
884
  }
728
885
 
729
- function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates) {
730
- const validation = validateRedlineOoxml(outputXml);
731
- if (!validation.valid) {
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) {
732
896
  return {
733
897
  valid: false,
734
898
  stage: 'validation',
735
- message: validation.issues.filter(issue => issue.severity === 'error').map(issue => issue.message).join(' ')
899
+ code: 'GENERATED_OOXML_INVALID',
900
+ generatedIssues,
901
+ envelopeIssues,
902
+ message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
736
903
  };
737
904
  }
738
905
 
@@ -758,6 +925,176 @@ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIn
758
925
  return { valid: true };
759
926
  }
760
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
+
761
1098
  /**
762
1099
  * Materializes an explicit counterproposal for one or more paragraphs wholly
763
1100
  * deleted (content and paragraph mark) by another author.
@@ -794,7 +1131,22 @@ export async function restoreDeletedParagraphByExactText(
794
1131
 
795
1132
  const firstSource = resolved.paragraph;
796
1133
  let sourceParagraphs = [firstSource];
797
- if (targetEndRef) {
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) {
798
1150
  sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
799
1151
  opType: 'restore',
800
1152
  targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
@@ -808,7 +1160,11 @@ export async function restoreDeletedParagraphByExactText(
808
1160
  options.targetEndDescriptor.index,
809
1161
  'restore',
810
1162
  runtimeContext,
811
- { ...options, targetDescriptor: options.targetEndDescriptor }
1163
+ {
1164
+ ...options,
1165
+ targetDescriptor: options.targetEndDescriptor,
1166
+ _compiledSourceId: options._compiledSourceEndId || null
1167
+ }
812
1168
  );
813
1169
  const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
814
1170
  const startIndex = allParagraphs.indexOf(firstSource);
@@ -891,7 +1247,8 @@ export async function restoreDeletedParagraphByExactText(
891
1247
  }
892
1248
  }
893
1249
 
894
- const existingBlock = precedingParagraphBlock(firstSource, sourceParagraphs.length);
1250
+ const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
1251
+ const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
895
1252
  const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
896
1253
  && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
897
1254
  if (
@@ -939,7 +1296,8 @@ export async function restoreDeletedParagraphByExactText(
939
1296
  const paragraph = xmlDoc.importNode(template, true);
940
1297
  return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
941
1298
  });
942
- for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, firstSource);
1299
+ const insertionPoint = lastSource.nextSibling;
1300
+ for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
943
1301
  if (hasExistingRestoration) {
944
1302
  for (const paragraph of existingBlock) {
945
1303
  options?._mutationRemovedNodes?.push(paragraph);
@@ -955,7 +1313,8 @@ export async function restoreDeletedParagraphByExactText(
955
1313
  outputXml,
956
1314
  sourceStartIndex,
957
1315
  existingIndexes,
958
- templates
1316
+ templates,
1317
+ insertedParagraphs
959
1318
  );
960
1319
  if (!oracle.valid) {
961
1320
  return {
@@ -963,9 +1322,11 @@ export async function restoreDeletedParagraphByExactText(
963
1322
  hasChanges: false,
964
1323
  status: 'error',
965
1324
  error: {
966
- code: 'PATCH_ROUNDTRIP_MISMATCH',
1325
+ code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
967
1326
  message: oracle.message,
968
1327
  stage: oracle.stage,
1328
+ ...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
1329
+ ...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
969
1330
  ...(oracle.expected ? { expected: oracle.expected } : {}),
970
1331
  ...(oracle.actual ? { actual: oracle.actual } : {})
971
1332
  }
@@ -1352,7 +1713,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1352
1713
  status: 'error',
1353
1714
  error: {
1354
1715
  code: 'EXISTING_REVISIONS',
1355
- message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
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
1356
1719
  }
1357
1720
  };
1358
1721
  }
@@ -1715,8 +2078,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1715
2078
  ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1716
2079
  : (inferredTableRangeParagraphs
1717
2080
  ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1718
- : (currentParagraphText || targetText))
1719
- );
2081
+ : (currentParagraphText || targetText))
2082
+ );
1720
2083
  const scopedXml = useTableScope
1721
2084
  ? serializer.serializeToString(containingTable)
1722
2085
  : (
@@ -42,6 +42,7 @@ export class DocumentOperationSession {
42
42
  this.executionOrder = [];
43
43
  this.authorsUsed = new Set();
44
44
  this.captureTable = new Map();
45
+ this.sourceTargetRegistry = null;
45
46
  this.nextCaptureParaId = 1;
46
47
  this.receiptCollector = new ReceiptCollector();
47
48
 
@@ -111,6 +112,7 @@ export class DocumentOperationSession {
111
112
  commentsXmlMode: this.commentsXmlMode,
112
113
  commentsExtendedXmlMode: this.commentsExtendedXmlMode,
113
114
  captureTable: cloneCaptureTable(this.captureTable),
115
+ sourceTargetRegistry: this.sourceTargetRegistry?.createSavepoint?.(this.document) || null,
114
116
  nextCaptureParaId: this.nextCaptureParaId,
115
117
  receiptCollector: this.receiptCollector ? this.receiptCollector.createSavepoint() : null
116
118
  };
@@ -127,6 +129,7 @@ export class DocumentOperationSession {
127
129
  this.commentsXmlMode = savepoint.commentsXmlMode || 'merge';
128
130
  this.commentsExtendedXmlMode = savepoint.commentsExtendedXmlMode || 'merge';
129
131
  this.captureTable = savepoint.captureTable ? cloneCaptureTable(savepoint.captureTable) : new Map();
132
+ this.sourceTargetRegistry?.restoreSavepoint?.(this.document, savepoint.sourceTargetRegistry);
130
133
  if (typeof savepoint.nextCaptureParaId === 'number') {
131
134
  this.nextCaptureParaId = savepoint.nextCaptureParaId;
132
135
  }
@@ -160,6 +163,7 @@ export class DocumentOperationSession {
160
163
  this.hasChanges = false;
161
164
  this.documentHasChanges = false;
162
165
  this.captureTable.clear();
166
+ this.sourceTargetRegistry = null;
163
167
  this.receiptCollector?.clear();
164
168
  return this.originalDocumentXml;
165
169
  }