@ansonlai/docx-redline-js 0.5.0 → 0.5.2

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.
@@ -61,7 +61,7 @@ function getCommentIdsInOoxml(node) {
61
61
  * @param {Object} [options={}] - Options
62
62
  * @param {string} [options.author='AI'] - Author for track changes
63
63
  * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
64
- * @param {'merge-same-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
64
+ * @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
65
65
  * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
66
66
  * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
67
67
  * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
@@ -82,7 +82,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
82
82
  const finalize = result => {
83
83
  const withStatus = { ...result };
84
84
  if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
85
- if (existingRevisionsPolicy === 'merge-same-author') {
85
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
86
86
  withStatus.oxml = workingOoxml;
87
87
  withStatus.hasChanges = true;
88
88
  withStatus.warnings = [
@@ -141,7 +141,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
141
141
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
142
142
 
143
143
  if (containsTrackedChanges(xmlDoc)) {
144
- if (existingRevisionsPolicy === 'merge-same-author') {
144
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
145
145
  const authors = getTrackedChangeAuthors(xmlDoc);
146
146
  const currentAuthor = String(author || '').trim().toLowerCase();
147
147
  const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
@@ -202,8 +202,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
202
202
  ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
203
203
  : '';
204
204
  originalText = baselineText;
205
- } else {
206
- log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
205
+ } else if (existingRevisionsPolicy === 'merge-same-author') {
206
+ log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
207
207
  return finalize({
208
208
  oxml: inputOoxml,
209
209
  hasChanges: false,
@@ -213,8 +213,24 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
213
213
  message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
214
214
  }
215
215
  });
216
- }
217
- } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
216
+ } else {
217
+ const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
218
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
219
+ });
220
+ if (hasMoveRevision) {
221
+ return finalize({
222
+ oxml: inputOoxml,
223
+ hasChanges: false,
224
+ status: 'error',
225
+ error: {
226
+ code: 'UNSAFE_REVISION_NESTING',
227
+ message: 'Cross-author slicing does not support pending move revisions.'
228
+ }
229
+ });
230
+ }
231
+ log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
232
+ }
233
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
218
234
  log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
219
235
  const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
220
236
  if (accepted.status === 'error') return finalize(accepted);
@@ -291,7 +307,9 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
291
307
  }
292
308
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
293
309
 
294
- const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
310
+ const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
311
+ ? cleanModifiedText !== originalText
312
+ : cleanModifiedText.trim() !== originalText.trim();
295
313
  const hasFormatHints = formatHints.length > 0;
296
314
 
297
315
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
@@ -453,7 +471,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
453
471
  const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
454
472
  const tableCellContext = initialTableCellContext;
455
473
 
456
- log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
474
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
475
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
457
476
 
458
477
  try {
459
478
  if (isMarkdownTable && !hasTables) {
@@ -466,8 +485,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
466
485
  recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
467
486
  return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
468
487
  }
469
- if (hasTables) {
470
- recordRouteSelection(options, 'surgical', { tableScoped: true });
488
+ if (usesSurgicalTextMode) {
489
+ recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
471
490
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
472
491
  ? tableCellContext.targetParagraph
473
492
  : null;
@@ -475,7 +494,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
475
494
  log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
476
495
  }
477
496
 
478
- const result = applySurgicalMode(
497
+ const result = applySurgicalMode(
479
498
  xmlDoc,
480
499
  originalText,
481
500
  cleanModifiedText,
@@ -485,10 +504,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
485
504
  generateRedlines,
486
505
  surgicalTarget,
487
506
  {},
488
- options
489
- );
490
-
491
- if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
507
+ options
508
+ );
509
+
510
+ if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
511
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
512
+ }
513
+
514
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
492
515
  log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
493
516
  return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
494
517
  }
@@ -12,7 +12,8 @@ import {
12
12
  getRunContentPieces,
13
13
  getRunTextLength,
14
14
  insertRunPiecesBefore,
15
- sliceRunPieces
15
+ sliceRunPieces,
16
+ splitTrackChangeCarrier
16
17
  } from './surgical-run-splitting.js';
17
18
  import {
18
19
  findContainingSpan,
@@ -86,8 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
86
87
  spansByRun.get(span.runElement).push(span);
87
88
  });
88
89
 
