@ansonlai/docx-redline-js 0.5.1 → 0.5.3

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.1 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js v0.5.3 — 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 [{
@@ -6149,7 +6160,15 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
6149
6160
  if (delWrapper && record.deletedPieces.length > 0) {
6150
6161
  delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
6151
6162
  }
6152
- insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6163
+ const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
6164
+ if (record.globalEnd === endPos && !isWordElement(parent, "ins")) {
6165
+ if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = /* @__PURE__ */ new Map();
6166
+ spanIndex.replacementInsertionAnchors.set(endPos, {
6167
+ parent,
6168
+ referenceNode: afterRun || runElement.nextSibling,
6169
+ rPr: record.rPr
6170
+ });
6171
+ }
6153
6172
  parent.removeChild(runElement);
6154
6173
  changed = true;
6155
6174
  }
@@ -6186,6 +6205,23 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6186
6205
  revisionMetadata
6187
6206
  );
6188
6207
  }
6208
+ const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
6209
+ if (replacementAnchor && !affinity && isConnected(replacementAnchor.parent) && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)) {
6210
+ spanIndex.replacementInsertionAnchors.delete(pos);
6211
+ insertTextRuns(
6212
+ xmlDoc,
6213
+ replacementAnchor.parent,
6214
+ replacementAnchor.referenceNode,
6215
+ text,
6216
+ replacementAnchor.rPr,
6217
+ author,
6218
+ formatHints,
6219
+ insertOffset,
6220
+ generateRedlines,
6221
+ revisionMetadata
6222
+ );
6223
+ return true;
6224
+ }
6189
6225
  if (!affinity) {
6190
6226
  let targetSpan = findContainingSpan(spanIndex, pos);
6191
6227
  if (!targetSpan && pos > 0) {
@@ -6208,6 +6244,7 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6208
6244
  insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6209
6245
  return true;
6210
6246
  }
6247
+ const generateNestedRevision = !(generateRedlines && existingRevisions === "slice-cross-author" && isSameAuthorInsertion(parent2, author));
6211
6248
  if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent2, author)) {
6212
6249
  return spliceInsertionAtCarrierOffset(
6213
6250
  xmlDoc,
@@ -6229,13 +6266,13 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
6229
6266
  const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
6230
6267
  const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
6231
6268
  insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, beforePieces, targetSpan.rPr);
6232
- insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6269
+ insertTextRuns(xmlDoc, parent2, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
6233
6270
  insertRunPiecesBefore(xmlDoc, parent2, targetSpan.runElement, afterPieces, targetSpan.rPr);
6234
6271
  parent2.removeChild(targetSpan.runElement);
6235
6272
  return true;
6236
6273
  }
6237
6274
  const referenceNode2 = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
6238
- insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
6275
+ insertTextRuns(xmlDoc, parent2, referenceNode2, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
6239
6276
  return true;
6240
6277
  }
6241
6278
  const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
@@ -6443,6 +6480,11 @@ function isForeignInsertion(node, author) {
6443
6480
  const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
6444
6481
  return carrierAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase();
6445
6482
  }
6483
+ function isSameAuthorInsertion(node, author) {
6484
+ if (!isWordElement(node, "ins")) return false;
6485
+ const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
6486
+ return carrierAuthor.trim().toLowerCase() === String(author || "").trim().toLowerCase();
6487
+ }
6446
6488
  function nextElementSibling(node) {
6447
6489
  let sibling = node?.nextSibling || null;
6448
6490
  while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
@@ -6541,68 +6583,119 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6541
6583
  void originalText;
6542
6584
  const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
6543
6585
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
6544
- const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
6586
+ const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
6587
+ const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
6545
6588
  const spanIndex = buildSpanIndex(textSpans);
6546
6589
  const pairReplacements = options.pairReplacements === true;
6547
6590
  const warnings = [];
6548
6591
  let originalPos = 0;
6549
6592
  let newPos = 0;
6550
6593
  let hasChanges = false;
6551
- for (let i = 0; i < diffs.length; i++) {
6552
- const [op, text] = diffs[i];
6553
- if (op === 0) {
6554
- const len = text.length;
6555
- const startPos = originalPos;
6556
- const endPos = originalPos + len;
6557
- forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => {
6558
- const overlapStartOriginal = Math.max(span.charStart, startPos);
6559
- const overlapEndOriginal = Math.min(span.charEnd, endPos);
6560
- const segmentLen = overlapEndOriginal - overlapStartOriginal;
6561
- const relativeOffset = overlapStartOriginal - startPos;
6562
- const overlapStartNew = newPos + relativeOffset;
6563
- const overlapEndNew = overlapStartNew + segmentLen;
6564
- const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
6565
- if (reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines)) {
6594
+ const insertionOperations = insertionOnlyDiffs ? collectInsertionOperations(insertionOnlyDiffs) : [];
6595
+ if (insertionOperations.length > 1 && formatHints.length === 0) {
6596
+ for (const operation of insertionOperations.slice().reverse()) {
6597
+ const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
6598
+ const liveSpanIndex = buildSpanIndex(liveSpans);
6599
+ const textWithoutNewlines = operation.text.replace(/\n/g, " ");
6600
+ if (textWithoutNewlines.length === 0) continue;
6601
+ const insertResult = processInsert(
6602
+ xmlDoc,
6603
+ liveSpanIndex,
6604
+ operation.originalPos,
6605
+ textWithoutNewlines,
6606
+ author,
6607
+ formatHints,
6608
+ operation.newPos,
6609
+ generateRedlines,
6610
+ allParagraphs[0] || null,
6611
+ null,
6612
+ options?.insertionAffinity || null,
6613
+ options?.existingRevisions || "merge-same-author"
6614
+ );
6615
+ if (insertResult && typeof insertResult === "object" && insertResult.error) {
6616
+ return withOoxmlSourceType({
6617
+ oxml: serializer.serializeToString(xmlDoc),
6618
+ hasChanges: false,
6619
+ status: "error",
6620
+ error: insertResult.error
6621
+ });
6622
+ }
6623
+ if (insertResult === true) hasChanges = true;
6624
+ }
6625
+ } else {
6626
+ for (let i = 0; i < diffs.length; i++) {
6627
+ const [op, text] = diffs[i];
6628
+ if (op === 0) {
6629
+ const len = text.length;
6630
+ const startPos = originalPos;
6631
+ const endPos = originalPos + len;
6632
+ forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => {
6633
+ const overlapStartOriginal = Math.max(span.charStart, startPos);
6634
+ const overlapEndOriginal = Math.min(span.charEnd, endPos);
6635
+ const segmentLen = overlapEndOriginal - overlapStartOriginal;
6636
+ const relativeOffset = overlapStartOriginal - startPos;
6637
+ const overlapStartNew = newPos + relativeOffset;
6638
+ const overlapEndNew = overlapStartNew + segmentLen;
6639
+ const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
6640
+ if (reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines)) {
6641
+ hasChanges = true;
6642
+ }
6643
+ });
6644
+ originalPos += len;
6645
+ newPos += len;
6646
+ } else if (op === -1) {
6647
+ const hasNextInsert = i + 1 < diffs.length && diffs[i + 1][0] === 1;
6648
+ let paired = false;
6649
+ let delMetadata = null;
6650
+ let insMetadata = null;
6651
+ if (pairReplacements && generateRedlines && hasNextInsert) {
6652
+ const nextText = diffs[i + 1][1];
6653
+ const textWithoutNewlines = nextText.replace(/\n/g, " ");
6654
+ if (textWithoutNewlines.length > 0) {
6655
+ const checkResult = checkSafeAdjacencyForPairing(
6656
+ spanIndex,
6657
+ originalPos,
6658
+ originalPos + text.length,
6659
+ options?.existingRevisions === "slice-cross-author"
6660
+ );
6661
+ if (checkResult.safe) {
6662
+ const event = createReplacementRevisionEvent(author, xmlDoc);
6663
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6664
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6665
+ paired = true;
6666
+ } else if (checkResult.structuralBoundary) {
6667
+ warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6668
+ }
6669
+ }
6670
+ }
6671
+ if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
6566
6672
  hasChanges = true;
6567
6673
  }
6568
- });
6569
- originalPos += len;
6570
- newPos += len;
6571
- } else if (op === -1) {
6572
- const hasNextInsert = i + 1 < diffs.length && diffs[i + 1][0] === 1;
6573
- let paired = false;
6574
- let delMetadata = null;
6575
- let insMetadata = null;
6576
- if (pairReplacements && generateRedlines && hasNextInsert) {
6577
- const nextText = diffs[i + 1][1];
6578
- const textWithoutNewlines = nextText.replace(/\n/g, " ");
6579
- if (textWithoutNewlines.trim().length > 0) {
6580
- const checkResult = checkSafeAdjacencyForPairing(
6581
- spanIndex,
6582
- originalPos,
6583
- originalPos + text.length,
6584
- options?.existingRevisions === "slice-cross-author"
6585
- );
6586
- if (checkResult.safe) {
6587
- const event = createReplacementRevisionEvent(author, xmlDoc);
6588
- delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6589
- insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6590
- paired = true;
6591
- } else if (checkResult.structuralBoundary) {
6592
- warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6674
+ originalPos += text.length;
6675
+ if (paired) {
6676
+ i++;
6677
+ const [, nextText] = diffs[i];
6678
+ const textWithoutNewlines = nextText.replace(/\n/g, " ");
6679
+ if (textWithoutNewlines.length > 0) {
6680
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6681
+ if (insertResult && typeof insertResult === "object" && insertResult.error) {
6682
+ return withOoxmlSourceType({
6683
+ oxml: serializer.serializeToString(xmlDoc),
6684
+ hasChanges: false,
6685
+ status: "error",
6686
+ error: insertResult.error
6687
+ });
6688
+ }
6689
+ if (insertResult === true) {
6690
+ hasChanges = true;
6691
+ }
6593
6692
  }
6693
+ newPos += nextText.length;
6594
6694
  }
6595
- }
6596
- if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
6597
- hasChanges = true;
6598
- }
6599
- originalPos += text.length;
6600
- if (paired) {
6601
- i++;
6602
- const [, nextText] = diffs[i];
6603
- const textWithoutNewlines = nextText.replace(/\n/g, " ");
6604
- if (textWithoutNewlines.trim().length > 0) {
6605
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6695
+ } else if (op === 1) {
6696
+ const textWithoutNewlines = text.replace(/\n/g, " ");
6697
+ if (textWithoutNewlines.length > 0) {
6698
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6606
6699
  if (insertResult && typeof insertResult === "object" && insertResult.error) {
6607
6700
  return withOoxmlSourceType({
6608
6701
  oxml: serializer.serializeToString(xmlDoc),
@@ -6615,33 +6708,63 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6615
6708
  hasChanges = true;
6616
6709
  }
6617
6710
  }
6618
- newPos += nextText.length;
6619
- }
6620
- } else if (op === 1) {
6621
- const textWithoutNewlines = text.replace(/\n/g, " ");
6622
- if (textWithoutNewlines.trim().length > 0) {
6623
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
6624
- if (insertResult && typeof insertResult === "object" && insertResult.error) {
6625
- return withOoxmlSourceType({
6626
- oxml: serializer.serializeToString(xmlDoc),
6627
- hasChanges: false,
6628
- status: "error",
6629
- error: insertResult.error
6630
- });
6631
- }
6632
- if (insertResult === true) {
6633
- hasChanges = true;
6634
- }
6711
+ newPos += text.length;
6635
6712
  }
6636
- newPos += text.length;
6637
6713
  }
6638
6714
  }
6715
+ const actualText = allParagraphs.map((paragraph) => extractCanonicalParagraphText(paragraph)).join("\n");
6716
+ const expectedText = String(modifiedText).replace(/\r\n/g, "\n");
6717
+ if (options.existingRevisions === "slice-cross-author" && actualText !== expectedText) {
6718
+ const mismatchOffset = firstMismatchOffset(expectedText, actualText);
6719
+ return withOoxmlSourceType({
6720
+ oxml: serializer.serializeToString(xmlDoc),
6721
+ hasChanges: false,
6722
+ status: "error",
6723
+ error: {
6724
+ code: "PATCH_ROUNDTRIP_MISMATCH",
6725
+ message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
6726
+ mismatchOffset,
6727
+ expectedExcerpt: excerptAt(expectedText, mismatchOffset),
6728
+ actualExcerpt: excerptAt(actualText, mismatchOffset)
6729
+ },
6730
+ ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6731
+ });
6732
+ }
6639
6733
  return withOoxmlSourceType({
6640
6734
  oxml: serializer.serializeToString(xmlDoc),
6641
6735
  hasChanges,
6642
6736
  ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6643
6737
  });
6644
6738
  }
6739
+ function firstMismatchOffset(expected, actual) {
6740
+ const limit = Math.min(expected.length, actual.length);
6741
+ for (let index = 0; index < limit; index++) {
6742
+ if (expected[index] !== actual[index]) return index;
6743
+ }
6744
+ return limit;
6745
+ }
6746
+ function excerptAt(text, offset, radius = 40) {
6747
+ const start = Math.max(0, offset - radius);
6748
+ const end = Math.min(text.length, offset + radius);
6749
+ return text.slice(start, end);
6750
+ }
6751
+ function collectInsertionOperations(diffs) {
6752
+ const operations = [];
6753
+ let originalPos = 0;
6754
+ let newPos = 0;
6755
+ for (const [op, text] of diffs) {
6756
+ if (op === 0) {
6757
+ originalPos += text.length;
6758
+ newPos += text.length;
6759
+ } else if (op === -1) {
6760
+ originalPos += text.length;
6761
+ } else if (op === 1) {
6762
+ operations.push({ originalPos, newPos, text });
6763
+ newPos += text.length;
6764
+ }
6765
+ }
6766
+ return operations;
6767
+ }
6645
6768
 
6646
6769
  // engine/reconstruction-mapper.js
6647
6770
  var import_diff_match_patch2 = __toESM(require_diff_match_patch(), 1);
@@ -7544,8 +7667,8 @@ function resolveAuthorFilter(options = {}) {
7544
7667
  if (options?.allAuthors === true) {
7545
7668
  return { valid: true, allAuthors: true, normalizedAuthor: "" };
7546
7669
  }
7547
- const normalizedAuthor = normalizeAuthor(options?.author);
7548
- if (!normalizedAuthor) {
7670
+ const normalizedAuthor2 = normalizeAuthor(options?.author);
7671
+ if (!normalizedAuthor2) {
7549
7672
  return {
7550
7673
  valid: false,
7551
7674
  allAuthors: false,
@@ -7553,7 +7676,7 @@ function resolveAuthorFilter(options = {}) {
7553
7676
  warning: "No author provided. Pass { author } or set { allAuthors: true }."
7554
7677
  };
7555
7678
  }
7556
- return { valid: true, allAuthors: false, normalizedAuthor };
7679
+ return { valid: true, allAuthors: false, normalizedAuthor: normalizedAuthor2 };
7557
7680
  }
7558
7681
  function authorMatchesNode(node, filter) {
7559
7682
  if (filter.allAuthors) return true;
@@ -8005,6 +8128,133 @@ function recordRouteSelection(options, route, context = {}) {
8005
8128
  }));
8006
8129
  }
8007
8130
 
8131
+ // core/paragraph-revision-safety.js
8132
+ var NON_CONTENT_CHILDREN = /* @__PURE__ */ new Set([
8133
+ "pPr",
8134
+ "bookmarkStart",
8135
+ "bookmarkEnd",
8136
+ "commentRangeStart",
8137
+ "commentRangeEnd",
8138
+ "commentReference",
8139
+ "customXmlInsRangeStart",
8140
+ "customXmlInsRangeEnd",
8141
+ "customXmlDelRangeStart",
8142
+ "customXmlDelRangeEnd",
8143
+ "moveFromRangeStart",
8144
+ "moveFromRangeEnd",
8145
+ "moveToRangeStart",
8146
+ "moveToRangeEnd",
8147
+ "permStart",
8148
+ "permEnd",
8149
+ "proofErr"
8150
+ ]);
8151
+ function localNameOf2(node) {
8152
+ return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
8153
+ }
8154
+ function directElementChildren(node) {
8155
+ return Array.from(node?.childNodes || []).filter((child) => child.nodeType === 1);
8156
+ }
8157
+ function directChild(node, localName2) {
8158
+ return directElementChildren(node).find((child) => localNameOf2(child) === localName2) || null;
8159
+ }
8160
+ function wordAttribute2(node, localName2) {
8161
+ return node?.getAttribute?.(`w:${localName2}`) || node?.getAttribute?.(localName2) || "";
8162
+ }
8163
+ function normalizedAuthor(author) {
8164
+ return String(author || "").trim().toLowerCase();
8165
+ }
8166
+ function isAnchorOnlyRun(node) {
8167
+ if (localNameOf2(node) !== "r") return false;
8168
+ return directElementChildren(node).every((child) => [
8169
+ "rPr",
8170
+ "commentReference",
8171
+ "bookmarkStart",
8172
+ "bookmarkEnd",
8173
+ "commentRangeStart",
8174
+ "commentRangeEnd",
8175
+ "proofErr"
8176
+ ].includes(localNameOf2(child)));
8177
+ }
8178
+ function isNonContentChild(node) {
8179
+ return NON_CONTENT_CHILDREN.has(localNameOf2(node)) || isAnchorOnlyRun(node);
8180
+ }
8181
+ function isWhollyDeletedContentNode(node) {
8182
+ if (localNameOf2(node) === "del") return true;
8183
+ if (!["customXml", "smartTag", "sdt", "sdtContent"].includes(localNameOf2(node))) return false;
8184
+ const contentChildren = directElementChildren(node).filter((child) => !isNonContentChild(child) && localNameOf2(child) !== "sdtPr");
8185
+ return contentChildren.every(isWhollyDeletedContentNode);
8186
+ }
8187
+ function paragraphMarkDeletion(paragraph) {
8188
+ const pPr = directChild(paragraph, "pPr");
8189
+ const rPr = directChild(pPr, "rPr");
8190
+ return directChild(rPr, "del");
8191
+ }
8192
+ function hasVisibleInsertionContent(insertion) {
8193
+ for (const node of Array.from(insertion?.getElementsByTagName?.("*") || [])) {
8194
+ const localName2 = localNameOf2(node);
8195
+ if (!["t", "tab", "br", "cr", "noBreakHyphen", "softHyphen"].includes(localName2)) continue;
8196
+ let ancestor = node.parentNode;
8197
+ let hidden = false;
8198
+ while (ancestor && ancestor !== insertion) {
8199
+ const ancestorName = localNameOf2(ancestor);
8200
+ if (ancestorName === "del" || ancestorName === "moveFrom") {
8201
+ hidden = true;
8202
+ break;
8203
+ }
8204
+ ancestor = ancestor.parentNode;
8205
+ }
8206
+ if (hidden) continue;
8207
+ if (localName2 !== "t" || (node.textContent || "").length > 0) return true;
8208
+ }
8209
+ return false;
8210
+ }
8211
+ function inspectForeignDeletedParagraphTarget(paragraph, mutationAuthor) {
8212
+ const markDeletion = paragraphMarkDeletion(paragraph);
8213
+ if (!markDeletion) {
8214
+ return {
8215
+ matches: false,
8216
+ hasParagraphMarkDeletion: false,
8217
+ foreignParagraphMarkDeletion: false,
8218
+ allContentDeleted: false,
8219
+ ownerAuthor: null,
8220
+ markDeletion: null
8221
+ };
8222
+ }
8223
+ const ownerAuthor = wordAttribute2(markDeletion, "author") || null;
8224
+ const foreignParagraphMarkDeletion = !ownerAuthor || normalizedAuthor(ownerAuthor) !== normalizedAuthor(mutationAuthor);
8225
+ const contentChildren = directElementChildren(paragraph).filter((child) => !isNonContentChild(child));
8226
+ const allContentDeleted = contentChildren.every(isWhollyDeletedContentNode);
8227
+ return {
8228
+ matches: foreignParagraphMarkDeletion && allContentDeleted,
8229
+ hasParagraphMarkDeletion: true,
8230
+ foreignParagraphMarkDeletion,
8231
+ allContentDeleted,
8232
+ ownerAuthor,
8233
+ markDeletion
8234
+ };
8235
+ }
8236
+ function findForeignDeletedParagraphResurrections(root) {
8237
+ const paragraphs = localNameOf2(root) === "p" ? [root] : Array.from(root?.getElementsByTagName?.("*") || []).filter((node) => localNameOf2(node) === "p");
8238
+ const matches = [];
8239
+ for (const paragraph of paragraphs) {
8240
+ const markDeletion = paragraphMarkDeletion(paragraph);
8241
+ if (!markDeletion) continue;
8242
+ const ownerAuthor = wordAttribute2(markDeletion, "author") || null;
8243
+ const contentChildren = directElementChildren(paragraph).filter((child) => !isNonContentChild(child));
8244
+ const foreignInsertions = contentChildren.filter((child) => {
8245
+ if (localNameOf2(child) !== "ins" || !hasVisibleInsertionContent(child)) return false;
8246
+ return normalizedAuthor(wordAttribute2(child, "author")) !== normalizedAuthor(ownerAuthor);
8247
+ });
8248
+ const onlyDeletedContentAndForeignInsertions = contentChildren.every((child) => {
8249
+ return isWhollyDeletedContentNode(child) || foreignInsertions.includes(child);
8250
+ });
8251
+ if (foreignInsertions.length > 0 && onlyDeletedContentAndForeignInsertions) {
8252
+ matches.push({ paragraph, markDeletion, ownerAuthor, foreignInsertions });
8253
+ }
8254
+ }
8255
+ return matches;
8256
+ }
8257
+
8008
8258
  // engine/oxml-engine.js
8009
8259
  function getCommentIdsInOoxml(node) {
8010
8260
  const ids = /* @__PURE__ */ new Set();
@@ -8085,6 +8335,23 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8085
8335
  }
8086
8336
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator ? options._revisionIdAllocator : new RevisionIdAllocator();
8087
8337
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
8338
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || "").toLowerCase() === "p" ? [xmlDoc.documentElement] : getDocumentParagraphs(xmlDoc);
8339
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
8340
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
8341
+ if (resurrectionTarget.matches) {
8342
+ const ownerAuthor = resurrectionTarget.ownerAuthor || "unattributed";
8343
+ return finalize({
8344
+ oxml: inputOoxml,
8345
+ hasChanges: false,
8346
+ status: "error",
8347
+ error: {
8348
+ code: "FOREIGN_PARAGRAPH_MARK_DELETION",
8349
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
8350
+ ownerAuthor
8351
+ }
8352
+ });
8353
+ }
8354
+ }
8088
8355
  if (containsTrackedChanges(xmlDoc)) {
8089
8356
  if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
8090
8357
  const authors = getTrackedChangeAuthors(xmlDoc);
@@ -8243,7 +8510,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8243
8510
  }
8244
8511
  }
8245
8512
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
8246
- const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
8513
+ const hasTextChanges = existingRevisionsPolicy === "slice-cross-author" ? cleanModifiedText !== originalText : cleanModifiedText.trim() !== originalText.trim();
8247
8514
  const hasFormatHints = formatHints.length > 0;
8248
8515
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
8249
8516
  const hasExistingFormatting = existingFormatHints.length > 0;
@@ -8396,6 +8663,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8396
8663
  {},
8397
8664
  options
8398
8665
  );
