@ansonlai/docx-redline-js 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js v0.5.2 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js v0.5.4 — 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,10 @@ function computeWordDiffs(originalText, newText, options = {}) {
2634
2634
  }
2635
2635
  return decodeBmpDiffs(charDiffs, wordArray);
2636
2636
  }
2637
+ function computeCharacterDiffs(originalText, newText, options = {}) {
2638
+ if (originalText === newText) return [[0, originalText]];
2639
+ return createDiffEngine(options).diff_main(originalText, newText);
2640
+ }
2637
2641
  function computeInsertionOnlyDiffs(originalText, newText) {
2638
2642
  if (originalText === newText) return [[0, originalText]];
2639
2643
  if (!originalText) return [[1, newText]];
@@ -5713,7 +5717,7 @@ function getParagraphId(paragraph) {
5713
5717
 
5714
5718
  // engine/surgical-spans.js
5715
5719
  function getRunChildText(child) {
5716
- if (isWordElement(child, "t")) return child.textContent || "";
5720
+ if (isWordElement(child, "t") || isWordElement(child, "delText")) return child.textContent || "";
5717
5721
  if (isWordElement(child, "br") || isWordElement(child, "cr")) return "\n";
5718
5722
  if (isWordElement(child, "tab")) return " ";
5719
5723
  if (isWordElement(child, "noBreakHyphen")) return "\u2011";
@@ -5721,7 +5725,7 @@ function getRunChildText(child) {
5721
5725
  return "";
5722
5726
  }
5723
5727
  function isTextLikeRunChild(child) {
5724
- return isWordElement(child, "t") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
5728
+ return isWordElement(child, "t") || isWordElement(child, "delText") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
5725
5729
  }
5726
5730
  function buildSurgicalTextSpans(paragraphs) {
5727
5731
  let fullText = "";
@@ -5904,12 +5908,13 @@ function refreshRunPropertyChangeIds(root, allocator = null) {
5904
5908
  } else {
5905
5909
  node.setAttribute("w:id", nextId);
5906
5910
  }
5911
+ resolvedAllocator._receiptCollector?.recordRevision(Number(nextId), "rPrChange");
5907
5912
  }
5908
5913
  return root;
5909
5914
  }
5910
5915
 
5911
5916
  // engine/surgical-run-splitting.js
5912
- var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
5917
+ var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins", "del"]);
5913
5918
  function getRunContentPieces(runElement) {
5914
5919
  const pieces = [];
5915
5920
  let offset = 0;
@@ -5961,7 +5966,7 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
5961
5966
  function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
5962
5967
  const carrierName = getLocalName(carrierElement);
5963
5968
  if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
5964
- throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
5969
+ throw new TypeError("splitTrackChangeCarrier requires a w:ins or w:del carrier.");
5965
5970
  }
5966
5971
  if (!Number.isInteger(splitOffset) || splitOffset < 0) {
5967
5972
  throw new RangeError("splitOffset must be a non-negative integer.");
@@ -5998,16 +6003,17 @@ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator
5998
6003
  } else {
5999
6004
  const localOffset = splitOffset - offset;
6000
6005
  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);
6006
+ const asDeletedText = carrierName === "del";
6007
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
6008
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
6003
6009
  leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
6004
6010
  const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
6005
- refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
6006
6011
  rightCarrier.appendChild(rightRun);
6007
6012
  }
6008
6013
  offset = runEnd;
6009
6014
  }
6010
6015
  const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
6016
+ refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
6011
6017
  const nextId = resolvedAllocator.next();
6012
6018
  setWordAttribute(rightCarrier, "id", String(nextId));
6013
6019
  resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
@@ -6027,7 +6033,7 @@ function getLocalName(element) {
6027
6033
  return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
6028
6034
  }
6029
6035
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
6030
- if (asDeletedText) {
6036
+ if (asDeletedText && (isWordElement(sourceNode, "delText") || isWordElement(sourceNode, "t"))) {
6031
6037
  const delText = createWordElement(xmlDoc, "w:delText");
6032
6038
  delText.setAttribute("xml:space", "preserve");
6033
6039
  delText.textContent = text;
@@ -6050,6 +6056,15 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
6050
6056
  if (text === "\u2011" && isWordElement(sourceNode, "noBreakHyphen")) {
6051
6057
  return sourceNode.cloneNode(true);
6052
6058
  }
6059
+ if (text === "\xAD" && isWordElement(sourceNode, "softHyphen")) {
6060
+ return sourceNode.cloneNode(true);
6061
+ }
6062
+ if (asDeletedText) {
6063
+ const delText = createWordElement(xmlDoc, "w:delText");
6064
+ delText.setAttribute("xml:space", "preserve");
6065
+ delText.textContent = text;
6066
+ return delText;
6067
+ }
6053
6068
  const textNode = createWordElement(xmlDoc, "w:t");
6054
6069
  textNode.setAttribute("xml:space", "preserve");
6055
6070
  textNode.textContent = text;
@@ -6160,7 +6175,15 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6160
6175
  if (delWrapper && record.deletedPieces.length > 0) {
6161
6176
  delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
6162
6177
  }
6163
- insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6178
+ const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6179
+ if (record.globalEnd === endPos && !isWordElement(parent, "ins")) {
6180
+ if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = /* @__PURE__ */ new Map();
6181
+ spanIndex.replacementInsertionAnchors.set(endPos, {
6182
+ parent,
6183
+ referenceNode: afterRun || runElement.nextSibling,
6184
+ rPr: record.rPr
6185
+ });
6186
+ }
6164
6187
  parent.removeChild(runElement);
6165
6188
  changed = true;
6166
6189
  }
@@ -6197,6 +6220,23 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6197
6220
  revisionMetadata
6198
6221
  );