89
- let changed = false;
90
- let usedDelMetadata = false;
90
+ const records = [];
91
91
  spansByRun.forEach((runSpans, runElement) => {
92
92
  const parent = runElement.parentNode;
93
93
  if (!parent) return;
@@ -111,31 +111,104 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
111
111
 
112
112
  if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
113
113
 
114
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false);
115
- const deletedPieces = sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true);
116
- const afterPieces = sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false);
114
+ records.push({
115
+ runElement,
116
+ parent,
117
+ rPr: runSpans[0].rPr,
118
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
119
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
120
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
121
+ globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
122
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
123
+ carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
124
+ });
125
+ });
117
126
 
118
- insertRunPiecesBefore(xmlDoc, parent, runElement, beforePieces, runSpans[0].rPr);
127
+ const groups = [];
128
+ for (const record of records) {
129
+ const previousGroup = groups[groups.length - 1];
130
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
131
+ if (
132
+ previousRecord
133
+ && previousRecord.parent === record.parent
134
+ && nextElementSibling(previousRecord.runElement) === record.runElement
135
+ ) {
136
+ previousGroup.push(record);
137
+ } else {
138
+ groups.push([record]);
139
+ }
140
+ }
119
141
 
120
- if (generateRedlines && deletedPieces.length > 0) {
121
- const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
142
+ let changed = false;
143
+ let usedDelMetadata = false;
144
+ for (const group of groups) {
145
+ const firstRecord = group[0];
146
+ let delWrapper = null;
147
+ if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
122
148
  const metadata = revisionMetadata
123
- ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata)
149
+ ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
124
150
  : null;
125
151
  usedDelMetadata = true;
126
- const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author, metadata);
127
- parent.insertBefore(delWrapper, runElement);
152
+ delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
128
153
  }
129
154
 
130
- insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
131
- parent.removeChild(runElement);
132
- changed = true;
133
- });
155
+ for (const record of group) {
156
+ const { parent, runElement } = record;
157
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
158
+ if (delWrapper && record === firstRecord) {
159
+ parent.insertBefore(delWrapper, runElement);
160
+ }
161
+ if (delWrapper && record.deletedPieces.length > 0) {
162
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
+ }
164
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ parent.removeChild(runElement);
166
+ changed = true;
167
+ }
168
+
169
+ const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
170
+ const groupEnd = Math.max(...group.map(record => record.globalEnd));
171
+ if (carrier && groupEnd === endPos) {
172
+ const carrierStart = firstRecord.carrierGlobalStart;
173
+ const deletedBeforeEnd = group
174
+ .filter(record => record.globalStart < endPos)
175
+ .reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
176
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
177
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
178
+ spanIndex.revisionInsertionAnchors.set(endPos, {
179
+ carrier,
180
+ splitOffset: currentOffset,
181
+ rPr: firstRecord.rPr
182
+ });
183
+ }
184
+ }
134
185
 
135
186
  return changed;
136
187
  }
137
188
 
138
- export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
189
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
190
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
191
+ if (
192
+ mutationAnchor
193
+ && existingRevisions === 'slice-cross-author'
194
+ && isConnected(mutationAnchor.carrier)
195
+ && isForeignInsertion(mutationAnchor.carrier, author)
196
+ ) {
197
+ spanIndex.revisionInsertionAnchors.delete(pos);
198
+ return spliceInsertionAtCarrierOffset(
199
+ xmlDoc,
200
+ mutationAnchor.carrier,
201
+ mutationAnchor.splitOffset,
202
+ text,
203
+ mutationAnchor.rPr,
204
+ author,
205
+ formatHints,
206
+ insertOffset,
207
+ generateRedlines,
208
+ revisionMetadata
209
+ );
210
+ }
211
+
139
212
  if (!affinity) {
140
213
  let targetSpan = findContainingSpan(spanIndex, pos);
141
214
 
@@ -164,6 +237,31 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
164
237
  return true;
165
238
  }
166
239
 
240
+ const generateNestedRevision = !(
241
+ generateRedlines
242
+ && existingRevisions === 'slice-cross-author'
243
+ && isSameAuthorInsertion(parent, author)
244
+ );
245
+
246
+ if (
247
+ generateRedlines
248
+ && existingRevisions === 'slice-cross-author'
249
+ && isForeignInsertion(parent, author)
250
+ ) {
251
+ return spliceInsertionAtCarrierOffset(
252
+ xmlDoc,
253
+ parent,
254
+ getCarrierSplitOffset(spanIndex, parent, pos),
255
+ text,
256
+ targetSpan.rPr,
257
+ author,
258
+ formatHints,
259
+ insertOffset,
260
+ generateRedlines,
261
+ revisionMetadata
262
+ );
263
+ }
264
+
167
265
  const pieces = getRunContentPieces(targetSpan.runElement);
