@ansonlai/docx-redline-js 0.5.3 → 0.6.0

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.
Files changed (59) hide show
  1. package/AGENTS.md +82 -667
  2. package/ARCHITECTURE.md +51 -4
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +176 -39
  5. package/core/paragraph-revision-safety.js +10 -8
  6. package/core/paragraph-targeting.js +14 -2
  7. package/core/redline-validation.js +7 -4
  8. package/core/revision-cloning.js +21 -0
  9. package/core/validation-delta.js +23 -0
  10. package/dist/docx-redline-js.esm.js +275 -45
  11. package/dist/docx-redline-js.esm.js.map +3 -3
  12. package/dist/docx-redline-js.esm.min.js +82 -82
  13. package/dist/docx-redline-js.esm.min.js.map +4 -4
  14. package/docs/AGENT_FAST_START.md +59 -0
  15. package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
  16. package/docs/TESTING.md +20 -1
  17. package/docs/schemas/document-operations.schema.json +16 -2
  18. package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
  19. package/engine/oxml-engine.js +80 -13
  20. package/engine/run-builders.js +5 -15
  21. package/engine/surgical-mode.js +148 -3
  22. package/engine/surgical-run-splitting.js +19 -7
  23. package/engine/surgical-spans.js +2 -1
  24. package/index.d.ts +17 -1
  25. package/node/cli.js +235 -36
  26. package/node/docx-document.js +137 -83
  27. package/node/index.d.ts +6 -2
  28. package/package.json +10 -3
  29. package/pipeline/diff-engine.js +15 -0
  30. package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
  31. package/services/batch-operation-orchestrator.js +215 -120
  32. package/services/document-inspection.js +5 -3
  33. package/services/document-operation-applier.js +99 -36
  34. package/services/document-operation-contract.js +50 -6
  35. package/services/document-operation-mutations.js +404 -41
  36. package/services/document-operation-session.js +4 -0
  37. package/services/error-recovery.js +174 -0
  38. package/services/operation-batch-compiler.js +394 -0
  39. package/services/operation-preflight.js +91 -72
  40. package/services/standalone-operation-runner.d.ts +35 -1
  41. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
  42. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
  43. package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
  44. package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
  45. package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
  46. package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
  47. package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
  48. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
  49. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
  50. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
  51. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
  52. package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
  53. package/docs/test-comparison-dashboard.html +0 -4338
  54. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
  55. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
  56. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
  57. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
  58. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
  59. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js v0.5.3 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js v0.6.0 — 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]];
@@ -4886,6 +4890,47 @@ function extractFormattingFromOoxml(xmlDoc) {
4886
4890
  return { existingFormatHints, textSpans, paragraphs };
4887
4891
  }
4888
4892
 
4893
+ // core/revision-cloning.js
4894
+ var REVISION_HISTORY_ELEMENTS = /* @__PURE__ */ new Set([
4895
+ "ins",
4896
+ "del",
4897
+ "moveFrom",
4898
+ "moveTo",
4899
+ "pPrChange",
4900
+ "rPrChange",
4901
+ "tblPrChange",
4902
+ "trPrChange",
4903
+ "tcPrChange",
4904
+ "sectPrChange"
4905
+ ]);
4906
+ function clonePropertiesWithoutRevisionHistory(root) {
4907
+ if (!root) return null;
4908
+ const clone = root.cloneNode(true);
4909
+ const candidates = [clone, ...Array.from(clone.getElementsByTagName?.("*") || [])];
4910
+ for (const node of candidates.reverse()) {
4911
+ if (!REVISION_HISTORY_ELEMENTS.has(String(node.localName || node.nodeName || "").replace(/^.*:/, ""))) continue;
4912
+ node.parentNode?.removeChild(node);
4913
+ }
4914
+ return clone;
4915
+ }
4916
+ function refreshRunPropertyChangeIds(root, allocator = null) {
4917
+ if (!root) return root;
4918
+ const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
4919
+ const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
4920
+ const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
4921
+ for (const node of candidates) {
4922
+ if (!isWordElement(node, "rPrChange")) continue;
4923
+ const nextId = String(resolvedAllocator.next());
4924
+ if (typeof node.setAttributeNS === "function") {
4925
+ node.setAttributeNS(NS_W, "w:id", nextId);
4926
+ } else {
4927
+ node.setAttribute("w:id", nextId);
4928
+ }
4929
+ resolvedAllocator._receiptCollector?.recordRevision(Number(nextId), "rPrChange");
4930
+ }
4931
+ return root;
4932
+ }
4933
+
4889
4934
  // engine/run-builders.js