6199
6222
  }
6223
+ const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
6224
+ if (replacementAnchor && !affinity && isConnected(replacementAnchor.parent) && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)) {
6225
+ spanIndex.replacementInsertionAnchors.delete(pos);
6226
+ insertTextRuns(
6227
+ xmlDoc,
6228
+ replacementAnchor.parent,
6229
+ replacementAnchor.referenceNode,
6230
+ text,
6231
+ replacementAnchor.rPr,
6232
+ author,
6233
+ formatHints,
6234
+ insertOffset,
6235
+ generateRedlines,
6236
+ revisionMetadata
6237
+ );
6238
+ return true;
6239
+ }
6200
6240
  if (!affinity) {
6201
6241
  let targetSpan = findContainingSpan(spanIndex, pos);
6202
6242
  if (!targetSpan && pos > 0) {
@@ -6559,68 +6599,197 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6559
6599
  const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
6560
6600
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
6561
6601
  const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
6562
- const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
6602
+ const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
6603
+ computeWordDiffs(fullText, modifiedText, diffOptions),
6604
+ diffOptions
6605
+ );
6563
6606
  const spanIndex = buildSpanIndex(textSpans);
6564
6607
  const pairReplacements = options.pairReplacements === true;
6565
6608
  const warnings = [];
6566
6609
  let originalPos = 0;
6567
6610
  let newPos = 0;
6568
6611
  let hasChanges = false;
6569
- for (let i = 0; i < diffs.length; i++) {
6570
- const [op, text] = diffs[i];
6571
- if (op === 0) {
6572
- const len = text.length;
6573
- const startPos = originalPos;
6574
- const endPos = originalPos + len;
6575
- forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => {
6576
- const overlapStartOriginal = Math.max(span.charStart, startPos);
6577
- const overlapEndOriginal = Math.min(span.charEnd, endPos);
6578
- const segmentLen = overlapEndOriginal - overlapStartOriginal;
6579
- const relativeOffset = overlapStartOriginal - startPos;
6580
- const overlapStartNew = newPos + relativeOffset;
6581
- const overlapEndNew = overlapStartNew + segmentLen;
6582
- const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
6583
- if (reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines)) {
6584
- hasChanges = true;
6612
+ const insertionOperations = insertionOnlyDiffs ? collectInsertionOperations(insertionOnlyDiffs) : [];
6613
+ if (insertionOperations.length > 1 && formatHints.length === 0) {
6614
+ for (const operation of insertionOperations.slice().reverse()) {
6615
+ const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
6616
+ const liveSpanIndex = buildSpanIndex(liveSpans);
6617
+ const textWithoutNewlines = operation.text.replace(/\n/g, " ");
6618
+ if (textWithoutNewlines.length === 0) continue;
6619
+ const insertResult = processInsert(
6620
+ xmlDoc,
6621
+ liveSpanIndex,
6622
+ operation.originalPos,
6623
+ textWithoutNewlines,
6624
+ author,
6625
+ formatHints,
6626
+ operation.newPos,
6627
+ generateRedlines,
6628
+ allParagraphs[0] || null,
6629
+ null,
6630
+ options?.insertionAffinity || null,
6631
+ options?.existingRevisions || "merge-same-author"
6632
+ );
6633
+ if (insertResult && typeof insertResult === "object" && insertResult.error) {
6634
+ return withOoxmlSourceType({
6635
+ oxml: serializer.serializeToString(xmlDoc),
6636
+ hasChanges: false,
6637
+ status: "error",
6638
+ error: insertResult.error
6639
+ });
6640
+ }
6641
+ if (insertResult === true) hasChanges = true;
6642
+ }
6643
+ } else if (formatHints.length === 0) {
6644
+ const editOperations = collectTextEditOperations(diffs);
6645
+ for (const operation of editOperations.reverse()) {
6646
+ const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
6647
+ if (operation.type === "insert") {
6648
+ const inserted = processInsert(
6649
+ xmlDoc,
6650
+ liveSpanIndex,
6651
+ operation.start,
6652
+ operation.text.replace(/\n/g, " "),
6653
+ author,
6654
+ formatHints,
6655
+ operation.newPos,
6656
+ generateRedlines,
6657
+ allParagraphs[0] || null,
6658
+ null,
6659
+ options?.insertionAffinity || null,
6660
+ options?.existingRevisions || "merge-same-author"
6661
+ );
6662
+ if (inserted && typeof inserted === "object" && inserted.error) {
6663
+ return withOoxmlSourceType({
6664
+ oxml: serializer.serializeToString(xmlDoc),
6665
+ hasChanges: false,
6666
+ status: "error",
6667
+ error: inserted.error
6668
+ });
6585
6669
  }
6586
- });
6587
- originalPos += len;
6588
- newPos += len;
6589
- } else if (op === -1) {
6590
- const hasNextInsert = i + 1 < diffs.length && diffs[i + 1][0] === 1;
6591
- let paired = false;
6670
+ if (inserted === true) hasChanges = true;
6671
+ continue;
6672
+ }
6592
6673
  let delMetadata = null;
6593
6674
  let insMetadata = null;
6594
- if (pairReplacements && generateRedlines && hasNextInsert) {
6595
- const nextText = diffs[i + 1][1];
6596
- const textWithoutNewlines = nextText.replace(/\n/g, " ");
6597
- if (textWithoutNewlines.length > 0) {
6598
- const checkResult = checkSafeAdjacencyForPairing(
6599
- spanIndex,
6600
- originalPos,
6601
- originalPos + text.length,
6602
- options?.existingRevisions === "slice-cross-author"
6603
- );
6604
- if (checkResult.safe) {
6605
- const event = createReplacementRevisionEvent(author, xmlDoc);
6606
- delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6607
- insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6608
- paired = true;
6609
- } else if (checkResult.structuralBoundary) {
6610
- warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6611
- }
6675
+ if (operation.type === "replace" && pairReplacements && generateRedlines && operation.text.replace(/\n/g, " ")) {
6676
+ const checkResult = checkSafeAdjacencyForPairing(
6677
+ liveSpanIndex,
6678
+ operation.start,
6679
+ operation.end,
6680
+ options?.existingRevisions === "slice-cross-author"
6681
+ );
6682
+ if (checkResult.safe) {
6683
+ const event = createReplacementRevisionEvent(author, xmlDoc);
6684
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6685
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6686
+ } else if (checkResult.structuralBoundary) {
6687
+ warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6612
6688
  }
6613
6689
  }
6614
- if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
6690
+ if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
6615
6691
  hasChanges = true;
6616
6692
  }
6617
- originalPos += text.length;
6618
- if (paired) {
6619
- i++;
6620
- const [, nextText] = diffs[i];
6621
- const textWithoutNewlines = nextText.replace(/\n/g, " ");
6693
+ if (operation.type === "replace") {
6694
+ const inserted = processInsert(
6695
+ xmlDoc,
6696
+ liveSpanIndex,
6697
+ operation.end,
6698
+ operation.text.replace(/\n/g, " "),
6699
+ author,
6700
+ formatHints,
6701
+ operation.newPos,
6702
+ generateRedlines,
6703
+ allParagraphs[0] || null,
6704
+ insMetadata,
6705
+ options?.insertionAffinity || null,
6706
+ options?.existingRevisions || "merge-same-author"
6707
+ );
6708
+ if (inserted && typeof inserted === "object" && inserted.error) {
6709
+ return withOoxmlSourceType({
6710
+ oxml: serializer.serializeToString(xmlDoc),
6711
+ hasChanges: false,
6712
+ status: "error",
6713
+ error: inserted.error
6714
+ });
6715
+ }
6716
+ if (inserted === true) hasChanges = true;
6717
+ }
6718
+ }
6719
+ } else {
6720
+ for (let i = 0; i < diffs.length; i++) {
6721
+ const [op, text] = diffs[i];
6722
+ if (op === 0) {
6723
+ const len = text.length;
6724
+ const startPos = originalPos;
6725
+ const endPos = originalPos + len;
6726
+ forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => {
6727
+ const overlapStartOriginal = Math.max(span.charStart, startPos);
6728
+ const overlapEndOriginal = Math.min(span.charEnd, endPos);
6729
+ const segmentLen = overlapEndOriginal - overlapStartOriginal;
6730
+ const relativeOffset = overlapStartOriginal - startPos;
6731
+ const overlapStartNew = newPos + relativeOffset;
6732
+ const overlapEndNew = overlapStartNew + segmentLen;
6733
+ const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
6734
+ if (reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines)) {
6735
+ hasChanges = true;
6736
+ }
6737
+ });
6738
+ originalPos += len;
6739
+ newPos += len;
6740
+ } else if (op === -1) {
6741
+ const hasNextInsert = i + 1 < diffs.length && diffs[i + 1][0] === 1;
6742
+ let paired = false;
6743
+ let delMetadata = null;
6744
+ let insMetadata = null;
6745
+ if (pairReplacements && generateRedlines && hasNextInsert) {
6746
+ const nextText = diffs[i + 1][1];
6747
+ const textWithoutNewlines = nextText.replace(/\n/g, " ");
6748
+ if (textWithoutNewlines.length > 0) {
6749
+ const checkResult = checkSafeAdjacencyForPairing(
6750
+ spanIndex,
6751
+ originalPos,
6752
+ originalPos + text.length,
6753
+ options?.existingRevisions === "slice-cross-author"
6754
+ );
6755
+ if (checkResult.safe) {
6756
+ const event = createReplacementRevisionEvent(author, xmlDoc);
6757
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6758
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6759
+ paired = true;
6760
+ } else if (checkResult.structuralBoundary) {
6761
+ warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6762
+ }
6763
+ }
6764
+ }
6765
+ if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
6766
+ hasChanges = true;
6767
+ }
6768
+ originalPos += text.length;
6769
+ if (paired) {
6770
+ i++;
6771
+ const [, nextText] = diffs[i];
6772
+ const textWithoutNewlines = nextText.replace(/\n/g, " ");
6773
+ if (textWithoutNewlines.length > 0) {
6774
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6775
+ if (insertResult && typeof insertResult === "object" && insertResult.error) {
6776
+ return withOoxmlSourceType({
6777
+ oxml: serializer.serializeToString(xmlDoc),
6778
+ hasChanges: false,
6779
+ status: "error",
6780
+ error: insertResult.error
6781
+ });
6782
+ }
6783
+ if (insertResult === true) {
6784
+ hasChanges = true;
6785
+ }
6786
+ }
6787
+ newPos += nextText.length;
6788
+ }
6789
+ } else if (op === 1) {
6790
+ const textWithoutNewlines = text.replace(/\n/g, " ");
6622
6791
  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");
6792
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6624
6793
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6625
6794
  return withOoxmlSourceType({
6626
6795
  oxml: serializer.serializeToString(xmlDoc),
@@ -6633,25 +6802,8 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6633
6802
  hasChanges = true;
6634
6803
  }
6635
6804
  }
6636
- newPos += nextText.length;
6637
- }
6638
- } else if (op === 1) {
6639
- const textWithoutNewlines = text.replace(/\n/g, " ");
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");
6642
- if (insertResult && typeof insertResult === "object" && insertResult.error) {
6643
- return withOoxmlSourceType({
6644
- oxml: serializer.serializeToString(xmlDoc),
6645
- hasChanges: false,
6646
- status: "error",
6647
- error: insertResult.error
6648
- });
6649
- }
6650
- if (insertResult === true) {
6651
- hasChanges = true;
6652
- }
6805
+ newPos += text.length;
6653
6806
  }
6654
- newPos += text.length;
6655
6807
  }
6656
6808
  }