8666
+ if (result.status === "error" && result.error?.code === "PATCH_ROUNDTRIP_MISMATCH") {
8667
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
8668
+ }
8399
8669
  if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
8400
8670
  log("[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)");
8401
8671
  return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
@@ -8788,6 +9058,23 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
8788
9058
  }
8789
9059
  return { paragraph: byId, resolvedBy: "paragraph_id" };
8790
9060
  }
9061
+ if (descriptor?.fingerprint && !cleanTargetText && !parsedRef) {
9062
+ let fingerprintCandidates = (paragraphMetadataIndex?.entries || []).filter((candidate) => candidate.fingerprint === descriptor.fingerprint);
9063
+ if (typeof descriptor.inTable === "boolean") {
9064
+ fingerprintCandidates = fingerprintCandidates.filter((candidate) => candidate.inTable === descriptor.inTable);
9065
+ }
9066
+ if (fingerprintCandidates.length === 1) {
9067
+ return { paragraph: fingerprintCandidates[0].paragraph, resolvedBy: "fingerprint" };
9068
+ }
9069
+ if (fingerprintCandidates.length > 1) {
9070
+ throw createTargetError(
9071
+ "AMBIGUOUS_TARGET",
9072
+ "Target fingerprint matched multiple paragraphs; provide paragraphId or index.",
9073
+ fingerprintCandidates.map(serializeTargetCandidate)
9074
+ );
9075
+ }
9076
+ throw createTargetError("TARGET_NOT_FOUND", `Target fingerprint not found: "${descriptor.fingerprint}".`);
9077
+ }
8791
9078
  let candidates = [];