4890
4935
  function createTrackChange(xmlDoc, type, run, author, revisionMetadata = null) {
4891
4936
  const wrapper = createWordElement(xmlDoc, type === "ins" ? "w:ins" : "w:del");
@@ -5074,13 +5119,8 @@ function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
5074
5119
  rPrChange.setAttribute("w:id", String(metadata.id));
5075
5120
  rPrChange.setAttribute("w:author", metadata.author);
5076
5121
  rPrChange.setAttribute("w:date", dateStr || metadata.date);
5077
- const previousRPr = createWordElement(xmlDoc, "w:rPr");
5078
5122
  const source = sourceNode || rPr;
5079
- Array.from(source.childNodes).forEach((child) => {
5080
- if (child.nodeName !== "w:rPrChange") {
5081
- previousRPr.appendChild(child.cloneNode(true));
5082
- }
5083
- });
5123
+ const previousRPr = clonePropertiesWithoutRevisionHistory(source) || createWordElement(xmlDoc, "w:rPr");
5084
5124
  rPrChange.appendChild(previousRPr);
5085
5125
  const existing = getFirstElementByTag(rPr, "w:rPrChange");
5086
5126
  if (existing) {
@@ -5713,7 +5753,7 @@ function getParagraphId(paragraph) {
5713
5753
 
5714
5754
  // engine/surgical-spans.js
5715
5755
  function getRunChildText(child) {
5716
- if (isWordElement(child, "t")) return child.textContent || "";
5756
+ if (isWordElement(child, "t") || isWordElement(child, "delText")) return child.textContent || "";
5717
5757
  if (isWordElement(child, "br") || isWordElement(child, "cr")) return "\n";
5718
5758
  if (isWordElement(child, "tab")) return " ";
5719
5759
  if (isWordElement(child, "noBreakHyphen")) return "\u2011";
@@ -5721,7 +5761,7 @@ function getRunChildText(child) {
5721
5761
  return "";
5722
5762
  }
5723
5763
  function isTextLikeRunChild(child) {
5724
- return isWordElement(child, "t") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
5764
+ return isWordElement(child, "t") || isWordElement(child, "delText") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
5725
5765
  }
5726
5766
  function buildSurgicalTextSpans(paragraphs) {
5727
5767
  let fullText = "";
@@ -5890,26 +5930,8 @@ function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
5890
5930
  };
5891
5931
  }
5892
5932
 
5893
- // core/revision-cloning.js
5894
- function refreshRunPropertyChangeIds(root, allocator = null) {
5895
- if (!root) return root;
5896
- const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
5897
- const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
5898
- const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
5899
- for (const node of candidates) {
5900
- if (!isWordElement(node, "rPrChange")) continue;
5901
- const nextId = String(resolvedAllocator.next());
5902
- if (typeof node.setAttributeNS === "function") {
5903
- node.setAttributeNS(NS_W, "w:id", nextId);
5904
- } else {
5905
- node.setAttribute("w:id", nextId);
5906
- }
5907
- }
5908
- return root;
5909
- }
5910
-
5911
5933
  // engine/surgical-run-splitting.js
5912
- var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
5934
+ var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins", "del"]);
5913
5935
  function getRunContentPieces(runElement) {
5914
5936
  const pieces = [];
5915
5937
  let offset = 0;
@@ -5961,7 +5983,7 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
5961
5983
  function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
5962
5984
  const carrierName = getLocalName(carrierElement);
5963
5985
  if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
5964
- throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
5986
+ throw new TypeError("splitTrackChangeCarrier requires a w:ins or w:del carrier.");
5965
5987
  }
5966
5988
  if (!Number.isInteger(splitOffset) || splitOffset < 0) {
5967
5989
  throw new RangeError("splitOffset must be a non-negative integer.");
@@ -5998,16 +6020,17 @@ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator
5998
6020
  } else {
5999
6021
  const localOffset = splitOffset - offset;
6000
6022
  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);
6023
+ const asDeletedText = carrierName === "del";
6024
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
6025
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
6003
6026
  leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
6004
6027
  const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
6005
- refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
6006
6028
  rightCarrier.appendChild(rightRun);
6007
6029
  }
6008
6030
  offset = runEnd;
6009
6031
  }
6010
6032
  const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
6033
+ refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
6011
6034
  const nextId = resolvedAllocator.next();
6012
6035
  setWordAttribute(rightCarrier, "id", String(nextId));
6013
6036
  resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
@@ -6027,7 +6050,7 @@ function getLocalName(element) {
6027
6050
  return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
6028
6051
  }
6029
6052
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
6030
- if (asDeletedText) {
6053
+ if (asDeletedText && (isWordElement(sourceNode, "delText") || isWordElement(sourceNode, "t"))) {
6031
6054
  const delText = createWordElement(xmlDoc, "w:delText");
6032
6055
  delText.setAttribute("xml:space", "preserve");
6033
6056
  delText.textContent = text;
@@ -6050,6 +6073,15 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
6050
6073
  if (text === "\u2011" && isWordElement(sourceNode, "noBreakHyphen")) {
6051
6074
  return sourceNode.cloneNode(true);
6052
6075
  }
6076
+ if (text === "\xAD" && isWordElement(sourceNode, "softHyphen")) {
6077
+ return sourceNode.cloneNode(true);
6078
+ }
6079
+ if (asDeletedText) {
6080
+ const delText = createWordElement(xmlDoc, "w:delText");
6081
+ delText.setAttribute("xml:space", "preserve");
6082
+ delText.textContent = text;
6083
+ return delText;
6084
+ }
6053
6085
  const textNode = createWordElement(xmlDoc, "w:t");
6054
6086
  textNode.setAttribute("xml:space", "preserve");
6055
6087
  textNode.textContent = text;
@@ -6584,7 +6616,10 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6584
6616
  const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
6585
6617
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
6586
6618
  const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
6587
- const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
6619
+ const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
6620
+ computeWordDiffs(fullText, modifiedText, diffOptions),
6621
+ diffOptions
6622
+ );
6588
6623
  const spanIndex = buildSpanIndex(textSpans);
6589
6624
  const pairReplacements = options.pairReplacements === true;
6590
6625
  const warnings = [];
@@ -6622,6 +6657,82 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6622
6657
  }
6623
6658
  if (insertResult === true) hasChanges = true;
6624
6659
  }