168
266
  const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
169
267
  const localInsertPos = targetPiece
@@ -175,14 +273,14 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
175
273
  const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
176
274
 
177
275
  insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
178
- insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
276
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
179
277
  insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
180
278
  parent.removeChild(targetSpan.runElement);
181
279
  return true;
182
280
  }
183
281
 
184
282
  const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
185
- insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
283
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
186
284
  return true;
187
285
  }
188
286
 
@@ -357,10 +455,96 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
357
455
  }
358
456
  }
359
457
 
458
+ if (
459
+ generateRedlines
460
+ && existingRevisions === 'slice-cross-author'
461
+ && isForeignInsertion(parent, author)
462
+ ) {
463
+ return spliceInsertionAtCarrierOffset(
464
+ xmlDoc,
465
+ parent,
466
+ getCarrierSplitOffset(spanIndex, parent, pos),
467
+ text,
468
+ baseRPr,
469
+ author,
470
+ formatHints,
471
+ insertOffset,
472
+ generateRedlines,
473
+ revisionMetadata
474
+ );
475
+ }
476
+
360
477
  insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
361
478
  return true;
362
479
  }
363
480
 
481
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
482
+ const parent = carrier.parentNode;
483
+ if (!parent) return false;
484
+
485
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
486
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
487
+ insertTextRuns(
488
+ xmlDoc,
489
+ parent,
490
+ carrier,
491
+ text,
492
+ withoutRunPropertyChanges(baseRPr),
493
+ author,
494
+ formatHints,
495
+ insertOffset,
496
+ generateRedlines,
497
+ revisionMetadata
498
+ );
499
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
500
+ parent.removeChild(carrier);
501
+ return true;
502
+ }
503
+
504
+ function withoutRunPropertyChanges(rPr) {
505
+ if (!rPr) return null;
506
+ const clone = rPr.cloneNode(true);
507
+ const changes = Array.from(clone.getElementsByTagName?.('*') || [])
508
+ .filter(node => isWordElement(node, 'rPrChange'));
509
+ changes.forEach(node => node.parentNode?.removeChild(node));
510
+ return clone;
511
+ }
512
+
513
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
514
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
515
+ const carrierLength = spanIndex.spans
516
+ .filter(span => span.runElement?.parentNode === carrier)
517
+ .reduce((length, span) => length + (span.charEnd - span.charStart), 0);
518
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
519
+ }
520
+
521
+ function getCarrierGlobalStart(spanIndex, carrier) {
522
+ const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
523
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
524
+ }
525
+
526
+ function isForeignInsertion(node, author) {
527
+ if (!isWordElement(node, 'ins')) return false;
528
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
529
+ return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
530
+ }
531
+
532
+ function isSameAuthorInsertion(node, author) {
533
+ if (!isWordElement(node, 'ins')) return false;
534
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
535
+ return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
536
+ }
537
+
538
+ function nextElementSibling(node) {
539
+ let sibling = node?.nextSibling || null;
540
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
541
+ return sibling;
542
+ }
543
+
544
+ function isConnected(node) {
545
+ return !!node?.parentNode;
546
+ }
547
+
364
548
  function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
365
549
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
366
550
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
9
- import { computeWordDiffs } from '../pipeline/diff-engine.js';
9
+ import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
10
10
  import { getDocumentParagraphs } from './format-extraction.js';
11
11
  import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