8792
9079
  if (cleanTargetText) {
8793
9080
  const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
@@ -9816,20 +10103,20 @@ async function executeSingleLineListStructuralFallback(plan, options = {}) {
9816
10103
  // core/redline-validation.js
9817
10104
  var REVISION_ID_ELEMENTS = /* @__PURE__ */ new Set(["ins", "del", "rPrChange", "pPrChange"]);
9818
10105
  var REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
9819
- function localNameOf2(node) {
10106
+ function localNameOf3(node) {
9820
10107
  return String(node?.localName || node?.nodeName || "").replace(/^.*:/, "");
9821
10108
  }
9822
10109
  function elementsByLocalName(root, name) {
9823
- return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf2(el) === name);
10110
+ return Array.from(root.getElementsByTagName("*")).filter((el) => localNameOf3(el) === name);
9824
10111
  }
9825
- function wordAttribute2(node, name) {
10112
+ function wordAttribute3(node, name) {
9826
10113
  return node.getAttribute(`w:${name}`) || node.getAttribute(name) || "";
9827
10114
  }
9828
10115
  function xmlSpaceAttribute(node) {
9829
10116
  return node.getAttribute("xml:space") || node.getAttribute("space") || node.getAttributeNS?.("http://www.w3.org/XML/1998/namespace", "space") || "";
9830
10117
  }
9831
10118
  function isParagraphMarkRevision(node) {
9832
- return localNameOf2(node.parentNode) === "rPr";
10119
+ return localNameOf3(node.parentNode) === "rPr";
9833
10120
  }
9834
10121
  function parseOoxmlForValidation(oxml) {
9835
10122
  const attempt = (xml) => {
@@ -9866,14 +10153,14 @@ function validateRedlineOoxml(oxml) {
9866
10153
  const delElements = elementsByLocalName(doc, "del");
9867
10154
  const revisions = insElements.concat(delElements);
9868
10155
  for (const paragraph of elementsByLocalName(doc, "p")) {
9869
- const nested = Array.from(paragraph.getElementsByTagName("*")).find((el) => el !== paragraph && localNameOf2(el) === "p");
10156
+ const nested = Array.from(paragraph.getElementsByTagName("*")).find((el) => el !== paragraph && localNameOf3(el) === "p");
9870
10157
  if (nested) {
9871
10158
  addIssue("NESTED_PARAGRAPH", "error", `<${paragraph.nodeName}> contains nested <${nested.nodeName}>.`);
9872
10159
  }
9873
10160
  }
9874
10161
  for (const body of elementsByLocalName(doc, "body")) {
9875
10162
  const children = Array.from(body.childNodes || []).filter((child) => child.nodeType === 1);
9876
- const sectPrIndexes = children.map((child, index) => localNameOf2(child) === "sectPr" ? index : -1).filter((index) => index >= 0);
10163
+ const sectPrIndexes = children.map((child, index) => localNameOf3(child) === "sectPr" ? index : -1).filter((index) => index >= 0);
9877
10164
  if (sectPrIndexes.length > 1) {
9878
10165
  addIssue("MULTIPLE_BODY_SECTPR", "error", "<w:body> contains multiple direct <w:sectPr> elements.");
9879
10166
  } else if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== children.length - 1) {
@@ -9881,16 +10168,16 @@ function validateRedlineOoxml(oxml) {
9881
10168
  }
9882
10169
  }
9883
10170
  for (const revision of revisions) {
9884
- const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf2(el)));
10171
+ const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf3(el)));
9885
10172
  const invalidNested = nested.find((candidate) => {
9886
- if (localNameOf2(revision) !== "ins" || localNameOf2(candidate) !== "del") return true;
10173
+ if (localNameOf3(revision) !== "ins" || localNameOf3(candidate) !== "del") return true;
9887
10174
  return candidate.parentNode !== revision;
9888
10175
  });
9889
10176
  if (invalidNested) {
9890
10177
  addIssue(
9891
10178
  "NESTED_REVISION",
9892
10179
  "error",
9893
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
10180
+ `<${revision.nodeName}> (w:id="${wordAttribute3(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
9894
10181
  );
9895
10182
  }
9896
10183
  }
@@ -9900,15 +10187,15 @@ function validateRedlineOoxml(oxml) {
9900
10187
  addIssue(
9901
10188
  "DEL_CONTAINS_T",
9902
10189
  "error",
9903
- `<w:del> (w:id="${wordAttribute2(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
10190
+ `<w:del> (w:id="${wordAttribute3(del, "id")}") contains <w:t>; deleted text must use <w:delText>.`
9904
10191
  );
9905
10192
  }
9906
10193
  }
9907
10194
  for (const revision of revisions) {
9908
10195
  const missing = [];
9909
- if (!wordAttribute2(revision, "id")) missing.push("w:id");
9910
- if (!wordAttribute2(revision, "author")) missing.push("w:author");
9911
- if (!REVISION_DATE_PATTERN.test(wordAttribute2(revision, "date"))) missing.push("w:date");
10196
+ if (!wordAttribute3(revision, "id")) missing.push("w:id");
10197
+ if (!wordAttribute3(revision, "author")) missing.push("w:author");
10198
+ if (!REVISION_DATE_PATTERN.test(wordAttribute3(revision, "date"))) missing.push("w:date");
9912
10199
  if (missing.length > 0) {
9913
10200
  addIssue(
9914
10201
  "MISSING_REVISION_METADATA",
@@ -9919,8 +10206,8 @@ function validateRedlineOoxml(oxml) {
9919
10206
  }
9920
10207
  const seenIds = /* @__PURE__ */ new Set();
9921
10208
  for (const node of Array.from(doc.getElementsByTagName("*"))) {
9922
- if (!REVISION_ID_ELEMENTS.has(localNameOf2(node))) continue;
9923
- const id = wordAttribute2(node, "id");
10209
+ if (!REVISION_ID_ELEMENTS.has(localNameOf3(node))) continue;
10210
+ const id = wordAttribute3(node, "id");
9924
10211
  if (!id) continue;
9925
10212
  if (seenIds.has(id)) {
9926
10213
  addIssue("DUPLICATE_REVISION_ID", "error", `Revision id ${id} appears more than once.`);
@@ -9948,10 +10235,18 @@ function validateRedlineOoxml(oxml) {
9948
10235
  addIssue(
9949
10236
  "EMPTY_REVISION_WRAPPER",
9950
10237
  "warning",
9951
- `<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") wraps no content.`
10238
+ `<${revision.nodeName}> (w:id="${wordAttribute3(revision, "id")}") wraps no content.`
9952
10239
  );
9953
10240
  }
9954
10241
  }
10242
+ for (const resurrection of findForeignDeletedParagraphResurrections(doc)) {
10243
+ const ownerAuthor = resurrection.ownerAuthor || "unattributed";
10244
+ addIssue(
10245
+ "FOREIGN_PARAGRAPH_MARK_DELETION",
10246
+ "warning",
10247
+ `Paragraph deleted by ${ownerAuthor} also contains non-empty insertion content from another author; Accept/Reject lifecycle may not preserve the apparent restoration.`
10248
+ );
10249
+ }
9955
10250
  return { valid: !issues.some((issue) => issue.severity === "error"), issues };
9956
10251
  }
9957
10252