6660
+ } else if (formatHints.length === 0) {
6661
+ const editOperations = collectTextEditOperations(diffs);
6662
+ for (const operation of editOperations.reverse()) {
6663
+ const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
6664
+ if (operation.type === "insert") {
6665
+ const inserted = processInsert(
6666
+ xmlDoc,
6667
+ liveSpanIndex,
6668
+ operation.start,
6669
+ operation.text.replace(/\n/g, " "),
6670
+ author,
6671
+ formatHints,
6672
+ operation.newPos,
6673
+ generateRedlines,
6674
+ allParagraphs[0] || null,
6675
+ null,
6676
+ options?.insertionAffinity || null,
6677
+ options?.existingRevisions || "merge-same-author"
6678
+ );
6679
+ if (inserted && typeof inserted === "object" && inserted.error) {
6680
+ return withOoxmlSourceType({
6681
+ oxml: serializer.serializeToString(xmlDoc),
6682
+ hasChanges: false,
6683
+ status: "error",
6684
+ error: inserted.error
6685
+ });
6686
+ }
6687
+ if (inserted === true) hasChanges = true;
6688
+ continue;
6689
+ }
6690
+ let delMetadata = null;
6691
+ let insMetadata = null;
6692
+ if (operation.type === "replace" && pairReplacements && generateRedlines && operation.text.replace(/\n/g, " ")) {
6693
+ const checkResult = checkSafeAdjacencyForPairing(
6694
+ liveSpanIndex,
6695
+ operation.start,
6696
+ operation.end,
6697
+ options?.existingRevisions === "slice-cross-author"
6698
+ );
6699
+ if (checkResult.safe) {
6700
+ const event = createReplacementRevisionEvent(author, xmlDoc);
6701
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
6702
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
6703
+ } else if (checkResult.structuralBoundary) {
6704
+ warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
6705
+ }
6706
+ }
6707
+ if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
6708
+ hasChanges = true;
6709
+ }
6710
+ if (operation.type === "replace") {
6711
+ const inserted = processInsert(
6712
+ xmlDoc,
6713
+ liveSpanIndex,
6714
+ operation.end,
6715
+ operation.text.replace(/\n/g, " "),
6716
+ author,
6717
+ formatHints,
6718
+ operation.newPos,
6719
+ generateRedlines,
6720
+ allParagraphs[0] || null,
6721
+ insMetadata,
6722
+ options?.insertionAffinity || null,
6723
+ options?.existingRevisions || "merge-same-author"
6724
+ );
6725
+ if (inserted && typeof inserted === "object" && inserted.error) {
6726
+ return withOoxmlSourceType({
6727
+ oxml: serializer.serializeToString(xmlDoc),
6728
+ hasChanges: false,
6729
+ status: "error",
6730
+ error: inserted.error
6731
+ });
6732
+ }
6733
+ if (inserted === true) hasChanges = true;
6734
+ }
6735
+ }
6625
6736
  } else {
6626
6737
  for (let i = 0; i < diffs.length; i++) {
6627
6738
  const [op, text] = diffs[i];
@@ -6725,7 +6836,9 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
6725
6836
  message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
6726
6837
  mismatchOffset,
6727
6838
  expectedExcerpt: excerptAt(expectedText, mismatchOffset),
6728
- actualExcerpt: excerptAt(actualText, mismatchOffset)
6839
+ actualExcerpt: excerptAt(actualText, mismatchOffset),
6840
+ expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
6841
+ actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
6729
6842
  },
6730
6843
  ...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
6731
6844
  });