6657
6809
  const actualText = allParagraphs.map((paragraph) => extractCanonicalParagraphText(paragraph)).join("\n");
@@ -6667,7 +6819,9 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6667
6819
  message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
6668
6820
  mismatchOffset,
6669
6821
  expectedExcerpt: excerptAt(expectedText, mismatchOffset),
6670
- actualExcerpt: excerptAt(actualText, mismatchOffset)
6822
+ actualExcerpt: excerptAt(actualText, mismatchOffset),
6823
+ expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
6824
+ actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
6671
6825
  },
6672
6826
  ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6673
6827
  });
@@ -6690,6 +6844,76 @@ function excerptAt(text, offset, radius = 40) {
6690
6844
  const end = Math.min(text.length, offset + radius);
6691
6845
  return text.slice(start, end);
6692
6846
  }
6847
+ function codePointAtOffset(text, offset) {
6848
+ if (offset >= text.length) return "END";
6849
+ return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, "0")}`;
6850
+ }
6851
+ function refineSpaceEquivalentReplacements(diffs, diffOptions) {
6852
+ const refined = [];
6853
+ for (let index = 0; index < diffs.length; index++) {
6854
+ const [op, text] = diffs[index];
6855
+ const next = diffs[index + 1];
6856
+ if (op === -1 && next?.[0] === 1 && text !== next[1] && text.length === next[1].length && text.replace(/\u00a0/g, " ") === next[1].replace(/\u00a0/g, " ")) {
6857
+ refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
6858
+ index++;
6859
+ continue;
6860
+ }
6861
+ refined.push([op, text]);
6862
+ }
6863
+ return refined;
6864
+ }
6865
+ function collectTextEditOperations(diffs) {
6866
+ const operations = [];
6867
+ let originalPos = 0;
6868
+ let newPos = 0;
6869
+ for (let index = 0; index < diffs.length; index++) {
6870
+ const [op, text] = diffs[index];
6871
+ if (op === 0) {
6872
+ originalPos += text.length;
6873
+ newPos += text.length;
6874
+ continue;
6875
+ }
6876
+ if (op === -1) {
6877
+ const next = diffs[index + 1];
6878
+ if (next?.[0] === 1) {
6879
+ operations.push({
6880
+ type: "replace",
6881
+ start: originalPos,
6882
+ end: originalPos + text.length,
6883
+ newPos,
6884
+ text: next[1]
6885
+ });
6886
+ originalPos += text.length;
6887
+ newPos += next[1].length;
6888
+ index++;
6889
+ } else {
6890
+ operations.push({ type: "delete", start: originalPos, end: originalPos + text.length, newPos, text: "" });
6891
+ originalPos += text.length;
6892
+ }
6893
+ continue;
6894
+ }
6895
+ operations.push({ type: "insert", start: originalPos, end: originalPos, newPos, text });
6896
+ newPos += text.length;
6897
+ }
6898
+ return operations;
6899
+ }
6900
+ function collectInsertionOperations(diffs) {
6901
+ const operations = [];
6902
+ let originalPos = 0;
6903
+ let newPos = 0;
6904
+ for (const [op, text] of diffs) {
6905
+ if (op === 0) {
6906
+ originalPos += text.length;
6907
+ newPos += text.length;
6908
+ } else if (op === -1) {
6909
+ originalPos += text.length;
6910
+ } else if (op === 1) {
6911
+ operations.push({ originalPos, newPos, text });
6912
+ newPos += text.length;
6913
+ }
6914
+ }
6915
+ return operations;
6916
+ }
6693
6917
 
6694
6918
  // engine/reconstruction-mapper.js
6695
6919
  var import_diff_match_patch2 = __toESM(require_diff_match_patch(), 1);
@@ -7592,8 +7816,8 @@ function resolveAuthorFilter(options = {}) {
7592
7816
  if (options?.allAuthors === true) {
7593
7817
  return { valid: true, allAuthors: true, normalizedAuthor: "" };
7594
7818
  }
7595
- const normalizedAuthor = normalizeAuthor(options?.author);
7596
- if (!normalizedAuthor) {
7819
+ const normalizedAuthor2 = normalizeAuthor(options?.author);
7820
+ if (!normalizedAuthor2) {
7597
7821
  return {
7598
7822
  valid: false,
7599
7823
  allAuthors: false,
@@ -7601,7 +7825,7 @@ function resolveAuthorFilter(options = {}) {
7601
7825
  warning: "No author provided. Pass { author } or set { allAuthors: true }."
7602
7826
  };
7603
7827
  }
7604
- return { valid: true, allAuthors: false, normalizedAuthor };
7828
+ return { valid: true, allAuthors: false, normalizedAuthor: normalizedAuthor2 };
7605
7829
  }
7606
7830
  function authorMatchesNode(node, filter) {
7607
7831
  if (filter.allAuthors) return true;
@@ -8053,6 +8277,133 @@ function recordRouteSelection(options, route, context = {}) {
8053
8277
  }));
8054
8278
  }
8055
8279
 
8280
+ // core/paragraph-revision-safety.js
8281
+ var NON_CONTENT_CHILDREN = /* @__PURE__ */ new Set([
8282
+ "pPr",
8283
+ "bookmarkStart",
8284
+ "bookmarkEnd",
8285
+ "commentRangeStart",
8286
+ "commentRangeEnd",
8287
+ "commentReference",
8288
+ "customXmlInsRangeStart",
8289
+ "customXmlInsRangeEnd",
8290
+ "customXmlDelRangeStart",
8291
+ "customXmlDelRangeEnd",
8292
+ "moveFromRangeStart",
8293
+ "moveFromRangeEnd",
8294
+ "moveToRangeStart",
8295
+ "moveToRangeEnd",
8296
+ "permStart",
8297
+ "permEnd",
8298
+ "proofErr"
8299
+ ]);
8300
+ function localNameOf2(node) {
8301
+ return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
8302
+ }
8303
+ function directElementChildren(node) {
8304
+ return Array.from(node?.childNodes || []).filter((child) => child.nodeType === 1);
8305
+ }
8306
+ function directChild(node, localName2) {
8307
+ return directElementChildren(node).find((child) => localNameOf2(child) === localName2) || null;
8308
+ }
8309
+ function wordAttribute2(node, localName2) {
8310
+ return node?.getAttribute?.(`w:${localName2}`) || node?.getAttribute?.(localName2) || "";
8311
+ }
8312
+ function normalizedAuthor(author) {
8313
+ return String(author || "").trim().toLowerCase();
8314
+ }
8315
+ function isAnchorOnlyRun(node) {
8316
+ if (localNameOf2(node) !== "r") return false;
8317
+ return directElementChildren(node).every((child) => [
8318
+ "rPr",
8319
+ "commentReference",
8320
+ "bookmarkStart",
8321
+ "bookmarkEnd",
8322
+ "commentRangeStart",
8323
+ "commentRangeEnd",
8324
+ "proofErr"
8325
+ ].includes(localNameOf2(child)));
8326
+ }
8327
+ function isNonContentChild(node) {
8328
+ return NON_CONTENT_CHILDREN.has(localNameOf2(node)) || isAnchorOnlyRun(node);
8329
+ }
8330
+ function isWhollyDeletedContentNode(node) {
8331
+ if (localNameOf2(node) === "del") return true;
8332
+ if (!["customXml", "smartTag", "sdt", "sdtContent"].includes(localNameOf2(node))) return false;
8333
+ const contentChildren = directElementChildren(node).filter((child) => !isNonContentChild(child) && localNameOf2(child) !== "sdtPr");
8334
+ return contentChildren.every(isWhollyDeletedContentNode);
8335
+ }
8336
+ function paragraphMarkDeletion(paragraph) {
8337
+ const pPr = directChild(paragraph, "pPr");
8338
+ const rPr = directChild(pPr, "rPr");
8339
+ return directChild(rPr, "del");
8340
+ }
8341
+ function hasVisibleInsertionContent(insertion) {
8342
+ for (const node of Array.from(insertion?.getElementsByTagName?.("*") || [])) {
8343
+ const localName2 = localNameOf2(node);
8344
+ if (!["t", "tab", "br", "cr", "noBreakHyphen", "softHyphen"].includes(localName2)) continue;
8345
+ let ancestor = node.parentNode;
8346
+ let hidden = false;
8347
+ while (ancestor && ancestor !== insertion) {
8348
+ const ancestorName = localNameOf2(ancestor);
8349
+ if (ancestorName === "del" || ancestorName === "moveFrom") {
8350
+ hidden = true;
8351
+ break;
8352
+ }
8353
+ ancestor = ancestor.parentNode;
8354
+ }
8355
+ if (hidden) continue;
8356
+ if (localName2 !== "t" || (node.textContent || "").length > 0) return true;
8357
+ }
8358
+ return false;
8359
+ }
8360
+ function inspectForeignDeletedParagraphTarget(paragraph, mutationAuthor) {
8361
+ const markDeletion = paragraphMarkDeletion(paragraph);
8362
+ if (!markDeletion) {
8363
+ return {
8364
+ matches: false,
8365
+ hasParagraphMarkDeletion: false,
8366
+ foreignParagraphMarkDeletion: false,
8367
+ allContentDeleted: false,
8368
+ ownerAuthor: null,
8369
+ markDeletion: null
8370
+ };
8371
+ }
8372
+ const ownerAuthor = wordAttribute2(markDeletion, "author") || null;
8373
+ const foreignParagraphMarkDeletion = !ownerAuthor || normalizedAuthor(ownerAuthor) !== normalizedAuthor(mutationAuthor);
8374
+ const contentChildren = directElementChildren(paragraph).filter((child) => !isNonContentChild(child));
8375
+ const allContentDeleted = contentChildren.every(isWhollyDeletedContentNode);
8376
+ return {
8377
+ matches: foreignParagraphMarkDeletion && allContentDeleted,
8378
+ hasParagraphMarkDeletion: true,
8379
+ foreignParagraphMarkDeletion,
8380
+ allContentDeleted,
8381
+ ownerAuthor,
8382
+ markDeletion
8383
+ };
8384
+ }
8385
+ function findForeignDeletedParagraphResurrections(root) {
8386
+ const paragraphs = localNameOf2(root) === "p" ? [root] : Array.from(root?.getElementsByTagName?.("*") || []).filter((node) => localNameOf2(node) === "p");
8387
+ const matches = [];
8388
+ for (const paragraph of paragraphs) {
8389
+ const markDeletion = paragraphMarkDeletion(paragraph);
8390
+ if (!markDeletion) continue;
8391
+ const ownerAuthor = wordAttribute2(markDeletion, "author") || null;
8392
+ const contentChildren = directElementChildren(paragraph).filter((child) => !isNonContentChild(child));
8393
+ const foreignInsertions = contentChildren.filter((child) => {
8394
+ if (localNameOf2(child) !== "ins" || !hasVisibleInsertionContent(child)) return false;
8395
+ return normalizedAuthor(wordAttribute2(child, "author")) !== normalizedAuthor(ownerAuthor);
8396
+ });
8397
+ const onlyDeletedContentAndForeignInsertions = contentChildren.every((child) => {
8398
+ return isWhollyDeletedContentNode(child) || foreignInsertions.includes(child);
8399
+ });
8400
+ if (foreignInsertions.length > 0 && onlyDeletedContentAndForeignInsertions) {
8401
+ matches.push({ paragraph, markDeletion, ownerAuthor, foreignInsertions });
8402
+ }
8403
+ }
8404
+ return matches;
8405
+ }
8406
+
8056
8407
  // engine/oxml-engine.js
8057
8408
  function getCommentIdsInOoxml(node) {
8058
8409
  const ids = /* @__PURE__ */ new Set();
@@ -8133,6 +8484,23 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8133
8484
  }
8134
8485
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator ? options._revisionIdAllocator : new RevisionIdAllocator();
8135
8486
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
8487
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || "").toLowerCase() === "p" ? [xmlDoc.documentElement] : getDocumentParagraphs(xmlDoc);
8488
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
8489
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
8490
+ if (resurrectionTarget.matches) {
8491
+ const ownerAuthor = resurrectionTarget.ownerAuthor || "unattributed";
8492
+ return finalize({
8493
+ oxml: inputOoxml,
8494
+ hasChanges: false,
8495
+ status: "error",
8496
+ error: {
8497
+ code: "FOREIGN_PARAGRAPH_MARK_DELETION",
8498
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
8499
+ ownerAuthor
8500
+ }
8501
+ });
8502
+ }
8503
+ }
8136
8504
  if (containsTrackedChanges(xmlDoc)) {
8137
8505
  if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
8138
8506
  const authors = getTrackedChangeAuthors(xmlDoc);
@@ -8839,6 +9207,23 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
8839
9207
  }
8840
9208
  return { paragraph: byId, resolvedBy: "paragraph_id" };
8841
9209
  }
9210
+ if (descriptor?.fingerprint && !cleanTargetText && !parsedRef) {
9211
+ let fingerprintCandidates = (paragraphMetadataIndex?.entries || []).filter((candidate) => candidate.fingerprint === descriptor.fingerprint);
9212
+ if (typeof descriptor.inTable === "boolean") {
9213
+ fingerprintCandidates = fingerprintCandidates.filter((candidate) => candidate.inTable === descriptor.inTable);
9214
+ }
9215
+ if (fingerprintCandidates.length === 1) {
9216
+ return { paragraph: fingerprintCandidates[0].paragraph, resolvedBy: "fingerprint" };
9217
+ }
9218
+ if (fingerprintCandidates.length > 1) {
9219
+ throw createTargetError(
9220
+ "AMBIGUOUS_TARGET",
9221
+ "Target fingerprint matched multiple paragraphs; provide paragraphId or index.",
9222
+ fingerprintCandidates.map(serializeTargetCandidate)
9223
+ );
9224
+ }
9225
+ throw createTargetError("TARGET_NOT_FOUND", `Target fingerprint not found: "${descriptor.fingerprint}".`);
9226
+ }
8842
9227
  let candidates = [];
8843
9228
  if (cleanTargetText) {
8844
9229
  const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
@@ -9867,20 +10252,20 @@ async function executeSingleLineListStructuralFallback(plan, options = {}) {
9867
10252
  // core/redline-validation.js
9868
10253
  var REVISION_ID_ELEMENTS = /* @__PURE__ */ new Set(["ins", "del", "rPrChange", "pPrChange"]);
9869
10254
  var REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
9870
- function localNameOf2(node) {
10255
+ function localNameOf3(node) {
9871
10256
  return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
9872
10257
  }
9873
10258
  function elementsByLocalName(root, name) {
9874
- return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf2(el) === name);
10259
+ return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf3(el) === name);
9875
10260
  }
9876
- function wordAttribute2(node, name) {
10261
+ function wordAttribute3(node, name) {
9877
10262
  return node.getAttribute(`w:${name}`) || node.getAttribute(name) || "";
9878
10263
  }
9879
10264
  function xmlSpaceAttribute(node) {
9880
10265
  return node.getAttribute("xml:space") || node.getAttribute("space") || node.getAttributeNS?.("http://www.w3.org/XML/1998/namespace", "space") || "";
9881
10266
  }
9882
10267
  function isParagraphMarkRevision(node) {
9883
- return localNameOf2(node.parentNode) === "rPr";
10268
+ return localNameOf3(node.parentNode) === "rPr";
9884
10269
  }
9885
10270
  function parseOoxmlForValidation(oxml) {
9886
10271
  const attempt = (xml) => {
@@ -9904,11 +10289,12 @@ function parseOoxmlForValidation(oxml) {
9904
10289
  function validateRedlineOoxml(oxml) {
9905
10290
  const issues = [];
9906
10291
  const addIssue = (code, severity, message) => issues.push({ code, severity, message });
9907
- if (typeof oxml !== "string" || oxml.trim() === "") {
9908
- addIssue("PARSE_ERROR", "error", "Input is not a non-empty OOXML string.");
10292
+ const isDomNode = oxml && typeof oxml === "object" && (oxml.nodeType === 9 || oxml.nodeType === 1);
10293
+ if (!isDomNode && (typeof oxml !== "string" || oxml.trim() === "")) {
10294
+ addIssue("PARSE_ERROR", "error", "Input is not non-empty OOXML or a parsed XML DOM.");
9909
10295
  return { valid: false, issues };
9910
10296
  }
9911
- const { doc, error: error2 } = parseOoxmlForValidation(oxml);
10297
+ const { doc, error: error2 } = isDomNode ? { doc: oxml.nodeType === 9 ? oxml : oxml.ownerDocument } : parseOoxmlForValidation(oxml);
9912
10298
  if (!doc) {
9913
10299
  addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
9914
10300
  return { valid: false, issues };
@@ -9917,14 +10303,14 @@ function validateRedlineOoxml(oxml) {
9917
10303
  const delElements = elementsByLocalName(doc, "del");
9918
10304
  const revisions = insElements.concat(delElements);
9919
10305
  for (const paragraph of elementsByLocalName(doc, "p")) {
9920
- const nested = Array.from(paragraph.getElementsByTagName("*")).find((el) => el !== paragraph && localNameOf2(el) === "p");
10306
+ const nested = Array.from(paragraph.getElementsByTagName("*")).find((el) => el !== paragraph && localNameOf3(el) === "p");
9921
10307
  if (nested) {
9922
10308
  addIssue("NESTED_PARAGRAPH", "error", `<${paragraph.nodeName}> contains nested <${nested.nodeName}>.`);
9923
10309
  }
9924
10310
  }
9925
10311
  for (const body of elementsByLocalName(doc, "body")) {
9926
10312
  const children = Array.from(body.childNodes || []).filter((child) => child.nodeType === 1);
9927
- const sectPrIndexes = children.map((child, index) => localNameOf2(child) === "sectPr" ? index : -1).filter((index) => index >= 0);
10313
+ const sectPrIndexes = children.map((child, index) => localNameOf3(child) === "sectPr" ? index : -1).filter((index) => index >= 0);
9928
10314
  if (sectPrIndexes.length > 1) {
9929
10315
  addIssue("MULTIPLE_BODY_SECTPR", "error", "<w:body> contains multiple direct <w:sectPr> elements.");
9930
10316
  } else if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== children.length - 1) {
@@ -9932,16 +10318,16 @@ function validateRedlineOoxml(oxml) {
9932
10318
  }
9933
10319
  }
9934
10320
  for (const revision of revisions) {
9935
- const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf2(el)));
10321
+ const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf3(el)));
9936
10322
  const invalidNested = nested.find((candidate) => {
9937
- if (localNameOf2(revision) !== "ins" || localNameOf2(candidate) !== "del") return true;
10323
+ if (localNameOf3(revision) !== "ins" || localNameOf3(candidate) !== "del") return true;
9938
10324
  return candidate.parentNode !== revision;
9939
10325
  });
9940
10326
  if (invalidNested) {
9941
10327
  addIssue(
9942
10328
  "NESTED_REVISION",
9943
10329
  "error",
9944
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
10330
+ `<${revision.nodeName}> (w:id="${wordAttribute3(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
9945
10331
  );
