@ansonlai/docx-redline-js 0.5.3 → 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.
@@ -43,6 +43,7 @@ function nonEmptyString(value) {
43
43
 
44
44
  export function getCanonicalOperationType(operation) {
45
45
  const type = operation?.type;
46
+ if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
46
47
  if (type === 'restore') return 'restore';
47
48
  if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
48
49
  if (type === 'paragraph-format') return 'paragraph-format';
@@ -95,6 +96,12 @@ export function normalizeDocumentOperation(operation) {
95
96
  operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
96
97
  captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
97
98
  operationKind: kind,
99
+ anchor: isRecord(source.anchor) ? {
100
+ exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
101
+ occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
102
+ occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
103
+ offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
104
+ } : null,
98
105
  targetDescriptor,
99
106
  targetEndDescriptor,
100
107
  target: targetDescriptor.text,
@@ -211,6 +218,39 @@ export function validateDocumentOperation(operation) {
211
218
  };
212
219
  }
213
220
 
221
+ if (normalized.operationKind === 'rejected-insert') {
222
+ if (!nonEmptyString(normalized.modified)) {
223
+ return {
224
+ valid: false,
225
+ error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
226
+ };
227
+ }
228
+ if (
229
+ !normalized.anchor
230
+ || !nonEmptyString(normalized.anchor.exactText)
231
+ || !Number.isInteger(normalized.anchor.offset)
232
+ || normalized.anchor.offset < 0
233
+ || normalized.anchor.offset > normalized.anchor.exactText.length
234
+ ) {
235
+ return {
236
+ valid: false,
237
+ error: {
238
+ code: 'INVALID_OPERATION',
239
+ message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
240
+ }
241
+ };
242
+ }
243
+ if (normalized.existingRevisions !== 'slice-cross-author') {
244
+ return {
245
+ valid: false,
246
+ error: {
247
+ code: 'INVALID_OPERATION',
248
+ message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
249
+ }
250
+ };
251
+ }
252
+ }
253
+
214
254
  if (normalized.operationKind === 'restore') {
215
255
  const validSingle = nonEmptyString(normalized.modified);
216
256
  const validRange = Array.isArray(normalized.modified)
@@ -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,9 +169,60 @@ 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
227
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
170
228
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
@@ -178,16 +236,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
178
236
  if (options?._resolutionCapture && resolved?.paragraph) {
179
237
  const paragraph = resolved.paragraph;
180
238
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
181
- Object.assign(options._resolutionCapture, {
182
- resolvedBy: resolved.resolvedBy,
183
- resolvedTarget: {
239
+ Object.assign(options._resolutionCapture, {
240
+ resolvedBy: resolved.resolvedBy,
241
+ resolvedTarget: {
184
242
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
185
243
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
186
244
  text: metadata?.text ?? getParagraphText(paragraph),
187
245
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
188
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
189
- }
190
- });
246
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
247
+ targetTextMatch: describeTargetTextMatch(
248
+ metadata?.text ?? getParagraphText(paragraph),
249
+ options?.targetDescriptor?.exactText ?? targetText
250
+ )
251
+ }
252
+ });
191
253
  }
192
254
  return resolved;
193
255
  } catch (error) {
@@ -216,14 +278,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
216
278
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
217
279
  Object.assign(options._resolutionCapture, {
218
280
  resolvedBy: resolved.resolvedBy,
219
- resolvedTarget: {
281
+ resolvedTarget: {
220
282
  index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
221
283
  paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
222
284
  text: metadata?.text ?? getParagraphText(paragraph),
223
285
  fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
224
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
225
- }
226
- });
286
+ inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
287
+ targetTextMatch: describeTargetTextMatch(
288
+ metadata?.text ?? getParagraphText(paragraph),
289
+ options?.targetDescriptor?.exactText ?? targetText
290
+ )
291
+ }
292
+ });
227
293
  }
228
294
  return resolved;
229
295
  }
@@ -260,7 +326,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
260
326
  removeProofErrNodes(targetParagraph);
261
327
  }
262
328
 
