@ansonlai/docx-redline-js 0.5.0 → 0.5.1

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.1 — 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;
@@ -5879,7 +5879,26 @@ function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
5879
5879
  };
5880
5880
  }
5881
5881
 
5882
+ // core/revision-cloning.js
5883
+ function refreshRunPropertyChangeIds(root, allocator = null) {
5884
+ if (!root) return root;
5885
+ const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
5886
+ const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
5887
+ const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
5888
+ for (const node of candidates) {
5889
+ if (!isWordElement(node, "rPrChange")) continue;
5890
+ const nextId = String(resolvedAllocator.next());
5891
+ if (typeof node.setAttributeNS === "function") {
5892
+ node.setAttributeNS(NS_W, "w:id", nextId);
5893
+ } else {
5894
+ node.setAttribute("w:id", nextId);
5895
+ }
5896
+ }
5897
+ return root;
5898
+ }
5899
+
5882
5900
  // engine/surgical-run-splitting.js
5901
+ var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
5883
5902
  function getRunContentPieces(runElement) {
5884
5903
  const pieces = [];
5885
5904
  let offset = 0;
@@ -5928,6 +5947,74 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
5928
5947
  parent.insertBefore(run, referenceNode);
5929
5948
  return run;
5930
5949
  }
5950
+ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
5951
+ const carrierName = getLocalName(carrierElement);
5952
+ if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
5953
+ throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
5954
+ }
5955
+ if (!Number.isInteger(splitOffset) || splitOffset < 0) {
5956
+ throw new RangeError("splitOffset must be a non-negative integer.");
5957
+ }
5958
+ const children = Array.from(carrierElement.childNodes || []);
5959
+ const totalLength = children.reduce((length, child) => {
5960
+ return length + (isWordElement(child, "r") ? getRunTextLength(getRunContentPieces(child)) : 0);
5961
+ }, 0);
5962
+ if (splitOffset > totalLength) {
5963
+ throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
5964
+ }
5965
+ if (splitOffset === 0) {
5966
+ return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
5967
+ }
5968
+ if (splitOffset === totalLength) {
5969
+ return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
5970
+ }
5971
+ const leftCarrier = carrierElement.cloneNode(false);
5972
+ const rightCarrier = carrierElement.cloneNode(false);
5973
+ let offset = 0;
5974
+ for (const child of children) {
5975
+ if (!isWordElement(child, "r")) {
5976
+ const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
5977
+ destination.appendChild(child.cloneNode(true));
5978
+ continue;
5979
+ }
5980
+ const pieces = getRunContentPieces(child);
5981
+ const runLength = getRunTextLength(pieces);
5982
+ const runEnd = offset + runLength;
5983
+ if (runEnd <= splitOffset) {
5984
+ leftCarrier.appendChild(child.cloneNode(true));
5985
+ } else if (offset >= splitOffset) {
5986
+ rightCarrier.appendChild(child.cloneNode(true));
5987
+ } else {
5988
+ const localOffset = splitOffset - offset;
5989
+ const rPr = Array.from(child.childNodes || []).find((node) => isWordElement(node, "rPr")) || null;
5990
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
5991
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
5992
+ leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
5993
+ const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
5994
+ refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
5995
+ rightCarrier.appendChild(rightRun);
5996
+ }
5997
+ offset = runEnd;
5998
+ }
5999
+ const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
6000
+ const nextId = resolvedAllocator.next();
6001
+ setWordAttribute(rightCarrier, "id", String(nextId));
6002
+ resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
6003
+ return { leftCarrier, rightCarrier };
6004
+ }
6005
+ function resolveAllocator(xmlDoc, allocator) {
6006
+ return allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
6007
+ }
6008
+ function setWordAttribute(element, localName2, value) {
6009
+ if (typeof element.setAttributeNS === "function") {
6010
+ element.setAttributeNS(NS_W, `w:${localName2}`, value);
6011
+ } else {
6012
+ element.setAttribute(`w:${localName2}`, value);
6013
+ }
6014
+ }
6015
+ function getLocalName(element) {
6016
+ return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
6017
+ }
5931
6018
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
5932
6019
  if (asDeletedText) {
5933
6020
  const delText = createWordElement(xmlDoc, "w:delText");
@@ -6003,8 +6090,7 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6003
6090
  if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
6004
6091
  spansByRun.get(span.runElement).push(span);
6005
6092
  });