9946
10332
  }
9947
10333
  }
@@ -9951,15 +10337,15 @@ function validateRedlineOoxml(oxml) {
9951
10337
  addIssue(
9952
10338
  "DEL_CONTAINS_T",
9953
10339
  "error",
9954
- `<w:del> (w:id="${wordAttribute2(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
10340
+ `<w:del> (w:id="${wordAttribute3(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
9955
10341
  );
9956
10342
  }
9957
10343
  }
9958
10344
  for (const revision of revisions) {
9959
10345
  const missing = [];
9960
- if (!wordAttribute2(revision, "id")) missing.push("w:id");
9961
- if (!wordAttribute2(revision, "author")) missing.push("w:author");
9962
- if (!REVISION_DATE_PATTERN.test(wordAttribute2(revision, "date"))) missing.push("w:date");
10346
+ if (!wordAttribute3(revision, "id")) missing.push("w:id");
10347
+ if (!wordAttribute3(revision, "author")) missing.push("w:author");
10348
+ if (!REVISION_DATE_PATTERN.test(wordAttribute3(revision, "date"))) missing.push("w:date");
9963
10349
  if (missing.length > 0) {
9964
10350
  addIssue(
9965
10351
  "MISSING_REVISION_METADATA",
@@ -9970,8 +10356,8 @@ function validateRedlineOoxml(oxml) {
9970
10356
  }
9971
10357
  const seenIds = /* @__PURE__ */ new Set();
9972
10358
  for (const node of Array.from(doc.getElementsByTagName("*"))) {
9973
- if (!REVISION_ID_ELEMENTS.has(localNameOf2(node))) continue;
9974
- const id = wordAttribute2(node, "id");
10359
+ if (!REVISION_ID_ELEMENTS.has(localNameOf3(node))) continue;
10360
+ const id = wordAttribute3(node, "id");
9975
10361
  if (!id) continue;
9976
10362
  if (seenIds.has(id)) {
9977
10363
  addIssue("DUPLICATE_REVISION_ID", "error", `Revision id ${id} appears more than once.`);
@@ -9999,10 +10385,18 @@ function validateRedlineOoxml(oxml) {
9999
10385
  addIssue(
10000
10386
  "EMPTY_REVISION_WRAPPER",
10001
10387
  "warning",
10002
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") wraps no content.`
10388
+ `<${revision.nodeName}> (w:id="${wordAttribute3(revision, "id")}") wraps no content.`
10003
10389
  );
10004
10390
  }
10005
10391
  }
10392
+ for (const resurrection of findForeignDeletedParagraphResurrections(doc)) {
10393
+ const ownerAuthor = resurrection.ownerAuthor || "unattributed";
10394
+ addIssue(
10395
+ "FOREIGN_PARAGRAPH_MARK_DELETION",
10396
+ "warning",
10397
+ `Paragraph deleted by ${ownerAuthor} also contains non-empty insertion content from another author; Accept/Reject lifecycle may not preserve the apparent restoration.`
10398
+ );
10399
+ }
10006
10400
  return { valid: !issues.some((issue) => issue.severity === "error"), issues };
10007
10401
  }
10008
10402