263
- function getDirectWordChild(element, localName) {
329
+ function getDirectWordChild(element, localName) {
264
330
  if (!element) return null;
265
331
  return Array.from(element.childNodes || []).find(
266
332
  node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
@@ -325,7 +391,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
325
391
 
326
392
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
327
393
  if (anchorPPr) {
328
- paragraph.appendChild(anchorPPr.cloneNode(true));
394
+ paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
329
395
  }
330
396
  ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
331
397
 
@@ -341,7 +407,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
341
407
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
342
408
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
343
409
  if (anchorRunPr) {
344
- run.appendChild(anchorRunPr.cloneNode(true));
410
+ run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
345
411
  }
346
412
 
347
413
  const textNode = createWordElement(xmlDoc, 'w:t');
@@ -445,12 +511,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
445
511
  function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
446
512
  const paragraph = createWordElement(xmlDoc, 'w:p');
447
513
  const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
448
- if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
514
+ if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
449
515
 
450
516
  const run = createWordElement(xmlDoc, 'w:r');
451
517
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
452
518
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
453
- if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
519
+ if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
454
520
 
455
521
  const textNode = createWordElement(xmlDoc, 'w:t');
456
522
  textNode.textContent = '';
@@ -463,7 +529,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
463
529
  const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
464
530
  const pPr = options.sanitizeParagraphProperties === true
465
531
  ? createSanitizedRestorationPPr(xmlDoc, paragraph)
466
- : getDirectWordChild(paragraph, 'pPr')?.cloneNode(true);
532
+ : clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
467
533
  if (pPr) wrappedParagraph.appendChild(pPr);
468
534
  if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
469
535
  if (options.trackParagraphMark === true) {
@@ -539,6 +605,71 @@ function collectNumberingIdsFromNodes(nodes) {
539
605
  return Array.from(ids);
540
606
  }
541
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
+
542
673
  const RESTORATION_PPR_ALLOWLIST = new Set([
543
674
  'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
544
675
  'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
@@ -678,13 +809,13 @@ function isInsertedParagraphByAuthor(paragraph, author) {
678
809
  return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
679
810
  }
680
811
 
681
- function precedingParagraphBlock(firstSource, count) {
812
+ function followingParagraphBlock(lastSource, count) {
682
813
  const paragraphs = [];
683
- let cursor = firstSource;
814
+ let cursor = lastSource;
684
815
  for (let i = 0; i < count; i++) {
685
- cursor = directWordParagraphSibling(cursor, 'previousSibling');
816
+ cursor = directWordParagraphSibling(cursor, 'nextSibling');
686
817
  if (!cursor) return [];
687
- paragraphs.unshift(cursor);
818
+ paragraphs.push(cursor);
688
819
  }
689
820
  return paragraphs;
690
821
  }
@@ -713,26 +844,37 @@ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingI
713
844
  if (parsed.error || !parsed.doc) return null;
714
845
  const expectedDoc = parsed.doc;
715
846
  const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
716
- const source = originalParagraphs[sourceStartIndex] || null;
717
- if (!source?.parentNode) return null;
847
+ const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
848
+ if (!lastSource?.parentNode) return null;
718
849
 
719
850
  for (const index of [...existingIndexes].sort((a, b) => b - a)) {
720
851
  const paragraph = originalParagraphs[index];
721
852
  paragraph?.parentNode?.removeChild(paragraph);
722
853
  }
854
+ const insertionPoint = lastSource.nextSibling;
723
855
  for (const template of templates) {
724
- source.parentNode.insertBefore(expectedDoc.importNode(template, true), source);
856
+ lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
725
857
  }
726
858
  return createSerializer().serializeToString(expectedDoc);
727
859
  }
728
860
 
729
- function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates) {
730
- const validation = validateRedlineOoxml(outputXml);
731
- if (!validation.valid) {
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) {
732
871
  return {
733
872
  valid: false,
734
873
  stage: 'validation',
735
- message: validation.issues.filter(issue => issue.severity === 'error').map(issue => issue.message).join(' ')
874
+ code: 'GENERATED_OOXML_INVALID',
875
+ generatedIssues,
876
+ envelopeIssues,
877
+ message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
736
878
  };
737
879
  }
738
880
 
@@ -758,6 +900,176 @@ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIn
758
900
  return { valid: true };
759
901
  }
760
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
+
761
1073
  /**
762
1074
  * Materializes an explicit counterproposal for one or more paragraphs wholly
763
1075
  * deleted (content and paragraph mark) by another author.
@@ -891,7 +1203,8 @@ export async function restoreDeletedParagraphByExactText(
891
1203
  }
892
1204
  }
893
1205
 
894
- const existingBlock = precedingParagraphBlock(firstSource, sourceParagraphs.length);
1206
+ const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
1207
+ const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
895
1208
  const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
896
1209
  && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
897
1210
  if (
@@ -939,7 +1252,8 @@ export async function restoreDeletedParagraphByExactText(
939
1252
  const paragraph = xmlDoc.importNode(template, true);
940
1253
  return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
941
1254
  });
942
- for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, firstSource);
1255
+ const insertionPoint = lastSource.nextSibling;
1256
+ for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
943
1257
  if (hasExistingRestoration) {
944
1258
  for (const paragraph of existingBlock) {
945
1259
  options?._mutationRemovedNodes?.push(paragraph);
@@ -955,7 +1269,8 @@ export async function restoreDeletedParagraphByExactText(
955
1269
  outputXml,
956
1270
  sourceStartIndex,
957
1271
  existingIndexes,
958
- templates
1272
+ templates,
1273
+ insertedParagraphs
959
1274
  );
960
1275
  if (!oracle.valid) {
961
1276
  return {
@@ -963,9 +1278,11 @@ export async function restoreDeletedParagraphByExactText(
963
1278
  hasChanges: false,
964
1279
  status: 'error',
965
1280
  error: {
966
- code: 'PATCH_ROUNDTRIP_MISMATCH',
1281
+ code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
967
1282
  message: oracle.message,
968
1283
  stage: oracle.stage,
1284
+ ...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
1285
+ ...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
969
1286
  ...(oracle.expected ? { expected: oracle.expected } : {}),
970
1287
  ...(oracle.actual ? { actual: oracle.actual } : {})
971
1288
  }
@@ -1715,8 +2032,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
1715
2032
  ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1716
2033
  : (inferredTableRangeParagraphs
1717
2034
  ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1718
- : (currentParagraphText || targetText))
1719
- );
2035
+ : (currentParagraphText || targetText))
2036
+ );
1720
2037
  const scopedXml = useTableScope
1721
2038
  ? serializer.serializeToString(containingTable)
1722
2039
  : (
@@ -418,7 +418,7 @@ export function preflightOperations(documentXml, operations, author, options = {
418
418
  }
419
419
 
420
420
  for (const [targetIndex, targetResults] of byTarget) {
421
- const redlines = targetResults.filter(result => ['redline', 'restore'].includes(result.operationType));
421
+ const redlines = targetResults.filter(result => ['redline', 'restore', 'rejected-insert'].includes(result.operationType));
422
422
  const highlights = targetResults.filter(result => result.operationType === 'highlight');
423
423
  const target = targetResults[0].resolvedTarget;
424
424
  if (redlines.length > 1) {
@@ -23,6 +23,12 @@ export interface InsertionAffinity {
23
23
  comment?: 'inside' | 'outside';
24
24
  }
25
25
 
26
+ export interface RejectedTextInsertionAnchor {
27
+ exactText: string;
28
+ occurrence?: number;
29
+ offset: number;
30
+ }
31
+
26
32
  export interface DocumentOperationBase {
27
33
  operationId?: string;
28
34
  captureKey?: string;
@@ -41,6 +47,8 @@ export interface RedlineDocumentOperation extends DocumentOperationBase {
41
47
  structuredContent?: boolean;
42
48
  targetEnd?: ParagraphTargetDescriptor;
43
49
  targetEndRef?: number | string | null;
50
+ /** Required when type is insert and target.revisionView is rejected. */
51
+ anchor?: RejectedTextInsertionAnchor;
44
52
  }
45
53
 
46
54
  export interface RestoreDocumentOperation extends DocumentOperationBase {
@@ -124,6 +132,16 @@ export interface ResolvedDocumentTarget {
124
132
  text: string;
125
133
  fingerprint?: string;
126
134
  inTable?: boolean;
135
+ targetTextMatch?: {
136
+ mode: 'exact' | 'space_equivalent' | 'normalized';
137
+ differences?: Array<{
138
+ offset: number;
139
+ sourceCodePoint: string;
140
+ requestedCodePoint: string;
141
+ }>;
142
+ sourceExcerpt?: string;
143
+ requestedExcerpt?: string;
144
+ };
127
145
  }
128
146
 
129
147
  export interface ResolvedCommentAnchor {