@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.
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js v0.5.0 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js v0.5.2 — https://github.com/AnsonLai/docx-redline-js
2
2
  var __create = Object.create;
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -2634,6 +2634,17 @@ function computeWordDiffs(originalText, newText, options = {}) {
2634
2634
  }
2635
2635
  return decodeBmpDiffs(charDiffs, wordArray);
2636
2636
  }
2637
+ function computeInsertionOnlyDiffs(originalText, newText) {
2638
+ if (originalText === newText) return [[0, originalText]];
2639
+ if (!originalText) return [[1, newText]];
2640
+ let originalIndex = 0;
2641
+ for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
2642
+ if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
2643
+ }
2644
+ if (originalIndex !== originalText.length) return null;
2645
+ const diffs = createDiffEngine().diff_main(originalText, newText);
2646
+ return diffs.some(([op]) => op === -1) ? null : diffs;
2647
+ }
2637
2648
  function computeWordLevelDiffOps(originalText, newText, options = {}) {
2638
2649
  if (originalText === newText) {
2639
2650
  return [{
@@ -5879,7 +5890,26 @@ function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
5879
5890
  };
5880
5891
  }
5881
5892
 
5893
+ // core/revision-cloning.js
5894
+ function refreshRunPropertyChangeIds(root, allocator = null) {
5895
+ if (!root) return root;
5896
+ const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
5897
+ const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
5898
+ const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
5899
+ for (const node of candidates) {
5900
+ if (!isWordElement(node, "rPrChange")) continue;
5901
+ const nextId = String(resolvedAllocator.next());
5902
+ if (typeof node.setAttributeNS === "function") {
5903
+ node.setAttributeNS(NS_W, "w:id", nextId);
5904
+ } else {
5905
+ node.setAttribute("w:id", nextId);
5906
+ }
5907
+ }
5908
+ return root;
5909
+ }
5910
+
5882
5911
  // engine/surgical-run-splitting.js
5912
+ var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
5883
5913
  function getRunContentPieces(runElement) {
5884
5914
  const pieces = [];
5885
5915
  let offset = 0;
@@ -5928,6 +5958,74 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
5928
5958
  parent.insertBefore(run, referenceNode);
5929
5959
  return run;
5930
5960
  }
5961
+ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
5962
+ const carrierName = getLocalName(carrierElement);
5963
+ if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
5964
+ throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
5965
+ }
5966
+ if (!Number.isInteger(splitOffset) || splitOffset < 0) {
5967
+ throw new RangeError("splitOffset must be a non-negative integer.");
5968
+ }
5969
+ const children = Array.from(carrierElement.childNodes || []);
5970
+ const totalLength = children.reduce((length, child) => {
5971
+ return length + (isWordElement(child, "r") ? getRunTextLength(getRunContentPieces(child)) : 0);
5972
+ }, 0);
5973
+ if (splitOffset > totalLength) {
5974
+ throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
5975
+ }
5976
+ if (splitOffset === 0) {
5977
+ return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
5978
+ }
5979
+ if (splitOffset === totalLength) {
5980
+ return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
5981
+ }
5982
+ const leftCarrier = carrierElement.cloneNode(false);
5983
+ const rightCarrier = carrierElement.cloneNode(false);
5984
+ let offset = 0;
5985
+ for (const child of children) {
5986
+ if (!isWordElement(child, "r")) {
5987
+ const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
5988
+ destination.appendChild(child.cloneNode(true));
5989
+ continue;
5990
+ }
5991
+ const pieces = getRunContentPieces(child);
5992
+ const runLength = getRunTextLength(pieces);
5993
+ const runEnd = offset + runLength;
5994
+ if (runEnd <= splitOffset) {
5995
+ leftCarrier.appendChild(child.cloneNode(true));
5996
+ } else if (offset >= splitOffset) {
5997
+ rightCarrier.appendChild(child.cloneNode(true));
5998
+ } else {
5999
+ const localOffset = splitOffset - offset;
6000
+ const rPr = Array.from(child.childNodes || []).find((node) => isWordElement(node, "rPr")) || null;
6001
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
6002
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
6003
+ leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
6004
+ const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
6005
+ refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
6006
+ rightCarrier.appendChild(rightRun);
6007
+ }
6008
+ offset = runEnd;
6009
+ }
6010
+ const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
6011
+ const nextId = resolvedAllocator.next();
6012
+ setWordAttribute(rightCarrier, "id", String(nextId));
6013
+ resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
6014
+ return { leftCarrier, rightCarrier };
6015
+ }
6016
+ function resolveAllocator(xmlDoc, allocator) {
6017
+ return allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
6018
+ }
6019
+ function setWordAttribute(element, localName2, value) {
6020
+ if (typeof element.setAttributeNS === "function") {
6021
+ element.setAttributeNS(NS_W, `w:${localName2}`, value);
6022
+ } else {
6023
+ element.setAttribute(`w:${localName2}`, value);
6024
+ }
6025
+ }
6026
+ function getLocalName(element) {
6027
+ return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
6028
+ }
5931
6029
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
5932
6030
  if (asDeletedText) {
5933
6031
  const delText = createWordElement(xmlDoc, "w:delText");
@@ -6003,8 +6101,7 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6003
6101
  if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
6004
6102
  spansByRun.get(span.runElement).push(span);
6005
6103
  });