6006
- let changed = false;
6007
- let usedDelMetadata = false;
6093
+ const records = [];
6008
6094
  spansByRun.forEach((runSpans, runElement) => {
6009
6095
  const parent = runElement.parentNode;
6010
6096
  if (!parent) return;
@@ -6022,24 +6108,84 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6022
6108
  deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
6023
6109
  });
6024
6110
  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;
6111
+ records.push({
6112
+ runElement,
6113
+ parent,
6114
+ rPr: runSpans[0].rPr,
6115
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
6116
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
6117
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
6118
+ globalStart: Math.max(startPos, Math.min(...runSpans.map((span) => span.charStart))),
6119
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map((span) => span.charEnd))),
6120
+ carrierGlobalStart: isWordElement(parent, "ins") ? getCarrierGlobalStart(spanIndex, parent) : null
6121
+ });
6122
+ });
6123
+ const groups = [];
6124
+ for (const record of records) {
6125
+ const previousGroup = groups[groups.length - 1];
6126
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
6127
+ if (previousRecord && previousRecord.parent === record.parent && nextElementSibling(previousRecord.runElement) === record.runElement) {
6128
+ previousGroup.push(record);
6129
+ } else {
6130
+ groups.push([record]);
6131
+ }
6132
+ }
6133
+ let changed = false;
6134
+ let usedDelMetadata = false;
6135
+ for (const group of groups) {
6136
+ const firstRecord = group[0];
6137
+ let delWrapper = null;
6138
+ if (generateRedlines && group.some((record) => record.deletedPieces.length > 0)) {
6139
+ const metadata = revisionMetadata ? usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, "del").id } : revisionMetadata : null;
6032
6140
  usedDelMetadata = true;
6033
- const delWrapper = createTrackChange(xmlDoc, "del", delRun, author, metadata);
6034
- parent.insertBefore(delWrapper, runElement);
6141
+ delWrapper = createTrackChange(xmlDoc, "del", null, author, metadata);
6142
+ }
6143
+ for (const record of group) {
6144
+ const { parent, runElement } = record;
6145
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
6146
+ if (delWrapper && record === firstRecord) {
6147
+ parent.insertBefore(delWrapper, runElement);
6148
+ }
6149
+ if (delWrapper && record.deletedPieces.length > 0) {
6150
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
6151
+ }
6152
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6153
+ parent.removeChild(runElement);
6154
+ changed = true;
6155
+ }
6156
+ const carrier = isWordElement(firstRecord.parent, "ins") ? firstRecord.parent : null;
6157
+ const groupEnd = Math.max(...group.map((record) => record.globalEnd));
6158
+ if (carrier && groupEnd === endPos) {
6159
+ const carrierStart = firstRecord.carrierGlobalStart;
6160
+ const deletedBeforeEnd = group.filter((record) => record.globalStart < endPos).reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || "").length, 0), 0);
6161
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
6162
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = /* @__PURE__ */ new Map();
6163
+ spanIndex.revisionInsertionAnchors.set(endPos, {
6164
+ carrier,
6165
+ splitOffset: currentOffset,
6166
+ rPr: firstRecord.rPr
6167
+ });
6035
6168
  }
6036
- insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
6037
- parent.removeChild(runElement);
6038
- changed = true;
6039
- });
6169
+ }
6040
6170
  return changed;
6041
6171
  }
6042
- function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
6172
+ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = "merge-same-author") {
6173
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
6174
+ if (mutationAnchor && existingRevisions === "slice-cross-author" && isConnected(mutationAnchor.carrier) && isForeignInsertion(mutationAnchor.carrier, author)) {
6175
+ spanIndex.revisionInsertionAnchors.delete(pos);
6176
+ return spliceInsertionAtCarrierOffset(
6177
+ xmlDoc,
6178
+ mutationAnchor.carrier,
6179
+ mutationAnchor.splitOffset,
6180
+ text,
6181
+ mutationAnchor.rPr,
6182
+ author,
6183
+ formatHints,
6184
+ insertOffset,
6185
+ generateRedlines,
6186
+ revisionMetadata
6187
+ );
6188
+ }
6043
6189
  if (!affinity) {
6044
6190
  let targetSpan = findContainingSpan(spanIndex, pos);
6045
6191
  if (!targetSpan && pos > 0) {
@@ -6062,6 +6208,20 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6062
6208
  insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6063
6209
  return true;
6064
6210
  }
6211
+ if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent2, author)) {
6212
+ return spliceInsertionAtCarrierOffset(
6213
+ xmlDoc,
6214
+ parent2,
6215
+ getCarrierSplitOffset(spanIndex, parent2, pos),
6216
+ text,
6217
+ targetSpan.rPr,
6218
+ author,
6219
+ formatHints,
6220
+ insertOffset,
6221
+ generateRedlines,
6222
+ revisionMetadata
6223
+ );
6224
+ }
6065
6225
  const pieces = getRunContentPieces(targetSpan.runElement);