12
12
  import {
@@ -16,8 +16,9 @@ import {
16
16
  } from './surgical-diff-application.js';
17
17
  import { withOoxmlSourceType } from '../core/word-xml.js';
18
18
  import { createReplacementRevisionEvent } from '../core/types.js';
19
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
19
20
 
20
- function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
21
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
21
22
  const spans = [];
22
23
  forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
23
24
  if (spans.length === 0) return { safe: false };
@@ -32,7 +33,10 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
32
33
 
33
34
  // Parent container itself cannot be an existing revision or unsupported container
34
35
  const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
35
- if (['hyperlink', 'sdt', 'ins', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)) {
36
+ if (
37
+ ['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
38
+ || (parentLocal === 'ins' && !allowInsertionCarrier)
39
+ ) {
36
40
  return { safe: false, structuralBoundary: true };
37
41
  }
38
42
 
@@ -108,7 +112,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
108
112
  : getDocumentParagraphs(xmlDoc);
109
113
 
110
114
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
111
- const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
115
+ const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
116
+ ? computeInsertionOnlyDiffs(fullText, modifiedText)
117
+ : null;
118
+ const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
112
119
  const spanIndex = buildSpanIndex(textSpans);
113
120
  const pairReplacements = options.pairReplacements === true;
114
121
  const warnings = [];
@@ -148,8 +155,13 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
148
155
  if (pairReplacements && generateRedlines && hasNextInsert) {
149
156
  const nextText = diffs[i + 1][1];
150
157
  const textWithoutNewlines = nextText.replace(/\n/g, ' ');
151
- if (textWithoutNewlines.trim().length > 0) {
152
- const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
158
+ if (textWithoutNewlines.length > 0) {
159
+ const checkResult = checkSafeAdjacencyForPairing(
160
+ spanIndex,
161
+ originalPos,
162
+ originalPos + text.length,
163
+ options?.existingRevisions === 'slice-cross-author'
164
+ );
153
165
  if (checkResult.safe) {
154
166
  const event = createReplacementRevisionEvent(author, xmlDoc);
155
167
  delMetadata = { id: event.deletionId, author: event.author, date: event.date };
@@ -170,8 +182,8 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
170
182
  i++;
171
183
  const [, nextText] = diffs[i];
172
184
  const textWithoutNewlines = nextText.replace(/\n/g, ' ');
173
- if (textWithoutNewlines.trim().length > 0) {
174
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
185
+ if (textWithoutNewlines.length > 0) {
186
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
175
187
  if (insertResult && typeof insertResult === 'object' && insertResult.error) {
176
188
  return withOoxmlSourceType({
177
189
  oxml: serializer.serializeToString(xmlDoc),
@@ -188,8 +200,8 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
188
200
  }
189
201
  } else if (op === 1) {
190
202
  const textWithoutNewlines = text.replace(/\n/g, ' ');
191
- if (textWithoutNewlines.trim().length > 0) {
192
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
203
+ if (textWithoutNewlines.length > 0) {
204
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
193
205
  if (insertResult && typeof insertResult === 'object' && insertResult.error) {
194
206
  return withOoxmlSourceType({
195
207
  oxml: serializer.serializeToString(xmlDoc),
@@ -206,9 +218,42 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
206
218
  }
207
219
  }
208
220
 
221
+ const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
222
+ const expectedText = String(modifiedText).replace(/\r\n/g, '\n');
223
+ if (options.existingRevisions === 'slice-cross-author' && actualText !== expectedText) {
224
+ const mismatchOffset = firstMismatchOffset(expectedText, actualText);
225
+ return withOoxmlSourceType({
226
+ oxml: serializer.serializeToString(xmlDoc),
227
+ hasChanges: false,
228
+ status: 'error',
229
+ error: {
230
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
231
+ message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
232
+ mismatchOffset,
233
+ expectedExcerpt: excerptAt(expectedText, mismatchOffset),
234
+ actualExcerpt: excerptAt(actualText, mismatchOffset)
235
+ },
236
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
237
+ });
238
+ }
239
+
209
240
  return withOoxmlSourceType({
210
241
  oxml: serializer.serializeToString(xmlDoc),
211
242
  hasChanges,
212
243
  ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
213
244
  });
214
245
  }
246
+
247
+ function firstMismatchOffset(expected, actual) {
248
+ const limit = Math.min(expected.length, actual.length);
249
+ for (let index = 0; index < limit; index++) {
250
+ if (expected[index] !== actual[index]) return index;
251
+ }
252
+ return limit;
253
+ }
254
+
255
+ function excerptAt(text, offset, radius = 40) {
256
+ const start = Math.max(0, offset - radius);
257
+ const end = Math.min(text.length, offset + radius);
258
+ return text.slice(start, end);
259
+ }
@@ -1,6 +1,15 @@
1
1
  import { createWordElement, isWordElement } from '../core/word-xml.js';
2
+ import {
3
+ NS_W,
4
+ RevisionIdAllocator,
5
+ createRevisionIdAllocator,
6
+ getRevisionIdAllocatorForDocument
7
+ } from '../core/types.js';
8
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
2
9
  import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
3
10
 
11
+ const TRACK_CHANGE_CARRIERS = new Set(['ins']);
12
+
4
13
  export function getRunContentPieces(runElement) {
5
14
  const pieces = [];
6
15
  let offset = 0;
@@ -62,6 +71,100 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
62
71
  return run;
63
72
  }
64
73
 
74
+ /**
75
+ * Splits a run-level tracked-change carrier at an accepted-view character
76
+ * offset without mutating the source carrier. The original revision ID stays
77
+ * with the leading fragment; an interior trailing fragment receives a fresh,
78
+ * document-scoped ID while all other carrier metadata remains unchanged.
79
+ *
80
+ * @param {Document} xmlDoc
81
+ * @param {Element} carrierElement
82
+ * @param {number} splitOffset
83
+ * @param {RevisionIdAllocator|null} [allocator=null]
84
+ * @returns {{ leftCarrier: Element|null, rightCarrier: Element|null }}
85
+ */
86
+ export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
87
+ const carrierName = getLocalName(carrierElement);
88
+ if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
89
+ throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
90
+ }
91
+ if (!Number.isInteger(splitOffset) || splitOffset < 0) {
92
+ throw new RangeError('splitOffset must be a non-negative integer.');
93
+ }
94
+
95
+ const children = Array.from(carrierElement.childNodes || []);
96
+ const totalLength = children.reduce((length, child) => {
97
+ return length + (isWordElement(child, 'r') ? getRunTextLength(getRunContentPieces(child)) : 0);
98
+ }, 0);
99
+ if (splitOffset > totalLength) {
100
+ throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
101
+ }
102
+
103
+ if (splitOffset === 0) {
104
+ return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
105
+ }
106
+ if (splitOffset === totalLength) {
107
+ return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
108
+ }
109
+
110
+ const leftCarrier = carrierElement.cloneNode(false);
111
+ const rightCarrier = carrierElement.cloneNode(false);
112
+ let offset = 0;
113
+
114
+ for (const child of children) {
115
+ if (!isWordElement(child, 'r')) {
116
+ const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
117
+ destination.appendChild(child.cloneNode(true));
118
+ continue;
119
+ }
120
+
121
+ const pieces = getRunContentPieces(child);
122
+ const runLength = getRunTextLength(pieces);
123
+ const runEnd = offset + runLength;
124
+
125
+ if (runEnd <= splitOffset) {
126
+ leftCarrier.appendChild(child.cloneNode(true));
127
+ } else if (offset >= splitOffset) {
128
+ rightCarrier.appendChild(child.cloneNode(true));
129
+ } else {
130
+ const localOffset = splitOffset - offset;
131
+ const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
132
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
133
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
134
+ leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
135
+ const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
136
+ refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
137
+ rightCarrier.appendChild(rightRun);
138
+ }
139
+ offset = runEnd;
140
+ }
141
+
142
+ const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
143
+ const nextId = resolvedAllocator.next();
144
+ setWordAttribute(rightCarrier, 'id', String(nextId));
145
+ resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
146
+
147
+ return { leftCarrier, rightCarrier };
148
+ }
149
+
150
+ function resolveAllocator(xmlDoc, allocator) {
151
+ return allocator instanceof RevisionIdAllocator
152
+ ? allocator
153
+ : (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
154
+ }
155
+
156
+ function setWordAttribute(element, localName, value) {
157
+ if (typeof element.setAttributeNS === 'function') {
158
+ element.setAttributeNS(NS_W, `w:${localName}`, value);
159
+ } else {
160
+ element.setAttribute(`w:${localName}`, value);
161
+ }
162
+ }
163
+
164
+ function getLocalName(element) {
165
+ return String(element?.localName || element?.nodeName || '').replace(/^.*:/, '');
166
+ }
167
+
65
168
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
66
169
  if (asDeletedText) {
67
170
  const delText = createWordElement(xmlDoc, 'w:delText');
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type OoxmlSourceType = 'package' | 'document' | 'fragment';
2
2
  export type RedlineStatus = 'ok' | 'no-op' | 'error';
3
- export type ExistingRevisionsPolicy = 'merge-same-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
3
+ export type ExistingRevisionsPolicy = 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
4
4
  export type RevisionView = 'accepted' | 'rejected';
5
5
 
6
6
  export interface RevisionTextSegment {