6006
- let changed = false;
6007
- let usedDelMetadata = false;
6104
+ const records = [];
6008
6105
  spansByRun.forEach((runSpans, runElement) => {
6009
6106
  const parent = runElement.parentNode;
6010
6107
  if (!parent) return;
@@ -6022,24 +6119,84 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6022
6119
  deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
6023
6120
  });
6024
6121
  if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
6025
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false);
6026
- const deletedPieces = sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true);
6027
- const afterPieces = sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false);
6028
- insertRunPiecesBefore(xmlDoc, parent, runElement, beforePieces, runSpans[0].rPr);
6029
- if (generateRedlines && deletedPieces.length > 0) {
6030
- const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
6031
- const metadata = revisionMetadata ? usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata : null;
6122
+ records.push({
6123
+ runElement,
6124
+ parent,
6125
+ rPr: runSpans[0].rPr,
6126
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
6127
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
6128
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
6129
+ globalStart: Math.max(startPos, Math.min(...runSpans.map((span) => span.charStart))),
6130
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map((span) => span.charEnd))),
6131
+ carrierGlobalStart: isWordElement(parent, "ins") ? getCarrierGlobalStart(spanIndex, parent) : null
6132
+ });
6133
+ });
6134
+ const groups = [];
6135
+ for (const record of records) {
6136
+ const previousGroup = groups[groups.length - 1];
6137
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
6138
+ if (previousRecord && previousRecord.parent === record.parent && nextElementSibling(previousRecord.runElement) === record.runElement) {
6139
+ previousGroup.push(record);
6140
+ } else {
6141
+ groups.push([record]);
6142
+ }
6143
+ }
6144
+ let changed = false;
6145
+ let usedDelMetadata = false;
6146
+ for (const group of groups) {
6147
+ const firstRecord = group[0];
6148
+ let delWrapper = null;
6149
+ if (generateRedlines && group.some((record) => record.deletedPieces.length > 0)) {
6150
+ const metadata = revisionMetadata ? usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, "del").id } : revisionMetadata : null;
6032
6151
  usedDelMetadata = true;
6033
- const delWrapper = createTrackChange(xmlDoc, "del", delRun, author, metadata);
6034
- parent.insertBefore(delWrapper, runElement);
6152
+ delWrapper = createTrackChange(xmlDoc, "del", null, author, metadata);
6153
+ }
6154
+ for (const record of group) {
6155
+ const { parent, runElement } = record;
6156
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
6157
+ if (delWrapper && record === firstRecord) {
6158
+ parent.insertBefore(delWrapper, runElement);
6159
+ }
6160
+ if (delWrapper && record.deletedPieces.length > 0) {
6161
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
6162
+ }
6163
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6164
+ parent.removeChild(runElement);
6165
+ changed = true;
6166
+ }
6167
+ const carrier = isWordElement(firstRecord.parent, "ins") ? firstRecord.parent : null;
6168
+ const groupEnd = Math.max(...group.map((record) => record.globalEnd));
6169
+ if (carrier && groupEnd === endPos) {
6170
+ const carrierStart = firstRecord.carrierGlobalStart;
6171
+ const deletedBeforeEnd = group.filter((record) => record.globalStart < endPos).reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || "").length, 0), 0);
6172
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
6173
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = /* @__PURE__ */ new Map();
6174
+ spanIndex.revisionInsertionAnchors.set(endPos, {
6175
+ carrier,
6176
+ splitOffset: currentOffset,
6177
+ rPr: firstRecord.rPr
6178
+ });
6035
6179
  }