6066
6226
  const targetPiece = pieces.find((piece) => piece.node === targetSpan.textElement);
6067
6227
  const localInsertPos = targetPiece ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart)) : pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces);
@@ -6224,9 +6384,73 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6224
6384
  return true;
6225
6385
  }
6226
6386
  }
6387
+ if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent, author)) {
6388
+ return spliceInsertionAtCarrierOffset(
6389
+ xmlDoc,
6390
+ parent,
6391
+ getCarrierSplitOffset(spanIndex, parent, pos),
6392
+ text,
6393
+ baseRPr,
6394
+ author,
6395
+ formatHints,
6396
+ insertOffset,
6397
+ generateRedlines,
6398
+ revisionMetadata
6399
+ );
6400
+ }
6227
6401
  insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6228
6402
  return true;
6229
6403
  }
6404
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
6405
+ const parent = carrier.parentNode;
6406
+ if (!parent) return false;
6407
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
6408
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
6409
+ insertTextRuns(
6410
+ xmlDoc,
6411
+ parent,
6412
+ carrier,
6413
+ text,
6414
+ withoutRunPropertyChanges(baseRPr),
6415
+ author,
6416
+ formatHints,
6417
+ insertOffset,
6418
+ generateRedlines,
6419
+ revisionMetadata
6420
+ );
6421
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
6422
+ parent.removeChild(carrier);
6423
+ return true;
6424
+ }
6425
+ function withoutRunPropertyChanges(rPr) {
6426
+ if (!rPr) return null;
6427
+ const clone = rPr.cloneNode(true);
6428
+ const changes = Array.from(clone.getElementsByTagName?.("*") || []).filter((node) => isWordElement(node, "rPrChange"));
6429
+ changes.forEach((node) => node.parentNode?.removeChild(node));
6430
+ return clone;
6431
+ }
6432
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
6433
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
6434
+ const carrierLength = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier).reduce((length, span) => length + (span.charEnd - span.charStart), 0);
6435
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
6436
+ }
6437
+ function getCarrierGlobalStart(spanIndex, carrier) {
6438
+ const carrierSpans = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier);
6439
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map((span) => span.charStart)) : 0;
6440
+ }
6441
+ function isForeignInsertion(node, author) {
6442
+ if (!isWordElement(node, "ins")) return false;
6443
+ const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
6444
+ return carrierAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase();
6445
+ }
6446
+ function nextElementSibling(node) {
6447
+ let sibling = node?.nextSibling || null;
6448
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
6449
+ return sibling;
6450
+ }
6451
+ function isConnected(node) {
6452
+ return !!node?.parentNode;
6453
+ }
6230
6454
  function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
6231
6455
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
6232
6456
  if (applicableHints.length === 0) {
@@ -6250,7 +6474,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
6250
6474
  }
6251
6475
 
6252
6476
  // engine/surgical-mode.js
6253
- function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
6477
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
6254
6478
  const spans = [];
6255
6479
  forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => spans.push(span));
6256
6480
  if (spans.length === 0) return { safe: false };
@@ -6260,7 +6484,7 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
6260
6484
  const sameParent = spans.every((s) => s.runElement?.parentNode === parent);
6261
6485
  if (!sameParent) return { safe: false, structuralBoundary: true };
6262
6486
  const parentLocal = parent.localName || parent.nodeName.replace(/^.*:/, "");