@@ -6748,6 +6861,59 @@ function excerptAt(text, offset, radius = 40) {
6748
6861
  const end = Math.min(text.length, offset + radius);
6749
6862
  return text.slice(start, end);
6750
6863
  }
6864
+ function codePointAtOffset(text, offset) {
6865
+ if (offset >= text.length) return "END";
6866
+ return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, "0")}`;
6867
+ }
6868
+ function refineSpaceEquivalentReplacements(diffs, diffOptions) {
6869
+ const refined = [];
6870
+ for (let index = 0; index < diffs.length; index++) {
6871
+ const [op, text] = diffs[index];
6872
+ const next = diffs[index + 1];
6873
+ if (op === -1 && next?.[0] === 1 && text !== next[1] && text.length === next[1].length && text.replace(/\u00a0/g, " ") === next[1].replace(/\u00a0/g, " ")) {
6874
+ refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
6875
+ index++;
6876
+ continue;
6877
+ }
6878
+ refined.push([op, text]);
6879
+ }
6880
+ return refined;
6881
+ }
6882
+ function collectTextEditOperations(diffs) {
6883
+ const operations = [];
6884
+ let originalPos = 0;
6885
+ let newPos = 0;
6886
+ for (let index = 0; index < diffs.length; index++) {
6887
+ const [op, text] = diffs[index];
6888
+ if (op === 0) {
6889
+ originalPos += text.length;
6890
+ newPos += text.length;
6891
+ continue;
6892
+ }
6893
+ if (op === -1) {
6894
+ const next = diffs[index + 1];
6895
+ if (next?.[0] === 1) {
6896
+ operations.push({
6897
+ type: "replace",
6898
+ start: originalPos,
6899
+ end: originalPos + text.length,
6900
+ newPos,
6901
+ text: next[1]
6902
+ });
6903
+ originalPos += text.length;
6904
+ newPos += next[1].length;
6905
+ index++;
6906
+ } else {
6907
+ operations.push({ type: "delete", start: originalPos, end: originalPos + text.length, newPos, text: "" });
6908
+ originalPos += text.length;
6909
+ }
6910
+ continue;
6911
+ }
6912
+ operations.push({ type: "insert", start: originalPos, end: originalPos, newPos, text });
6913
+ newPos += text.length;
6914
+ }
6915
+ return operations;
6916
+ }
6751
6917
  function collectInsertionOperations(diffs) {
6752
6918
  const operations = [];
6753
6919
  let originalPos = 0;
@@ -8266,6 +8432,51 @@ function getCommentIdsInOoxml(node) {
8266
8432
  }
8267
8433
  return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
8268
8434
  }
8435
+ function directWordChild(node, localName2) {
8436
+ return Array.from(node?.childNodes || []).find((child) => child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === localName2) || null;
8437
+ }
8438
+ function insertedParagraphMarkMetadata(paragraph, author) {
8439
+ const marker = directWordChild(directWordChild(directWordChild(paragraph, "pPr"), "rPr"), "ins");
8440
+ if (!marker) return null;
8441
+ const markerAuthor = marker.getAttribute("w:author") || marker.getAttributeNS(NS_W, "author") || "";
8442
+ if (markerAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase()) return null;
8443
+ return {
8444
+ id: marker.getAttribute("w:id") || marker.getAttributeNS(NS_W, "id") || "",
8445
+ author: markerAuthor,
8446
+ date: marker.getAttribute("w:date") || marker.getAttributeNS(NS_W, "date") || ""
8447
+ };
8448
+ }
8449
+ function emptyParagraphBaseline(paragraph, serializer) {
8450
+ const clone = paragraph.cloneNode(false);
8451
+ const pPr = directWordChild(paragraph, "pPr");
8452
+ if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
8453
+ return serializer.serializeToString(clone);
8454
+ }
8455
+ function restoreInsertedParagraphMark(oxml, metadata) {
8456
+ if (!metadata || typeof oxml !== "string" || !oxml.trim()) return oxml;
8457
+ const parsed = parseOoxmlSafe(oxml, "text/xml");
8458
+ if (!parsed.doc || parsed.error) return oxml;
8459
+ const paragraph = parsed.doc.documentElement?.localName === "p" ? parsed.doc.documentElement : getDocumentParagraphs(parsed.doc)[0];
8460
+ if (!paragraph) return oxml;
8461
+ let pPr = directWordChild(paragraph, "pPr");
8462
+ if (!pPr) {
8463
+ pPr = createWordElement(parsed.doc, "w:pPr");
8464
+ paragraph.insertBefore(pPr, paragraph.firstChild);
8465
+ }
8466
+ let rPr = directWordChild(pPr, "rPr");
8467
+ if (!rPr) {
8468
+ rPr = createWordElement(parsed.doc, "w:rPr");
8469
+ pPr.appendChild(rPr);
8470
+ }
8471
+ if (!directWordChild(rPr, "ins")) {
8472
+ const marker = createWordElement(parsed.doc, "w:ins");
8473
+ if (metadata.id) marker.setAttribute("w:id", metadata.id);
8474
+ marker.setAttribute("w:author", metadata.author);
8475
+ if (metadata.date) marker.setAttribute("w:date", metadata.date);
8476
+ rPr.appendChild(marker);
8477
+ }
8478
+ return serializeXml(parsed.doc);
8479
+ }
8269
8480
  async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
8270
8481
  const inputOoxml = oxml;
8271
8482
  let workingOoxml = oxml;
@@ -8277,6 +8488,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8277
8488
  let parseWarnings = [];
8278
8489
  const operationWarnings = [];
8279
8490
  let normalizedExistingRevisions = false;
8491
+ let preservedInsertedParagraphMark = null;
8280
8492
  const existingRevisionsPolicy = options.existingRevisions || "merge-same-author";
8281
8493
  const keepNormalizedNoOp = existingRevisionsPolicy === "accept-all-first-keep-normalized";
8282
8494
  const finalize = (result) => {
@@ -8307,6 +8519,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8307
8519
  if (!withStatus.status) {
8308
8520
  withStatus.status = withStatus.hasChanges ? "ok" : "no-op";
8309
8521
  }
8522
+ if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === "string") {
8523
+ withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
8524
+ }
8310
8525
  return withOoxmlSourceType(withStatus);
8311
8526
  };
8312
8527
  const finalizeUnchanged = () => {
@@ -8372,9 +8587,11 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8372
8587
  });
8373
8588
  }
8374
8589
  log("[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline");
8590
+ const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
8591
+ preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
8375
8592
  const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
8376
8593
  if (rejected.status === "error") return finalize(rejected);
8377
- workingOoxml = rejected.oxml;
8594
+ workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || "").trim() ? emptyParagraphBaseline(soleParagraph, serializer) : rejected.oxml;
8378
8595
  normalizedExistingRevisions = true;
8379
8596
  const rejectedParsed = parseOoxmlSafe(workingOoxml, "text/xml");
8380
8597
  parseWarnings.push(...rejectedParsed.warnings);
@@ -8416,7 +8633,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
8416
8633
  status: "error",
8417
8634
  error: {
8418
8635
  code: "EXISTING_REVISIONS",
8419
- message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
8636
+ message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
8637
+ revisionAuthors: authors,
8638
+ currentPolicy: existingRevisionsPolicy
8420
8639
  }
8421
8640
  });
8422
8641
  } else {
@@ -9024,9 +9243,12 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
9024
9243
  );
9025
9244
  }
9026
9245
  if (descriptor.fingerprint && descriptor.fingerprint !== actualFingerprint) {
9246
+ const alternateView = revisionView === "rejected" ? "accepted" : "rejected";
9247
+ const alternateFingerprint = createParagraphFingerprint(byId, { revisionView: alternateView });
9248
+ const viewHint = descriptor.fingerprint === alternateFingerprint ? ` The supplied fingerprint matches the ${alternateView} view; set target.revisionView to "${alternateView}" or use a fingerprint extracted from the ${revisionView} view.` : "";
9027
9249
  throw createTargetError(
9028
9250
  "TARGET_FINGERPRINT_MISMATCH",
9029
- `Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint.`,
9251
+ `Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint.${viewHint}`,
9030
9252
  cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
9031
9253
  );
9032
9254
  }
@@ -9038,9 +9260,14 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
9038
9260
  );
9039
9261
  }
9040
9262
  if (cleanTargetText && actualText !== normalizeWhitespaceForTargeting(cleanTargetText)) {
9263
+ const alternateView = revisionView === "rejected" ? "accepted" : "rejected";
9264
+ const alternateText = normalizeWhitespaceForTargeting(
9265
+ extractCanonicalParagraphText(byId, { revisionView: alternateView })
9266
+ );
9267
+ const viewHint = alternateText === normalizeWhitespaceForTargeting(cleanTargetText) ? ` The supplied text matches the ${alternateView} view; set target.revisionView to "${alternateView}".` : "";
9041
9268
  throw createTargetError(
9042
9269
  "TARGET_TEXT_MISMATCH",
9043
- `Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text.`,
9270
+ `Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text.${viewHint}`,
9044
9271
  cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
9045
9272
  );
9046
9273
  }
@@ -10140,11 +10367,12 @@ function parseOoxmlForValidation(oxml) {
10140
10367
  function validateRedlineOoxml(oxml) {
10141
10368
  const issues = [];
10142
10369
  const addIssue = (code, severity, message) => issues.push({ code, severity, message });
10143
- if (typeof oxml !== "string" || oxml.trim() === "") {
10144
- addIssue("PARSE_ERROR", "error", "Input is not a non-empty OOXML string.");
10370
+ const isDomNode = oxml && typeof oxml === "object" && (oxml.nodeType === 9 || oxml.nodeType === 1);
10371
+ if (!isDomNode && (typeof oxml !== "string" || oxml.trim() === "")) {
10372
+ addIssue("PARSE_ERROR", "error", "Input is not non-empty OOXML or a parsed XML DOM.");
10145
10373
  return { valid: false, issues };
10146
10374
  }
10147
- const { doc, error: error2 } = parseOoxmlForValidation(oxml);
10375
+ const { doc, error: error2 } = isDomNode ? { doc: oxml.nodeType === 9 ? oxml : oxml.ownerDocument } : parseOoxmlForValidation(oxml);
10148
10376
  if (!doc) {
10149
10377
  addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
10150
10378
  return { valid: false, issues };
@@ -11253,9 +11481,10 @@ function inspectDocumentParts(parts, options = {}) {
11253
11481
  const resolveNumbering = createNumberingResolver(numberingPart.doc);
11254
11482
  let nearestHeading = null;
11255
11483
  const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
11256
- const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, options.revisionView || "accepted");
11484
+ const revisionView = options.revisionView === "rejected" ? "rejected" : "accepted";
11485
+ const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, revisionView);
11257
11486
  let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
11258
- const text = extractCanonicalParagraphText(paragraph, { revisionView: options.revisionView || "accepted" });
11487
+ const text = extractCanonicalParagraphText(paragraph, { revisionView });
11259
11488
  const level = headingLevel(paragraph);
11260
11489
  if (level) nearestHeading = { level, text };
11261
11490
  const ids = [...new Set([...descendants(paragraph, "commentRangeStart"), ...descendants(paragraph, "commentReference")].map((node) => attr2(node, "id")).filter(Boolean))];
@@ -11272,7 +11501,8 @@ function inspectDocumentParts(parts, options = {}) {
11272
11501
  index,
11273
11502
  ref: `P${index}`,
11274
11503
  paragraphId: getParagraphId2(paragraph),
11275
- fingerprint: createParagraphFingerprint(paragraph),
11504
+ fingerprint: createParagraphFingerprint(paragraph, { text, index, revisionView }),
11505
+ revisionView,
11276
11506
  text,
11277
11507
  exactText: text,
11278
11508
  excerpt: text.slice(0, options.excerptLength || 120),