6036
- insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
6037
- parent.removeChild(runElement);
6038
- changed = true;
6039
- });
6180
+ }
6040
6181
  return changed;
6041
6182
  }
6042
- function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
6183
+ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = "merge-same-author") {
6184
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
6185
+ if (mutationAnchor && existingRevisions === "slice-cross-author" && isConnected(mutationAnchor.carrier) && isForeignInsertion(mutationAnchor.carrier, author)) {
6186
+ spanIndex.revisionInsertionAnchors.delete(pos);
6187
+ return spliceInsertionAtCarrierOffset(
6188
+ xmlDoc,
6189
+ mutationAnchor.carrier,
6190
+ mutationAnchor.splitOffset,
6191
+ text,
6192
+ mutationAnchor.rPr,
6193
+ author,
6194
+ formatHints,
6195
+ insertOffset,
6196
+ generateRedlines,
6197
+ revisionMetadata
6198
+ );
6199
+ }
6043
6200
  if (!affinity) {
6044
6201
  let targetSpan = findContainingSpan(spanIndex, pos);
6045
6202
  if (!targetSpan && pos > 0) {
@@ -6062,6 +6219,21 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6062
6219
  insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6063
6220
  return true;
6064
6221
  }
6222
+ const generateNestedRevision = !(generateRedlines && existingRevisions === "slice-cross-author" && isSameAuthorInsertion(parent2, author));
6223
+ if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent2, author)) {
6224
+ return spliceInsertionAtCarrierOffset(
6225
+ xmlDoc,
6226
+ parent2,
6227
+ getCarrierSplitOffset(spanIndex, parent2, pos),
6228
+ text,
6229
+ targetSpan.rPr,
6230
+ author,
6231
+ formatHints,
6232
+ insertOffset,
6233
+ generateRedlines,
6234
+ revisionMetadata
6235
+ );
6236
+ }
6065
6237
  const pieces = getRunContentPieces(targetSpan.runElement);
6066
6238
  const targetPiece = pieces.find((piece) => piece.node === targetSpan.textElement);
6067
6239
  const localInsertPos = targetPiece ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart)) : pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces);
@@ -6069,13 +6241,13 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6069
6241
  const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
6070
6242
  const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
6071
6243
  insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, beforePieces, targetSpan.rPr);
6072
- insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6244
+ insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
6073
6245
  insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, afterPieces, targetSpan.rPr);
6074
6246
  parent2.removeChild(targetSpan.runElement);
6075
6247
  return true;
6076
6248
  }
6077
6249
  const referenceNode2 = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
6078
- insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6250
+ insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
6079
6251
  return true;
6080
6252
  }
6081
6253
  const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
@@ -6224,9 +6396,78 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6224
6396
  return true;
6225
6397
  }
6226
6398
  }
6399
+ if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent, author)) {
6400
+ return spliceInsertionAtCarrierOffset(
6401
+ xmlDoc,
6402
+ parent,
6403
+ getCarrierSplitOffset(spanIndex, parent, pos),
6404
+ text,
6405
+ baseRPr,
6406
+ author,
6407
+ formatHints,
6408
+ insertOffset,
6409
+ generateRedlines,
6410
+ revisionMetadata
6411
+ );
6412
+ }
6227
6413
  insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6228
6414
  return true;
6229
6415
  }