6263
- if (["hyperlink", "sdt", "ins", "del", "moveFrom", "moveTo"].includes(parentLocal)) {
6487
+ if (["hyperlink", "sdt", "del", "moveFrom", "moveTo"].includes(parentLocal) || parentLocal === "ins" && !allowInsertionCarrier) {
6264
6488
  return { safe: false, structuralBoundary: true };
6265
6489
  }
6266
6490
  const structuralTags = /* @__PURE__ */ new Set([
@@ -6353,7 +6577,12 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6353
6577
  const nextText = diffs[i + 1][1];
6354
6578
  const textWithoutNewlines = nextText.replace(/\n/g, " ");
6355
6579
  if (textWithoutNewlines.trim().length > 0) {
6356
- const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
6580
+ const checkResult = checkSafeAdjacencyForPairing(
6581
+ spanIndex,
6582
+ originalPos,
6583
+ originalPos + text.length,
6584
+ options?.existingRevisions === "slice-cross-author"
6585
+ );
6357
6586
  if (checkResult.safe) {
6358
6587
  const event = createReplacementRevisionEvent(author, xmlDoc);
6359
6588
  delMetadata = { id: event.deletionId, author: event.author, date: event.date };
@@ -6373,7 +6602,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6373
6602
  const [, nextText] = diffs[i];
6374
6603
  const textWithoutNewlines = nextText.replace(/\n/g, " ");
6375
6604
  if (textWithoutNewlines.trim().length > 0) {
6376
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
6605
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6377
6606
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6378
6607
  return withOoxmlSourceType({
6379
6608
  oxml: serializer.serializeToString(xmlDoc),
@@ -6391,7 +6620,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6391
6620
  } else if (op === 1) {
6392
6621
  const textWithoutNewlines = text.replace(/\n/g, " ");
6393
6622
  if (textWithoutNewlines.trim().length > 0) {
6394
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
6623
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6395
6624
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6396
6625
  return withOoxmlSourceType({
6397
6626
  oxml: serializer.serializeToString(xmlDoc),
@@ -7380,6 +7609,30 @@ function unwrapNode(node) {
7380
7609
  parent.removeChild(node);
7381
7610
  return true;
7382
7611
  }
7612
+ function nextElementSibling2(node) {
7613
+ let sibling = node?.nextSibling || null;
7614
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
7615
+ return sibling;
7616
+ }
7617
+ function revisionMetadataWithoutId(node) {
7618
+ 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");
7619
+ }
7620
+ function coalesceAdjacentCompatibleInsertions(xmlDoc) {
7621
+ let coalesced = 0;
7622
+ const insertions = getWordElementsByLocalName(xmlDoc, "ins");
7623
+ for (const insertion of insertions) {
7624
+ if (!insertion.parentNode || isParagraphMarkRevisionMarker(insertion)) continue;
7625
+ let sibling = nextElementSibling2(insertion);
7626
+ while (isWordElement5(sibling, "ins") && normalizeAuthor(getAttributeByLocalName(sibling, "author")) === normalizeAuthor(getAttributeByLocalName(insertion, "author")) && revisionMetadataWithoutId(sibling) === revisionMetadataWithoutId(insertion)) {
7627
+ const next = nextElementSibling2(sibling);
7628
+ while (sibling.firstChild) insertion.appendChild(sibling.firstChild);
7629
+ sibling.parentNode?.removeChild(sibling);
7630
+ coalesced += 1;
7631
+ sibling = next;
7632
+ }
7633
+ }
7634
+ return coalesced;
7635
+ }
7383
7636
  function isTableRowRevisionMarker(node) {
7384
7637
  const parent = node?.parentNode;
7385
7638
  return isWordElement5(parent, "trPr") && isWordElement5(parent?.parentNode, "tr");
@@ -7627,6 +7880,7 @@ function rejectTrackedChangesInOoxml(oxml, options = {}) {
7627
7880
  if (rejectPropertyChangeNode(changeNode, localName2)) rejectedCount += 1;
7628
7881
  }
7629
7882
  }
7883
+ coalesceAdjacentCompatibleInsertions(xmlDoc);
7630
7884
  const serializedOxml = parseResult.isFragmentWrapped ? Array.from(xmlDoc.documentElement.childNodes).map((n) => serializer.serializeToString(n)).join("") : serializer.serializeToString(xmlDoc);
7631
7885
  return {
7632
7886
  oxml: serializedOxml,
@@ -7778,7 +8032,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7778
8032
  const finalize = (result) => {
7779
8033
  const withStatus = { ...result };
7780
8034
  if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== "error") {
7781
- if (existingRevisionsPolicy === "merge-same-author") {
8035
+ if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
7782
8036
  withStatus.oxml = workingOoxml;
7783
8037
  withStatus.hasChanges = true;
7784
8038
  withStatus.warnings = [
@@ -7832,7 +8086,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7832
8086
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator ? options._revisionIdAllocator : new RevisionIdAllocator();
7833
8087
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
7834
8088
  if (containsTrackedChanges(xmlDoc)) {
7835
- if (existingRevisionsPolicy === "merge-same-author") {
8089
+ if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
7836
8090
  const authors = getTrackedChangeAuthors(xmlDoc);
7837
8091
  const currentAuthor = String(author || "").trim().toLowerCase();
7838
8092
  const isSameAuthor = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === currentAuthor);
@@ -7887,7 +8141,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7887
8141
  const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || "").toLowerCase() === "p" ? [xmlDoc.documentElement] : getDocumentParagraphs(xmlDoc);
7888
8142
  const baselineText = paragraphsInDoc.length > 0 ? paragraphsInDoc.map((p) => extractCanonicalParagraphText(p)).join("\n") : "";
7889
8143
  originalText = baselineText;
7890
- } else {
8144
+ } else if (existingRevisionsPolicy === "merge-same-author") {
7891
8145
  log("[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy");
7892
8146
  return finalize({
7893
8147
  oxml: inputOoxml,
@@ -7898,6 +8152,22 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
7898
8152
  message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
7899
8153
  }
7900
8154
  });
8155
+ } else {
8156
+ const hasMoveRevision = ["moveFrom", "moveTo"].some((localName2) => {
8157
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName2).length > 0;
8158
+ });
8159
+ if (hasMoveRevision) {
8160
+ return finalize({
8161
+ oxml: inputOoxml,
8162
+ hasChanges: false,
8163
+ status: "error",
8164
+ error: {
8165
+ code: "UNSAFE_REVISION_NESTING",
8166
+ message: "Cross-author slicing does not support pending move revisions."
8167
+ }
8168
+ });
8169
+ }
8170
+ log("[OxmlEngine] Existing revisions retained for cross-author surgical slicing");
7901
8171
  }
7902
8172
  } else if (existingRevisionsPolicy === "accept-all-first" || existingRevisionsPolicy === "accept-all-first-keep-normalized") {
7903
8173
  log("[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining");
@@ -8096,7 +8366,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8096
8366
  const isTargetList = isListTargetLoose(cleanModifiedText);
8097
8367
  const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
8098
8368
  const tableCellContext = initialTableCellContext;
8099
- log(`[OxmlEngine] Mode: ${hasTables ? "SURGICAL" : "RECONSTRUCTION"}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
8369
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === "slice-cross-author";
8370
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? "SURGICAL" : "RECONSTRUCTION"}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
8100
8371
  try {
8101
8372
  if (isMarkdownTable && !hasTables) {
8102
8373
  recordRouteSelection(options, "table", { transformation: "text-to-table" });
@@ -8107,8 +8378,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8107
8378
  recordRouteSelection(options, "table", { transformation: "table-reconciliation" });
8108
8379
  return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
8109
8380
  }
8110
- if (hasTables) {
8111
- recordRouteSelection(options, "surgical", { tableScoped: true });
8381
+ if (usesSurgicalTextMode) {
8382
+ recordRouteSelection(options, "surgical", { tableScoped: hasTables });
8112
8383
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph ? tableCellContext.targetParagraph : null;
8113
8384
  if (surgicalTarget) {
8114
8385
  log("[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph");
@@ -9611,11 +9882,15 @@ function validateRedlineOoxml(oxml) {
9611
9882
  }
9612
9883
  for (const revision of revisions) {
9613
9884
  const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf2(el)));
9614
- if (nested.length > 0) {
9885
+ const invalidNested = nested.find((candidate) => {
9886
+ if (localNameOf2(revision) !== "ins" || localNameOf2(candidate) !== "del") return true;
9887
+ return candidate.parentNode !== revision;
9888
+ });
9889
+ if (invalidNested) {
9615
9890
  addIssue(
9616
9891
  "NESTED_REVISION",
9617
9892
  "error",
9618
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains nested <${nested[0].nodeName}>.`
9893
+ `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
9619
9894
  );
9620
9895
  }
9621
9896
  }
@@ -10804,24 +11079,6 @@ function buildCommentsExtendedPartXml(entries) {
10804
11079
  return `<w15:commentsEx xmlns:w15="${NS_W15}">${body}</w15:commentsEx>`;
10805
11080
  }
10806
11081
 
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
11082
  // services/comment-locator.js
10826
11083
  function createParagraphTextIndex(paragraph, options = {}) {
10827
11084
  const revisionView = options.revisionView === "current" ? "accepted" : options.revisionView || "accepted";