6416
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
6417
+ const parent = carrier.parentNode;
6418
+ if (!parent) return false;
6419
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
6420
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
6421
+ insertTextRuns(
6422
+ xmlDoc,
6423
+ parent,
6424
+ carrier,
6425
+ text,
6426
+ withoutRunPropertyChanges(baseRPr),
6427
+ author,
6428
+ formatHints,
6429
+ insertOffset,
6430
+ generateRedlines,
6431
+ revisionMetadata
6432
+ );
6433
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
6434
+ parent.removeChild(carrier);
6435
+ return true;
6436
+ }
6437
+ function withoutRunPropertyChanges(rPr) {
6438
+ if (!rPr) return null;
6439
+ const clone = rPr.cloneNode(true);
6440
+ const changes = Array.from(clone.getElementsByTagName?.("*") || []).filter((node) => isWordElement(node, "rPrChange"));
6441
+ changes.forEach((node) => node.parentNode?.removeChild(node));
6442
+ return clone;
6443
+ }
6444
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
6445
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
6446
+ const carrierLength = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier).reduce((length, span) => length + (span.charEnd - span.charStart), 0);
6447
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
6448
+ }
6449
+ function getCarrierGlobalStart(spanIndex, carrier) {
6450
+ const carrierSpans = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier);
6451
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map((span) => span.charStart)) : 0;
6452
+ }
6453
+ function isForeignInsertion(node, author) {
6454
+ if (!isWordElement(node, "ins")) return false;
6455
+ const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
6456
+ return carrierAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase();
6457
+ }
6458
+ function isSameAuthorInsertion(node, author) {
6459
+ if (!isWordElement(node, "ins")) return false;
6460
+ const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
6461
+ return carrierAuthor.trim().toLowerCase() === String(author || "").trim().toLowerCase();
6462
+ }
6463
+ function nextElementSibling(node) {
6464
+ let sibling = node?.nextSibling || null;
6465
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
6466
+ return sibling;
6467
+ }
6468
+ function isConnected(node) {
6469
+ return !!node?.parentNode;
6470
+ }
6230
6471
  function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
6231
6472
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
6232
6473
  if (applicableHints.length === 0) {
@@ -6250,7 +6491,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
6250
6491
  }
6251
6492
 
6252
6493
  // engine/surgical-mode.js
6253
- function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
6494
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
6254
6495
  const spans = [];
6255
6496
  forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => spans.push(span));
6256
6497
  if (spans.length === 0) return { safe: false };
@@ -6260,7 +6501,7 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
6260
6501
  const sameParent = spans.every((s) => s.runElement?.parentNode === parent);
6261
6502
  if (!sameParent) return { safe: false, structuralBoundary: true };
6262
6503
  const parentLocal = parent.localName || parent.nodeName.replace(/^.*:/, "");
6263
- if (["hyperlink", "sdt", "ins", "del", "moveFrom", "moveTo"].includes(parentLocal)) {
6504
+ if (["hyperlink", "sdt", "del", "moveFrom", "moveTo"].includes(parentLocal) || parentLocal === "ins" && !allowInsertionCarrier) {
6264
6505
  return { safe: false, structuralBoundary: true };
6265
6506
  }
6266
6507
  const structuralTags = /* @__PURE__ */ new Set([
@@ -6317,7 +6558,8 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6317
6558
  void originalText;
6318
6559
  const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
6319
6560
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
6320
- const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
6561
+ const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
6562
+ const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
6321
6563
  const spanIndex = buildSpanIndex(textSpans);
6322
6564
  const pairReplacements = options.pairReplacements === true;
6323
6565
  const warnings = [];
@@ -6352,8 +6594,13 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6352
6594
  if (pairReplacements && generateRedlines && hasNextInsert) {
6353
6595
  const nextText = diffs[i + 1][1];
6354
6596
  const textWithoutNewlines = nextText.replace(/\n/g, " ");
6355
- if (textWithoutNewlines.trim().length > 0) {
6356
- const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
6597
+ if (textWithoutNewlines.length > 0) {
6598
+ const checkResult = checkSafeAdjacencyForPairing(
6599
+ spanIndex,
6600
+ originalPos,
6601
+ originalPos + text.length,
6602
+ options?.existingRevisions === "slice-cross-author"
6603
+ );
6357
6604
  if (checkResult.safe) {
6358
6605
  const event = createReplacementRevisionEvent(author, xmlDoc);
6359
6606
  delMetadata = { id: event.deletionId, author: event.author, date: event.date };
@@ -6372,8 +6619,8 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6372
6619
  i++;
6373
6620
  const [, nextText] = diffs[i];
6374
6621
  const textWithoutNewlines = nextText.replace(/\n/g, " ");
6375
- if (textWithoutNewlines.trim().length > 0) {
6376
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
6622
+ if (textWithoutNewlines.length > 0) {
6623
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6377
6624
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6378
6625
  return withOoxmlSourceType({
6379
6626
  oxml: serializer.serializeToString(xmlDoc),
@@ -6390,8 +6637,8 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6390
6637
  }
6391
6638
  } else if (op === 1) {
6392
6639
  const textWithoutNewlines = text.replace(/\n/g, " ");
6393
- if (textWithoutNewlines.trim().length > 0) {
6394
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
6640
+ if (textWithoutNewlines.length > 0) {
6641
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6395
6642
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6396
6643
  return withOoxmlSourceType({
6397
6644
  oxml: serializer.serializeToString(xmlDoc),
@@ -6407,12 +6654,42 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6407
6654
  newPos += text.length;
6408
6655
  }
6409
6656
  }
6657
+ const actualText = allParagraphs.map((paragraph) => extractCanonicalParagraphText(paragraph)).join("\n");
6658
+ const expectedText = String(modifiedText).replace(/\r\n/g, "\n");
6659
+ if (options.existingRevisions === "slice-cross-author" && actualText !== expectedText) {
6660
+ const mismatchOffset = firstMismatchOffset(expectedText, actualText);
6661
+ return withOoxmlSourceType({
6662
+ oxml: serializer.serializeToString(xmlDoc),
6663
+ hasChanges: false,
6664
+ status: "error",
6665
+ error: {
6666
+ code: "PATCH_ROUNDTRIP_MISMATCH",
6667
+ message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
6668
+ mismatchOffset,
6669
+ expectedExcerpt: excerptAt(expectedText, mismatchOffset),
6670
+ actualExcerpt: excerptAt(actualText, mismatchOffset)
6671
+ },
6672
+ ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6673
+ });
6674
+ }
6410
6675
  return withOoxmlSourceType({
6411
6676
  oxml: serializer.serializeToString(xmlDoc),
6412
6677
  hasChanges,
6413
6678
  ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6414
6679
  });
6415
6680
  }
6681
+ function firstMismatchOffset(expected, actual) {
6682
+ const limit = Math.min(expected.length, actual.length);
6683
+ for (let index = 0; index < limit; index++) {
6684
+ if (expected[index] !== actual[index]) return index;
6685
+ }
6686
+ return limit;
6687
+ }
6688
+ function excerptAt(text, offset, radius = 40) {
6689
+ const start = Math.max(0, offset - radius);
6690
+ const end = Math.min(text.length, offset + radius);
6691
+ return text.slice(start, end);
6692
+ }
6416
6693
 
6417
6694
  // engine/reconstruction-mapper.js
6418
6695
  var import_diff_match_patch2 = __toESM(require_diff_match_patch(), 1);
@@ -7380,6 +7657,30 @@ function unwrapNode(node) {
7380
7657
  parent.removeChild(node);
7381
7658
  return true;
7382
7659
  }
7660
+ function nextElementSibling2(node) {
7661
+ let sibling = node?.nextSibling || null;
7662
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
7663
+ return sibling;
7664
+ }
7665
+ function revisionMetadataWithoutId(node) {
7666
+ return Array.from(node?.attributes || []).filter((attribute) => String(attribute.localName || attribute.name || "").toLowerCase() !== "id").map((attribute) => `${attribute.namespaceURI || ""}|${attribute.localName || attribute.name}=${attribute.value}`).sort().join("\n");
7667
+ }
7668
+ function coalesceAdjacentCompatibleInsertions(xmlDoc) {
7669
+ let coalesced = 0;
7670
+ const insertions = getWordElementsByLocalName(xmlDoc, "ins");
7671
+ for (const insertion of insertions) {
7672
+ if (!insertion.parentNode || isParagraphMarkRevisionMarker(insertion)) continue;
7673
+ let sibling = nextElementSibling2(insertion);
7674
+ while (isWordElement5(sibling, "ins") && normalizeAuthor(getAttributeByLocalName(sibling, "author")) === normalizeAuthor(getAttributeByLocalName(insertion, "author")) && revisionMetadataWithoutId(sibling) === revisionMetadataWithoutId(insertion)) {
7675
+ const next = nextElementSibling2(sibling);
7676
+ while (sibling.firstChild) insertion.appendChild(sibling.firstChild);
7677
+ sibling.parentNode?.removeChild(sibling);
7678
+ coalesced += 1;
7679
+ sibling = next;
7680
+ }
7681
+ }
7682
+ return coalesced;
7683
+ }
7383
7684
  function isTableRowRevisionMarker(node) {
7384
7685
  const parent = node?.parentNode;
7385
7686
  return isWordElement5(parent, "trPr") && isWordElement5(parent?.parentNode, "tr");
@@ -7627,6 +7928,7 @@ function rejectTrackedChangesInOoxml(oxml, options = {}) {
7627
7928
  if (rejectPropertyChangeNode(changeNode, localName2)) rejectedCount += 1;
7628
7929
  }
7629
7930
  }
7931
+ coalesceAdjacentCompatibleInsertions(xmlDoc);
7630
7932
  const serializedOxml = parseResult.isFragmentWrapped ? Array.from(xmlDoc.documentElement.childNodes).map((n) => serializer.serializeToString(n)).join("") : serializer.serializeToString(xmlDoc);
7631
7933
  return {
7632
7934
  oxml: serializedOxml,
@@ -7778,7 +8080,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7778
8080
  const finalize = (result) => {
7779
8081
  const withStatus = { ...result };
7780
8082
  if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== "error") {
7781
- if (existingRevisionsPolicy === "merge-same-author") {
8083
+ if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
7782
8084
  withStatus.oxml = workingOoxml;
7783
8085
  withStatus.hasChanges = true;
7784
8086
  withStatus.warnings = [
@@ -7832,7 +8134,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7832
8134
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator ? options._revisionIdAllocator : new RevisionIdAllocator();
7833
8135
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
7834
8136
  if (containsTrackedChanges(xmlDoc)) {
7835
- if (existingRevisionsPolicy === "merge-same-author") {
8137
+ if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
7836
8138
  const authors = getTrackedChangeAuthors(xmlDoc);
7837
8139
  const currentAuthor = String(author || "").trim().toLowerCase();
7838
8140
  const isSameAuthor = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === currentAuthor);
@@ -7887,7 +8189,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7887
8189
  const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || "").toLowerCase() === "p" ? [xmlDoc.documentElement] : getDocumentParagraphs(xmlDoc);
7888
8190
  const baselineText = paragraphsInDoc.length > 0 ? paragraphsInDoc.map((p) => extractCanonicalParagraphText(p)).join("\n") : "";
7889
8191
  originalText = baselineText;
7890
- } else {
8192
+ } else if (existingRevisionsPolicy === "merge-same-author") {
7891
8193
  log("[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy");
7892
8194
  return finalize({
7893
8195
  oxml: inputOoxml,
@@ -7898,6 +8200,22 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7898
8200
  message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
7899
8201
  }
7900
8202
  });
8203
+ } else {
8204
+ const hasMoveRevision = ["moveFrom", "moveTo"].some((localName2) => {
8205
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName2).length > 0;
8206
+ });
8207
+ if (hasMoveRevision) {
8208
+ return finalize({
8209
+ oxml: inputOoxml,
8210
+ hasChanges: false,
8211
+ status: "error",
8212
+ error: {
8213
+ code: "UNSAFE_REVISION_NESTING",
8214
+ message: "Cross-author slicing does not support pending move revisions."
8215
+ }
8216
+ });
8217
+ }
8218
+ log("[OxmlEngine] Existing revisions retained for cross-author surgical slicing");
7901
8219
  }
7902
8220
  } else if (existingRevisionsPolicy === "accept-all-first" || existingRevisionsPolicy === "accept-all-first-keep-normalized") {
7903
8221
  log("[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining");
@@ -7973,7 +8291,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7973
8291
  }
7974
8292
  }
7975
8293
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
7976
- const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
8294
+ const hasTextChanges = existingRevisionsPolicy === "slice-cross-author" ? cleanModifiedText !== originalText : cleanModifiedText.trim() !== originalText.trim();
7977
8295
  const hasFormatHints = formatHints.length > 0;
7978
8296
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
7979
8297
  const hasExistingFormatting = existingFormatHints.length > 0;
@@ -8096,7 +8414,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8096
8414
  const isTargetList = isListTargetLoose(cleanModifiedText);
8097
8415
  const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
8098
8416
  const tableCellContext = initialTableCellContext;
8099
- log(`[OxmlEngine] Mode: ${hasTables ? "SURGICAL" : "RECONSTRUCTION"}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
8417
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === "slice-cross-author";
8418
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? "SURGICAL" : "RECONSTRUCTION"}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
8100
8419
  try {
8101
8420
  if (isMarkdownTable && !hasTables) {
8102
8421
  recordRouteSelection(options, "table", { transformation: "text-to-table" });
@@ -8107,8 +8426,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8107
8426
  recordRouteSelection(options, "table", { transformation: "table-reconciliation" });
8108
8427
  return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
8109
8428
  }
8110
- if (hasTables) {
8111
- recordRouteSelection(options, "surgical", { tableScoped: true });
8429
+ if (usesSurgicalTextMode) {
8430
+ recordRouteSelection(options, "surgical", { tableScoped: hasTables });
8112
8431
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph ? tableCellContext.targetParagraph : null;
8113
8432
  if (surgicalTarget) {
8114
8433
  log("[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph");
@@ -8125,6 +8444,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8125
8444
  {},
8126
8445
  options
8127
8446
  );
8447
+ if (result.status === "error" && result.error?.code === "PATCH_ROUNDTRIP_MISMATCH") {
8448
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
8449
+ }
8128
8450
  if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
8129
8451
  log("[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)");
8130
8452
  return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
@@ -9611,11 +9933,15 @@ function validateRedlineOoxml(oxml) {
9611
9933
  }
9612
9934
  for (const revision of revisions) {
9613
9935
  const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf2(el)));
9614
- if (nested.length > 0) {
9936
+ const invalidNested = nested.find((candidate) => {
9937
+ if (localNameOf2(revision) !== "ins" || localNameOf2(candidate) !== "del") return true;
9938
+ return candidate.parentNode !== revision;
9939
+ });
9940
+ if (invalidNested) {
9615
9941
  addIssue(
9616
9942
  "NESTED_REVISION",
9617
9943
  "error",
9618
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains nested <${nested[0].nodeName}>.`
9944
+ `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
9619
9945
  );
9620
9946
  }
9621
9947
  }
@@ -10804,24 +11130,6 @@ function buildCommentsExtendedPartXml(entries) {
10804
11130
  return `<w15:commentsEx xmlns:w15="${NS_W15}">${body}</w15:commentsEx>`;
10805
11131
  }
10806
11132
 
10807
- // core/revision-cloning.js
10808
- function refreshRunPropertyChangeIds(root, allocator = null) {
10809
- if (!root) return root;
10810
- const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
10811
- const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
10812
- const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
10813
- for (const node of candidates) {
10814
- if (!isWordElement(node, "rPrChange")) continue;
10815
- const nextId = String(resolvedAllocator.next());
10816
- if (typeof node.setAttributeNS === "function") {
10817
- node.setAttributeNS(NS_W, "w:id", nextId);
10818
- } else {
10819
- node.setAttribute("w:id", nextId);
10820
- }
10821
- }
10822
- return root;
10823
- }
10824
-
10825
11133
  // services/comment-locator.js
10826
11134
  function createParagraphTextIndex(paragraph, options = {}) {
10827
11135
  const revisionView = options.revisionView === "current" ? "accepted" : options.revisionView || "accepted";