@ansonlai/docx-redline-js 0.9.0 → 0.11.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 (36) hide show
  1. package/ARCHITECTURE.md +691 -669
  2. package/CHANGELOG.md +67 -1
  3. package/README.md +39 -8
  4. package/core/word-xml.js +160 -118
  5. package/dist/docx-redline-js.esm.js +528 -312
  6. package/dist/docx-redline-js.esm.js.map +4 -4
  7. package/dist/docx-redline-js.esm.min.js +94 -94
  8. package/dist/docx-redline-js.esm.min.js.map +4 -4
  9. package/dist/docx-redline.bundle.cjs +528 -312
  10. package/dist/docx-redline.bundle.cjs.map +4 -4
  11. package/dist/docx-redline.bundle.js +528 -312
  12. package/dist/docx-redline.bundle.js.map +4 -4
  13. package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
  14. package/docs/SKILL_AUTHORING.md +5 -1
  15. package/docs/TESTING.md +10 -1
  16. package/docs/schemas/document-operations.schema.json +1 -1
  17. package/engine/oxml-engine.js +748 -736
  18. package/engine/table-mode.js +7 -4
  19. package/index.d.ts +29 -2
  20. package/index.js +3 -0
  21. package/node/cli.js +993 -992
  22. package/package.json +1 -1
  23. package/pipeline/content-analysis.js +2 -1
  24. package/pipeline/markdown-processor.js +5 -2
  25. package/pipeline/structured-content.js +2 -1
  26. package/services/comment-parts-merge.js +124 -0
  27. package/services/comment-replies.js +1 -1
  28. package/services/document-operation-applier.js +1 -0
  29. package/services/document-operation-contract.js +1 -0
  30. package/services/document-operation-mutations.js +3362 -3346
  31. package/services/inspection-comparison.js +82 -0
  32. package/services/operation-heuristics.js +1 -1
  33. package/services/operation-preflight.js +42 -33
  34. package/services/revision-fidelity-verifier.js +5 -19
  35. package/services/standalone-docx-plumbing.js +38 -22
  36. package/services/table-reconciliation.js +11 -6
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js standalone CommonJS bundle v0.9.0 — https://github.com/AnsonLai/docx-redline-js
1
+ // @ansonlai/docx-redline-js standalone CommonJS bundle v0.11.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;
@@ -8009,6 +8009,7 @@ __export(index_exports, {
8009
8009
  canonicalNumberingDefinition: () => canonicalNumberingDefinition,
8010
8010
  clearSingleLineListFallbackExplicitSequence: () => clearSingleLineListFallbackExplicitSequence,
8011
8011
  collectContiguousListParagraphBlock: () => collectContiguousListParagraphBlock,
8012
+ compareDocumentInspections: () => compareDocumentInspections,
8012
8013
  computeDocumentPartsRevisionToken: () => computeDocumentPartsRevisionToken,
8013
8014
  computePackageRevisionToken: () => computePackageRevisionToken,
8014
8015
  computeRevisionToken: () => computeRevisionToken,
@@ -8060,9 +8061,11 @@ __export(index_exports, {
8060
8061
  injectCommentsIntoPackage: () => injectCommentsIntoPackage2,
8061
8062
  insertBodyElementBeforeSectPr: () => insertBodyElementBeforeSectPr,
8062
8063
  inspectDocumentParts: () => inspectDocumentParts,
8064
+ inspectionReviewShape: () => inspectionReviewShape,
8063
8065
  isLikelyStructuredTableSourceParagraph: () => isLikelyStructuredTableSourceParagraph,
8064
8066
  isMarkdownTableText: () => isMarkdownTableText,
8065
8067
  isNodeVisibleInRevisionView: () => isNodeVisibleInRevisionView,
8068
+ mergeCommentParts: () => mergeCommentParts,
8066
8069
  mergeNumberingParts: () => mergeNumberingParts,
8067
8070
  mergeNumberingXmlBySchemaOrder: () => mergeNumberingXmlBySchemaOrder,
8068
8071
  normalizeBodySectionOrderStandalone: () => normalizeBodySectionOrderStandalone,
@@ -8089,6 +8092,7 @@ __export(index_exports, {
8089
8092
  repairKnownContentTypes: () => repairKnownContentTypes,
8090
8093
  reserveNextNumberingId: () => reserveNextNumberingId,
8091
8094
  reserveNextNumberingIdPair: () => reserveNextNumberingIdPair,
8095
+ resolveExistingRevisionsPolicy: () => resolveExistingRevisionsPolicy,
8092
8096
  resolveParagraphRangeByRefs: () => resolveParagraphRangeByRefs,
8093
8097
  resolveSingleLineListFallbackNumberingAction: () => resolveSingleLineListFallbackNumberingAction,
8094
8098
  resolveTargetParagraph: () => resolveTargetParagraph,
@@ -8287,7 +8291,10 @@ var MARKDOWN_PATTERNS = [
8287
8291
  { regex: /\*\*\+\+(.+?)\+\+\*\*/, format: { bold: true, underline: true } },
8288
8292
  // Bold: **text** or __text__
8289
8293
  { regex: /\*\*(.+?)\*\*/, format: { bold: true } },
8290
- { regex: /__(.+?)__/, format: { bold: true } },
8294
+ // Underscore delimiters follow CommonMark flanking rules: not intraword, not part of a
8295
+ // longer underscore run, and never around whitespace. So signature lines ("_____")
8296
+ // and snake_case identifiers stay literal text.
8297
+ { regex: /(?<![A-Za-z0-9_])__(?=[^_\s])(.+?)(?<=[^_\s])__(?![A-Za-z0-9_])/, format: { bold: true } },
8291
8298
  // Underline: ++text++
8292
8299
  { regex: /\+\+(.+?)\+\+/, format: { underline: true } },
8293
8300
  // Strikethrough: ~~text~~ or ~text~
@@ -8295,7 +8302,7 @@ var MARKDOWN_PATTERNS = [
8295
8302
  { regex: /~(.+?)~/, format: { strikethrough: true } },
8296
8303
  // Italic: *text* or _text_ (using lookahead only for compatibility)
8297
8304
  { regex: /\*(?!\*)(.+?)\*(?!\*)/, format: { italic: true } },
8298
- { regex: /_(?!_)(.+?)_(?!_)/, format: { italic: true } }
8305
+ { regex: /(?<![A-Za-z0-9_])_(?=[^_\s])(.+?)(?<=[^_\s])_(?![A-Za-z0-9_])/, format: { italic: true } }
8299
8306
  ];
8300
8307
  function preprocessMarkdown(text2) {
8301
8308
  if (!text2) {
@@ -10466,6 +10473,23 @@ function withOoxmlSourceType(result) {
10466
10473
  }
10467
10474
  return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
10468
10475
  }
10476
+ function hasUnattributedTrackedChange(node) {
10477
+ if (!node) return false;
10478
+ for (const localName4 of TRACKED_CHANGE_NAMES) {
10479
+ for (const change of wordElementsByLocalName(node, localName4)) {
10480
+ const author = change.getAttribute?.("w:author") || change.getAttribute?.("author") || (typeof change.getAttributeNS === "function" ? change.getAttributeNS(NS_W, "author") : null);
10481
+ if (!author || !String(author).trim()) return true;
10482
+ }
10483
+ }
10484
+ return false;
10485
+ }
10486
+ function resolveExistingRevisionsPolicy(policy, node, author) {
10487
+ if (policy !== "auto") return policy;
10488
+ const nodes = (Array.isArray(node) ? node : [node]).filter(Boolean);
10489
+ const own = String(author || "").trim().toLowerCase();
10490
+ const foreign = nodes.some((item) => containsTrackedChanges(item) && (hasUnattributedTrackedChange(item) || getTrackedChangeAuthors(item).some((name) => name.trim().toLowerCase() !== own)));
10491
+ return foreign ? "slice-cross-author" : "merge-same-author";
10492
+ }
10469
10493
 
10470
10494
  // engine/paragraph-mark-revisions.js
10471
10495
  function localNameOf(node) {
@@ -10572,6 +10596,7 @@ function paragraphMarkPPrXml(kind, ctx = {}) {
10572
10596
  }
10573
10597
 
10574
10598
  // services/table-reconciliation.js
10599
+ var cellMarkdown = (text2, options) => options?.plainTextContent === true ? { cleanText: String(text2 ?? ""), formatHints: [] } : preprocessMarkdown(text2);
10575
10600
  function generateTableOoxml(tableData, options = {}) {
10576
10601
  const {
10577
10602
  generateRedlines = false,
@@ -10605,7 +10630,7 @@ function generateTableOoxml(tableData, options = {}) {
10605
10630
  let cellsXml = "";
10606
10631
  for (let c = 0; c < numCols; c++) {
10607
10632
  const cellText = row[c] || "";
10608
- const { cleanText, formatHints } = preprocessMarkdown(cellText);
10633
+ const { cleanText, formatHints } = cellMarkdown(cellText, options);
10609
10634
  const runModel = [{
10610
10635
  kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
10611
10636
  text: cleanText,
@@ -10720,7 +10745,7 @@ function serializeVirtualGridToOoxml(grid, operations, options) {
10720
10745
  let cellsXml = "";
10721
10746
  const rowInsertMeta = generateRedlines ? createRevisionMetadata(author, revisionIdAllocator) : null;
10722
10747
  for (const cellText of op.cells) {
10723
- const { cleanText, formatHints } = preprocessMarkdown(cellText);
10748
+ const { cleanText, formatHints } = cellMarkdown(cellText, options);
10724
10749
  const runModel = [{
10725
10750
  kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
10726
10751
  text: cleanText,
@@ -10794,7 +10819,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
10794
10819
  }
10795
10820
  return;
10796
10821
  }
10797
- const { cleanText, formatHints } = preprocessMarkdown(line);
10822
+ const { cleanText, formatHints } = cellMarkdown(line, options);
10798
10823
  const diffOps = computeWordLevelDiffOps(block.acceptedText, cleanText);
10799
10824
  const model = applyPatches(splitRunsAtDiffBoundaries(block.runModel, diffOps), diffOps, {
10800
10825
  generateRedlines,
@@ -10804,7 +10829,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
10804
10829
  out += serializeToOoxml(model, block.pPr, formatHints, { author, generateRedlines, revisionIdAllocator });
10805
10830
  });
10806
10831
  for (let i2 = cell.blocks.length; i2 < lines.length; i2++) {
10807
- const { cleanText, formatHints } = preprocessMarkdown(lines[i2]);
10832
+ const { cleanText, formatHints } = cellMarkdown(lines[i2], options);
10808
10833
  const runModel = [{
10809
10834
  kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
10810
10835
  text: cleanText,
@@ -10823,7 +10848,7 @@ function reconcileCellContent(cell, newText, options) {
10823
10848
  return reconcileMultiBlockCellContent(cell, newText, options);
10824
10849
  }
10825
10850
  const { generateRedlines, author, revisionIdAllocator = null } = options;
10826
- const { cleanText, formatHints } = preprocessMarkdown(newText);
10851
+ const { cleanText, formatHints } = cellMarkdown(newText, options);
10827
10852
  const oldText = cell.getText();
10828
10853
  const diffOps = computeWordLevelDiffOps(oldText, cleanText);
10829
10854
  const baseBlock = cell.blocks[0] || { runModel: [], pPr: null };
@@ -10869,7 +10894,7 @@ function parseTable(text2) {
10869
10894
  }
10870
10895
  const isSeparatorLine = (line) => {
10871
10896
  const normalized = line.replace(/\s+/g, "");
10872
- return /^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(normalized);
10897
+ return /^\|:?-{3,}:?(?:(\|:?-{3,}:?)+\|?|\|)$/.test(normalized);
10873
10898
  };
10874
10899
  const hasHeader = lines.some(isSeparatorLine);
10875
10900
  const dataLines = lines.filter((line) => !isSeparatorLine(line));
@@ -11453,7 +11478,7 @@ var ReconciliationPipeline = class {
11453
11478
  };
11454
11479
 
11455
11480
  // pipeline/structured-content.js
11456
- var TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
11481
+ var TABLE_SEPARATOR = /^\s*(?:\|\s*:?-{3,}:?\s*\||\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?)\s*$/;
11457
11482
  function isBlank(line) {
11458
11483
  return !line || line.trim().length === 0;
11459
11484
  }
@@ -15110,7 +15135,7 @@ function keepUnchangedTextOutsideWrappers(diffs, mapping) {
15110
15135
  function noChanges(serializer, xmlDoc) {
15111
15136
  return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
15112
15137
  }
15113
- function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true) {
15138
+ function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines = true, extra = {}) {
15114
15139
  const tableNodes = getElementsByTagNSOrTag(xmlDoc, NS_W, "tbl");
15115
15140
  const newTableData = parseTable(modifiedText);
15116
15141
  const hasNewContent = newTableData.rows.length > 0 || newTableData.headers.length > 0;
@@ -15126,7 +15151,8 @@ function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, auth
15126
15151
  const options = {
15127
15152
  generateRedlines,
15128
15153
  author,
15129
- revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
15154
+ revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc),
15155
+ plainTextContent: extra.plainTextContent === true
15130
15156
  };
15131
15157
  const reconciledOxml = serializeVirtualGridToOoxml(oldGrid, operations, options);
15132
15158
  const wrappedOxml = `<root xmlns:w="${NS_W}">${reconciledOxml}</root>`;
@@ -15146,14 +15172,14 @@ function applyTableReconciliation(xmlDoc, modifiedText, serializer, parser, auth
15146
15172
  targetTable.parentNode.replaceChild(importedTable, targetTable);
15147
15173
  return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: true });
15148
15174
  }
15149
- function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
15175
+ function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines, extra = {}) {
15150
15176
  const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
15151
15177
  const tableData = parseTable(modifiedText);
15152
15178
  if (!tableData || tableData.rows.length === 0 && tableData.headers.length === 0) {
15153
15179
  log("[OxmlEngine] Failed to parse table data from Markdown");
15154
15180
  return noChanges(serializer, xmlDoc);
15155
15181
  }
15156
- const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator, trackAsBlock: true });
15182
+ const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator, trackAsBlock: true, plainTextContent: extra.plainTextContent === true });
15157
15183
  const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, "application/xml").doc;
15158
15184
  if (!tableDoc) return noChanges(serializer, xmlDoc);
15159
15185
  const tableParseError = getXmlParseError(tableDoc);
@@ -15976,7 +16002,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
15976
16002
  const operationWarnings = [];
15977
16003
  let normalizedExistingRevisions = false;
15978
16004
  let preservedInsertedParagraphMark = null;
15979
- const existingRevisionsPolicy = options.existingRevisions || "merge-same-author";
16005
+ let existingRevisionsPolicy = options.existingRevisions || "merge-same-author";
15980
16006
  const keepNormalizedNoOp = existingRevisionsPolicy === "accept-all-first-keep-normalized";
15981
16007
  const finalize = (result) => {
15982
16008
  const withStatus = { ...result };
@@ -16024,6 +16050,10 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
16024
16050
  const parsed = parseOoxmlSafe(inputOoxml, "text/xml");
16025
16051
  parseWarnings = parsed.warnings;
16026
16052
  let xmlDoc = parsed.doc;
16053
+ if (existingRevisionsPolicy === "auto") {
16054
+ existingRevisionsPolicy = resolveExistingRevisionsPolicy("auto", xmlDoc, author);
16055
+ options = { ...options, existingRevisions: existingRevisionsPolicy };
16056
+ }
16027
16057
  const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
16028
16058
  if (parsed.error || parseError) {
16029
16059
  const message = parsed.error?.message || parseError?.textContent || "Could not parse OOXML input.";
@@ -16058,7 +16088,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
16058
16088
  if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
16059
16089
  const authors = getTrackedChangeAuthors(xmlDoc);
16060
16090
  const currentAuthor = String(author || "").trim().toLowerCase();
16061
- const isSameAuthor = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === currentAuthor);
16091
+ const isSameAuthor = authors.length > 0 && !hasUnattributedTrackedChange(xmlDoc) && authors.every((a) => a.trim().toLowerCase() === currentAuthor);
16062
16092
  if (isSameAuthor) {
16063
16093
  const commentIds = getCommentIdsInOoxml(xmlDoc);
16064
16094
  if (commentIds.length > 0) {
@@ -16215,7 +16245,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
16215
16245
  sanitizedText = structuredAnalysis.normalizedMarkdown;
16216
16246
  }
16217
16247
  }
16218
- const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
16248
+ const { cleanText: cleanModifiedText, formatHints } = options.plainTextContent === true ? { cleanText: sanitizedText, formatHints: [] } : preprocessMarkdown(sanitizedText);
16219
16249
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
16220
16250
  const hasLiteralTextChanges = existingRevisionsPolicy === "slice-cross-author" ? cleanModifiedText !== originalText : cleanModifiedText.trim() !== originalText.trim();
16221
16251
  const hasListStructureChange = !hasLiteralTextChanges && options.explicitStructuredContent === true && isListTargetLoose(cleanModifiedText) && !sourceMatchesListShape(paragraphs, cleanModifiedText);
@@ -16348,11 +16378,11 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
16348
16378
  if (isMarkdownTable && !hasTables) {
16349
16379
  recordRouteSelection(options, "table", { transformation: "text-to-table" });
16350
16380
  log("[OxmlEngine] Text-to-table transformation: generating new table from Markdown");
16351
- return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
16381
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
16352
16382
  }
16353
16383
  if (hasTables && isMarkdownTable) {
16354
16384
  recordRouteSelection(options, "table", { transformation: "table-reconciliation" });
16355
- return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
16385
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
16356
16386
  }
16357
16387
  if (usesSurgicalTextMode) {
16358
16388
  recordRouteSelection(options, "surgical", { tableScoped: hasTables });
@@ -20047,6 +20077,70 @@ function inspectDocumentParts(parts, options = {}) {
20047
20077
  };
20048
20078
  }
20049
20079
 
20080
+ // services/inspection-comparison.js
20081
+ var commentIdentity = (comment) => [comment.author ?? null, comment.text ?? "", comment.anchoredText || ""];
20082
+ function reviewSegments(segments = []) {
20083
+ return segments.filter((segment) => segment.kind !== "baseline").reduce((result, segment) => {
20084
+ const next = {
20085
+ kind: segment.kind,
20086
+ author: segment.author || null,
20087
+ text: segment.text,
20088
+ acceptedStart: segment.acceptedStart ?? null,
20089
+ rejectedStart: segment.rejectedStart ?? null
20090
+ };
20091
+ const previous = result.at(-1);
20092
+ const adjacent = (key) => previous[key] == null && next[key] == null || previous[key] != null && previous[key] + previous.text.length === next[key];
20093
+ if (previous && previous.kind === next.kind && previous.author === next.author && adjacent("acceptedStart") && adjacent("rejectedStart")) previous.text += next.text;
20094
+ else result.push(next);
20095
+ return result;
20096
+ }, []);
20097
+ }
20098
+ function inspectionReviewShape(inspection) {
20099
+ const comments = inspection?.comments || [];
20100
+ return {
20101
+ paragraphs: (inspection?.paragraphs || []).map((paragraph) => ({
20102
+ text: paragraph.exactText,
20103
+ inTable: paragraph.inTable === true,
20104
+ table: paragraph.table ? [paragraph.table.rowIndex, paragraph.table.cellIndex] : null,
20105
+ list: paragraph.list ? [paragraph.list.format, paragraph.list.level, paragraph.list.label] : null,
20106
+ review: {
20107
+ present: paragraph.hasRevisions === true,
20108
+ authors: [...paragraph.revisionAuthors || []].sort(),
20109
+ segments: reviewSegments(paragraph.segments)
20110
+ }
20111
+ })),
20112
+ comments: comments.map((comment) => {
20113
+ const parent = comment.parentCommentId != null ? comments.find((item) => String(item.id) === String(comment.parentCommentId)) : null;
20114
+ return [
20115
+ ...commentIdentity(comment),
20116
+ comment.done === true,
20117
+ parent ? commentIdentity(parent) : comment.parentCommentId != null || comment.parentParaId ? "unresolved parent" : null
20118
+ ];
20119
+ }).map((item) => JSON.stringify(item)).sort()
20120
+ };
20121
+ }
20122
+ function compareDocumentInspections(expected, actual) {
20123
+ const left = inspectionReviewShape(expected);
20124
+ const right = inspectionReviewShape(actual);
20125
+ const differences = [];
20126
+ const note = (path, a, b) => {
20127
+ if (differences.length < 10 && JSON.stringify(a) !== JSON.stringify(b)) differences.push({ path, expected: a, actual: b });
20128
+ };
20129
+ note("paragraphs.length", left.paragraphs.length, right.paragraphs.length);
20130
+ const count = Math.max(left.paragraphs.length, right.paragraphs.length);
20131
+ for (let index = 0; index < count; index++) {
20132
+ const a = left.paragraphs[index] ?? null;
20133
+ const b = right.paragraphs[index] ?? null;
20134
+ if (!a || !b) {
20135
+ note(`paragraphs[${index}]`, a, b);
20136
+ continue;
20137
+ }
20138
+ for (const key of ["text", "inTable", "table", "list", "review"]) note(`paragraphs[${index}].${key}`, a[key], b[key]);
20139
+ }
20140
+ note("comments", left.comments, right.comments);
20141
+ return { matches: differences.length === 0, differences };
20142
+ }
20143
+
20050
20144
  // services/comment-locator.js
20051
20145
  function createParagraphTextIndex(paragraph, options = {}) {
20052
20146
  const revisionView = options.revisionView === "current" ? "accepted" : options.revisionView || "accepted";
@@ -20521,7 +20615,7 @@ function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = null, doc
20521
20615
  extendedDoc = parsed.doc;
20522
20616
  }
20523
20617
  const occupied = usedParaIds(commentsDoc, extendedDoc);
20524
- if (documentXml) for (const match of String(documentXml).matchAll(/w14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
20618
+ if (documentXml) for (const match of String(documentXml).matchAll(/\bw14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
20525
20619
  const parentParagraph = commentThreadParagraph(parent);
20526
20620
  if (!parentParagraph) return { status: "error", error: { code: "PARENT_COMMENT_INVALID", message: `Parent comment '${parentCommentId}' has no paragraph.` } };
20527
20621
  let parentParaId = commentParaId(parent);
@@ -20584,232 +20678,6 @@ function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = null, doc
20584
20678
  };
20585
20679
  }
20586
20680
 
20587
- // engine/formatting-removal.js
20588
- function removeNode2(node) {
20589
- if (node?.parentNode) {
20590
- node.parentNode.removeChild(node);
20591
- }
20592
- }
20593
- function removeFormattingFromRPr(rPr, formatTypes = ["all"]) {
20594
- if (!rPr) return null;
20595
- const rPrClone = rPr.cloneNode(true);
20596
- if (formatTypes.includes("all")) {
20597
- const toRemove = [
20598
- "w:b",
20599
- "w:i",
20600
- "w:u",
20601
- "w:strike",
20602
- "w:dstrike",
20603
- "w:color",
20604
- "w:sz",
20605
- "w:szCs",
20606
- "w:rFonts",
20607
- "w:highlight",
20608
- "w:vertAlign",
20609
- "w:spacing",
20610
- "w:w",
20611
- "w:kern",
20612
- "w:position"
20613
- ];
20614
- toRemove.forEach((tag) => {
20615
- const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
20616
- elements.forEach(removeNode2);
20617
- });
20618
- } else {
20619
- const tagMap = {
20620
- "bold": "w:b",
20621
- "italic": "w:i",
20622
- "underline": "w:u",
20623
- "strikethrough": "w:strike",
20624
- "doubleStrike": "w:dstrike",
20625
- "color": "w:color",
20626
- "highlight": "w:highlight",
20627
- "fontSize": "w:sz",
20628
- "fontSizeCs": "w:szCs",
20629
- // Complex script font size
20630
- "fontFamily": "w:rFonts",
20631
- "superscript": "w:vertAlign",
20632
- "subscript": "w:vertAlign"
20633
- };
20634
- formatTypes.forEach((type) => {
20635
- const tag = tagMap[type];
20636
- if (tag) {
20637
- const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
20638
- elements.forEach(removeNode2);
20639
- }
20640
- });
20641
- }
20642
- return rPrClone.children.length > 0 ? rPrClone : null;
20643
- }
20644
- function applyFormattingRemovalToOoxml(ooxmlString, targetText, formatTypes) {
20645
- if (!targetText || !ooxmlString) return ooxmlString;
20646
- const doc = parseOoxml(ooxmlString);
20647
- if (!doc) return ooxmlString;
20648
- const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
20649
- const runs = doc.getElementsByTagNameNS(NS_W11, "r");
20650
- const insertions = doc.getElementsByTagNameNS(NS_W11, "ins");
20651
- const allRuns = [...Array.from(runs)];
20652
- for (const ins of insertions) {
20653
- const insideRuns = ins.getElementsByTagNameNS(NS_W11, "r");
20654
- allRuns.push(...Array.from(insideRuns));
20655
- }
20656
- for (const run of allRuns) {
20657
- const textNodes = run.getElementsByTagNameNS(NS_W11, "t");
20658
- const runText = Array.from(textNodes).map((t) => t.textContent).join("");
20659
- if (runText.includes(targetText) || runText === targetText) {
20660
- const rPrElements = run.getElementsByTagNameNS(NS_W11, "rPr");
20661
- if (rPrElements.length > 0) {
20662
- const rPr = rPrElements[0];
20663
- const newRPr = removeFormattingFromRPr(rPr, formatTypes);
20664
- if (newRPr) {
20665
- if (rPr.parentNode) {
20666
- rPr.parentNode.replaceChild(newRPr, rPr);
20667
- }
20668
- } else {
20669
- removeNode2(rPr);
20670
- }
20671
- }
20672
- }
20673
- }
20674
- return serializeOoxml(doc);
20675
- }
20676
- var HIGHLIGHT_COLOR_MAP = {
20677
- "yellow": "yellow",
20678
- "green": "green",
20679
- "cyan": "cyan",
20680
- "magenta": "magenta",
20681
- "blue": "blue",
20682
- "red": "red",
20683
- "darkblue": "darkBlue",
20684
- "darkcyan": "darkCyan",
20685
- "darkgreen": "darkGreen",
20686
- "darkmagenta": "darkMagenta",
20687
- "darkred": "darkRed",
20688
- "darkyellow": "darkYellow",
20689
- "gray25": "lightGray",
20690
- "gray50": "darkGray",
20691
- "black": "black",
20692
- "white": "white"
20693
- };
20694
- function injectHighlightIntoRPr(doc, rPr, color = "yellow", options = {}) {
20695
- const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
20696
- const ooxmlColor = HIGHLIGHT_COLOR_MAP[color.toLowerCase()] || "yellow";
20697
- const generateRedlines = options?.generateRedlines ?? false;
20698
- const author = options?.author || getDefaultAuthor();
20699
- let rPrElement = rPr;
20700
- if (!rPrElement) {
20701
- rPrElement = createWordElement(doc, "w:rPr");
20702
- } else {
20703
- rPrElement = rPr.cloneNode(true);
20704
- }
20705
- let previousRPrState = null;
20706
- if (generateRedlines) {
20707
- previousRPrState = createWordElement(doc, "w:rPr");
20708
- Array.from(rPrElement.childNodes).forEach((child) => {
20709
- if (child.nodeName !== "w:rPrChange") {
20710
- previousRPrState.appendChild(child.cloneNode(true));
20711
- }
20712
- });
20713
- }
20714
- const existingHighlight = rPrElement.getElementsByTagNameNS(NS_W11, "highlight");
20715
- Array.from(existingHighlight).forEach(removeNode2);
20716
- const highlightEl = createWordElement(doc, "w:highlight");
20717
- highlightEl.setAttributeNS(NS_W11, "w:val", ooxmlColor);
20718
- rPrElement.appendChild(highlightEl);
20719
- if (generateRedlines && previousRPrState) {
20720
- const rPrChange = createWordElement(doc, "w:rPrChange");
20721
- const metadata = createRevisionMetadata(author, doc);
20722
- rPrChange.setAttribute("w:id", String(metadata.id));
20723
- rPrChange.setAttribute("w:author", metadata.author);
20724
- rPrChange.setAttribute("w:date", metadata.date);
20725
- rPrChange.appendChild(previousRPrState);
20726
- const existingChange = rPrElement.getElementsByTagNameNS(NS_W11, "rPrChange");
20727
- Array.from(existingChange).forEach(removeNode2);
20728
- rPrElement.appendChild(rPrChange);
20729
- }
20730
- return rPrElement;
20731
- }
20732
- function applyHighlightToOoxml(ooxmlString, targetText, color = "yellow", options = {}) {
20733
- if (!targetText || !ooxmlString) return ooxmlString;
20734
- const doc = parseOoxml(ooxmlString);
20735
- if (!doc) return ooxmlString;
20736
- let revisionIdAllocator;
20737
- if (options?._revisionIdAllocator instanceof RevisionIdAllocator) {
20738
- revisionIdAllocator = options._revisionIdAllocator;
20739
- seedRevisionIdsFromDocument(doc, revisionIdAllocator);
20740
- } else {
20741
- revisionIdAllocator = createRevisionIdAllocator(doc);
20742
- }
20743
- const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
20744
- const getRunText = (run) => {
20745
- const textNodes = run.getElementsByTagNameNS(NS_W11, "t");
20746
- return Array.from(textNodes).map((t) => t.textContent).join("");
20747
- };
20748
- const allRuns = Array.from(doc.getElementsByTagNameNS(NS_W11, "r"));
20749
- const sourceRunsWithClaimedRevisionIds = /* @__PURE__ */ new WeakSet();
20750
- const cloneRunWithText2 = (sourceRun, text2, shouldHighlight) => {
20751
- const clonedRun = sourceRun.cloneNode(true);
20752
- if (sourceRunsWithClaimedRevisionIds.has(sourceRun)) {
20753
- refreshRunPropertyChangeIds(clonedRun, revisionIdAllocator);
20754
- } else {
20755
- sourceRunsWithClaimedRevisionIds.add(sourceRun);
20756
- }
20757
- const textNodes = clonedRun.getElementsByTagNameNS(NS_W11, "t");
20758
- Array.from(textNodes).forEach(removeNode2);
20759
- const newText = createWordElement(doc, "w:t");
20760
- newText.setAttribute("xml:space", "preserve");
20761
- newText.textContent = text2;
20762
- clonedRun.appendChild(newText);
20763
- if (shouldHighlight) {
20764
- const rPrElements = clonedRun.getElementsByTagNameNS(NS_W11, "rPr");
20765
- const existingRPr = rPrElements.length > 0 ? rPrElements[0] : null;
20766
- const newRPr = injectHighlightIntoRPr(doc, existingRPr, color, options);
20767
- if (existingRPr) {
20768
- clonedRun.replaceChild(newRPr, existingRPr);
20769
- } else {
20770
- clonedRun.insertBefore(newRPr, clonedRun.firstChild);
20771
- }
20772
- }
20773
- return clonedRun;
20774
- };
20775
- for (const run of allRuns) {
20776
- const runText = getRunText(run);
20777
- if (!runText) continue;
20778
- const matchIndexes = [];
20779
- let searchOffset = 0;
20780
- while (searchOffset <= runText.length - targetText.length) {
20781
- const matchIndex = runText.indexOf(targetText, searchOffset);
20782
- if (matchIndex === -1) break;
20783
- matchIndexes.push(matchIndex);
20784
- searchOffset = matchIndex + targetText.length;
20785
- }
20786
- if (matchIndexes.length === 0) continue;
20787
- const parent = run.parentNode;
20788
- if (!parent) {
20789
- console.warn("[Highlight] Run parent is null; skipping. Likely already processed.");
20790
- continue;
20791
- }
20792
- const fragment = doc.createDocumentFragment();
20793
- let cursor = 0;
20794
- for (const matchIndex of matchIndexes) {
20795
- if (matchIndex > cursor) {
20796
- fragment.appendChild(cloneRunWithText2(run, runText.slice(cursor, matchIndex), false));
20797
- }
20798
- fragment.appendChild(cloneRunWithText2(
20799
- run,
20800
- runText.slice(matchIndex, matchIndex + targetText.length),
20801
- true
20802
- ));
20803
- cursor = matchIndex + targetText.length;
20804
- }
20805
- if (cursor < runText.length) {
20806
- fragment.appendChild(cloneRunWithText2(run, runText.slice(cursor), false));
20807
- }
20808
- parent.replaceChild(fragment, run);
20809
- }
20810
- return serializeOoxml(doc);
20811
- }
20812
-
20813
20681
  // services/package-parts.js
20814
20682
  var WORD_ML = "application/vnd.openxmlformats-officedocument.wordprocessingml";
20815
20683
  var REL_OFFICE_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
@@ -21097,31 +20965,34 @@ async function ensureNumberingArtifactsInZip(zip, numberingXmlList, options = {}
21097
20965
  }
21098
20966
  }
21099
20967
  }
20968
+ function mergeCommentsPartXml(existingXml, commentsXml, options = {}) {
20969
+ if (!existingXml || options.replaceExisting === true) return commentsXml;
20970
+ const existingDoc = parseXmlStrictStandalone(existingXml, "word/comments.xml (existing)");
20971
+ const incomingDoc = parseXmlStrictStandalone(commentsXml, "word/comments.xml (incoming)");
20972
+ const incomingComments = Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W7, "comment"));
20973
+ if (incomingComments.length === 0) return existingXml;
20974
+ const existingRoot = existingDoc.documentElement;
20975
+ const usedIds = new Set(
20976
+ Array.from(existingRoot.getElementsByTagNameNS(NS_W7, "comment")).map((comment) => comment.getAttribute("w:id") || comment.getAttribute("id")).filter(Boolean)
20977
+ );
20978
+ for (const incomingComment of incomingComments) {
20979
+ const id = incomingComment.getAttribute("w:id") || incomingComment.getAttribute("id");
20980
+ if (id && usedIds.has(id)) {
20981
+ throw Object.assign(new Error(`Duplicate comment id: ${id}`), { code: "DUPLICATE_COMMENT_ID", commentId: id });
20982
+ }
20983
+ if (id) usedIds.add(id);
20984
+ existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
20985
+ }
20986
+ return createSerializer().serializeToString(existingDoc);
20987
+ }
21100
20988
  async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
21101
20989
  const onInfo = typeof options?.onInfo === "function" ? options.onInfo : () => {
21102
20990
  };
21103
20991
  if (!commentsXml) return;
21104
20992
  const serializer = createSerializer();
21105
20993
  const existingText = await readZipText(zip, COMMENTS_PATH);
21106
- if (!existingText || options.replaceExisting === true) {
21107
- onInfo("[Demo] Adding comments.xml");
21108
- zip.file(COMMENTS_PATH, commentsXml);
21109
- } else {
21110
- const existingDoc = parseXmlStrictStandalone(existingText, "word/comments.xml (existing)");
21111
- const incomingDoc = parseXmlStrictStandalone(commentsXml, "word/comments.xml (incoming)");
21112
- const existingRoot = existingDoc.documentElement;
21113
- const existingIds = new Set(
21114
- Array.from(existingRoot.getElementsByTagNameNS(NS_W7, "comment")).map((comment) => comment.getAttribute("w:id") || comment.getAttribute("id")).filter(Boolean)
21115
- );
21116
- for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W7, "comment"))) {
21117
- const id = incomingComment.getAttribute("w:id") || incomingComment.getAttribute("id");
21118
- if (id && existingIds.has(id)) {
21119
- throw new Error(`Duplicate comment id: ${id}`);
21120
- }
21121
- existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
21122
- }
21123
- zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
21124
- }
20994
+ if (!existingText || options.replaceExisting === true) onInfo("[Demo] Adding comments.xml");
20995
+ zip.file(COMMENTS_PATH, mergeCommentsPartXml(existingText, commentsXml, options));
21125
20996
  const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
21126
20997
  if (ctText) {
21127
20998
  const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
@@ -21357,6 +21228,318 @@ async function validateDocxPackage(zip) {
21357
21228
  }
21358
21229
  }
21359
21230
 
21231
+ // services/comment-parts-merge.js
21232
+ var PART_KEYS = Object.freeze({
21233
+ comments: "commentsXml",
21234
+ commentsExtended: "commentsExtendedXml",
21235
+ commentsIds: "commentsIdsXml",
21236
+ commentsExtensible: "commentsExtensibleXml"
21237
+ });
21238
+ var partsOf = (parts) => Object.fromEntries(Object.values(PART_KEYS).map((key) => [key, parts?.[key] || null]));
21239
+ function mergeCommentParts(parts = {}, output = {}) {
21240
+ const before = partsOf(parts);
21241
+ const after = { ...before };
21242
+ const fail = (error2) => ({ status: "error", hasChanges: false, parts: before, changedParts: [], addedParts: [], error: error2 });
21243
+ const commentsMode = output?.commentsXmlMode === "replace" ? "replace" : "merge";
21244
+ const extendedMode = output?.commentsExtendedXmlMode === "replace" ? "replace" : "merge";
21245
+ for (const [label, value] of [
21246
+ ...Object.values(PART_KEYS).map((key) => [`parts.${key}`, before[key]]),
21247
+ ["output.commentsXml", output?.commentsXml],
21248
+ ["output.commentsExtendedXml", output?.commentsExtendedXml]
21249
+ ]) {
21250
+ if (value != null && typeof value !== "string") {
21251
+ return fail({ code: "INVALID_COMMENT_PARTS", message: `${label} must be an XML string, null or omitted (got ${typeof value}).` });
21252
+ }
21253
+ }
21254
+ const requirePart = (xml, part, rootName, label) => {
21255
+ let doc;
21256
+ try {
21257
+ doc = parseXmlStrictStandalone(xml, label);
21258
+ } catch (error2) {
21259
+ throw Object.assign(error2, { part });
21260
+ }
21261
+ if (doc.documentElement?.localName !== rootName) {
21262
+ throw Object.assign(new Error(`${label}: expected a <${rootName}> root element, found <${doc.documentElement?.localName || "none"}>.`), { code: "INVALID_COMMENT_PARTS", part });
21263
+ }
21264
+ return doc;
21265
+ };
21266
+ try {
21267
+ if (output?.commentsXml) {
21268
+ const incomingDoc = requirePart(output.commentsXml, "comments", "comments", "word/comments.xml (incoming)");
21269
+ const seen = /* @__PURE__ */ new Set();
21270
+ for (const node of Array.from(incomingDoc.getElementsByTagNameNS("*", "comment"))) {
21271
+ const id = node.getAttribute("w:id") || node.getAttribute("id");
21272
+ if (!id) continue;
21273
+ if (seen.has(id)) {
21274
+ throw Object.assign(new Error(`Duplicate comment id: ${id}`), { code: "DUPLICATE_COMMENT_ID", commentId: id, part: "comments" });
21275
+ }
21276
+ seen.add(id);
21277
+ }
21278
+ if (before.commentsXml) requirePart(before.commentsXml, "comments", "comments", "word/comments.xml (existing)");
21279
+ if (before.commentsIdsXml) requirePart(before.commentsIdsXml, "commentsIds", "commentsIds", "word/commentsIds.xml (existing)");
21280
+ if (before.commentsExtensibleXml) requirePart(before.commentsExtensibleXml, "commentsExtensible", "commentsExtensible", "word/commentsExtensible.xml (existing)");
21281
+ after.commentsXml = mergeCommentsPartXml(before.commentsXml, output.commentsXml, { replaceExisting: commentsMode === "replace" });
21282
+ Object.assign(after, reconcileCommentSiblingParts({
21283
+ commentsXml: after.commentsXml,
21284
+ commentsIdsXml: after.commentsIdsXml,
21285
+ commentsExtensibleXml: after.commentsExtensibleXml
21286
+ }));
21287
+ }
21288
+ if (output?.commentsExtendedXml) {
21289
+ if (extendedMode !== "replace" && before.commentsExtendedXml) {
21290
+ return fail({
21291
+ code: "UNSUPPORTED_EXISTING_COMMENT_THREAD_METADATA",
21292
+ part: "commentsExtended",
21293
+ message: 'The output carries a commentsExtended delta (mode "merge") for parts that already have thread metadata; a delta cannot be merged into an existing commentsExtended.xml. Nothing was merged.'
21294
+ });
21295
+ }
21296
+ requirePart(output.commentsExtendedXml, "commentsExtended", "commentsEx", "word/commentsExtended.xml (incoming)");
21297
+ after.commentsExtendedXml = output.commentsExtendedXml;
21298
+ }
21299
+ } catch (error2) {
21300
+ return fail({
21301
+ code: error2?.code || "COMMENT_PARTS_MERGE_FAILED",
21302
+ message: error2?.message || String(error2),
21303
+ ...error2?.part || error2?.code === "DUPLICATE_COMMENT_ID" ? { part: error2.part || "comments" } : {},
21304
+ ...error2?.commentId ? { commentId: error2.commentId } : {}
21305
+ });
21306
+ }
21307
+ const changedParts = [];
21308
+ const addedParts = [];
21309
+ for (const [name, key] of Object.entries(PART_KEYS)) {
21310
+ if (after[key] === before[key]) continue;
21311
+ changedParts.push(name);
21312
+ if (!before[key]) addedParts.push(name);
21313
+ }
21314
+ return { status: "ok", hasChanges: changedParts.length > 0, parts: after, changedParts, addedParts };
21315
+ }
21316
+
21317
+ // engine/formatting-removal.js
21318
+ function removeNode2(node) {
21319
+ if (node?.parentNode) {
21320
+ node.parentNode.removeChild(node);
21321
+ }
21322
+ }
21323
+ function removeFormattingFromRPr(rPr, formatTypes = ["all"]) {
21324
+ if (!rPr) return null;
21325
+ const rPrClone = rPr.cloneNode(true);
21326
+ if (formatTypes.includes("all")) {
21327
+ const toRemove = [
21328
+ "w:b",
21329
+ "w:i",
21330
+ "w:u",
21331
+ "w:strike",
21332
+ "w:dstrike",
21333
+ "w:color",
21334
+ "w:sz",
21335
+ "w:szCs",
21336
+ "w:rFonts",
21337
+ "w:highlight",
21338
+ "w:vertAlign",
21339
+ "w:spacing",
21340
+ "w:w",
21341
+ "w:kern",
21342
+ "w:position"
21343
+ ];
21344
+ toRemove.forEach((tag) => {
21345
+ const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
21346
+ elements.forEach(removeNode2);
21347
+ });
21348
+ } else {
21349
+ const tagMap = {
21350
+ "bold": "w:b",
21351
+ "italic": "w:i",
21352
+ "underline": "w:u",
21353
+ "strikethrough": "w:strike",
21354
+ "doubleStrike": "w:dstrike",
21355
+ "color": "w:color",
21356
+ "highlight": "w:highlight",
21357
+ "fontSize": "w:sz",
21358
+ "fontSizeCs": "w:szCs",
21359
+ // Complex script font size
21360
+ "fontFamily": "w:rFonts",
21361
+ "superscript": "w:vertAlign",
21362
+ "subscript": "w:vertAlign"
21363
+ };
21364
+ formatTypes.forEach((type) => {
21365
+ const tag = tagMap[type];
21366
+ if (tag) {
21367
+ const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace("w:", "")}`);
21368
+ elements.forEach(removeNode2);
21369
+ }
21370
+ });
21371
+ }
21372
+ return rPrClone.children.length > 0 ? rPrClone : null;
21373
+ }
21374
+ function applyFormattingRemovalToOoxml(ooxmlString, targetText, formatTypes) {
21375
+ if (!targetText || !ooxmlString) return ooxmlString;
21376
+ const doc = parseOoxml(ooxmlString);
21377
+ if (!doc) return ooxmlString;
21378
+ const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
21379
+ const runs = doc.getElementsByTagNameNS(NS_W11, "r");
21380
+ const insertions = doc.getElementsByTagNameNS(NS_W11, "ins");
21381
+ const allRuns = [...Array.from(runs)];
21382
+ for (const ins of insertions) {
21383
+ const insideRuns = ins.getElementsByTagNameNS(NS_W11, "r");
21384
+ allRuns.push(...Array.from(insideRuns));
21385
+ }
21386
+ for (const run of allRuns) {
21387
+ const textNodes = run.getElementsByTagNameNS(NS_W11, "t");
21388
+ const runText = Array.from(textNodes).map((t) => t.textContent).join("");
21389
+ if (runText.includes(targetText) || runText === targetText) {
21390
+ const rPrElements = run.getElementsByTagNameNS(NS_W11, "rPr");
21391
+ if (rPrElements.length > 0) {
21392
+ const rPr = rPrElements[0];
21393
+ const newRPr = removeFormattingFromRPr(rPr, formatTypes);
21394
+ if (newRPr) {
21395
+ if (rPr.parentNode) {
21396
+ rPr.parentNode.replaceChild(newRPr, rPr);
21397
+ }
21398
+ } else {
21399
+ removeNode2(rPr);
21400
+ }
21401
+ }
21402
+ }
21403
+ }
21404
+ return serializeOoxml(doc);
21405
+ }
21406
+ var HIGHLIGHT_COLOR_MAP = {
21407
+ "yellow": "yellow",
21408
+ "green": "green",
21409
+ "cyan": "cyan",
21410
+ "magenta": "magenta",
21411
+ "blue": "blue",
21412
+ "red": "red",
21413
+ "darkblue": "darkBlue",
21414
+ "darkcyan": "darkCyan",
21415
+ "darkgreen": "darkGreen",
21416
+ "darkmagenta": "darkMagenta",
21417
+ "darkred": "darkRed",
21418
+ "darkyellow": "darkYellow",
21419
+ "gray25": "lightGray",
21420
+ "gray50": "darkGray",
21421
+ "black": "black",
21422
+ "white": "white"
21423
+ };
21424
+ function injectHighlightIntoRPr(doc, rPr, color = "yellow", options = {}) {
21425
+ const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
21426
+ const ooxmlColor = HIGHLIGHT_COLOR_MAP[color.toLowerCase()] || "yellow";
21427
+ const generateRedlines = options?.generateRedlines ?? false;
21428
+ const author = options?.author || getDefaultAuthor();
21429
+ let rPrElement = rPr;
21430
+ if (!rPrElement) {
21431
+ rPrElement = createWordElement(doc, "w:rPr");
21432
+ } else {
21433
+ rPrElement = rPr.cloneNode(true);
21434
+ }
21435
+ let previousRPrState = null;
21436
+ if (generateRedlines) {
21437
+ previousRPrState = createWordElement(doc, "w:rPr");
21438
+ Array.from(rPrElement.childNodes).forEach((child) => {
21439
+ if (child.nodeName !== "w:rPrChange") {
21440
+ previousRPrState.appendChild(child.cloneNode(true));
21441
+ }
21442
+ });
21443
+ }
21444
+ const existingHighlight = rPrElement.getElementsByTagNameNS(NS_W11, "highlight");
21445
+ Array.from(existingHighlight).forEach(removeNode2);
21446
+ const highlightEl = createWordElement(doc, "w:highlight");
21447
+ highlightEl.setAttributeNS(NS_W11, "w:val", ooxmlColor);
21448
+ rPrElement.appendChild(highlightEl);
21449
+ if (generateRedlines && previousRPrState) {
21450
+ const rPrChange = createWordElement(doc, "w:rPrChange");
21451
+ const metadata = createRevisionMetadata(author, doc);
21452
+ rPrChange.setAttribute("w:id", String(metadata.id));
21453
+ rPrChange.setAttribute("w:author", metadata.author);
21454
+ rPrChange.setAttribute("w:date", metadata.date);
21455
+ rPrChange.appendChild(previousRPrState);
21456
+ const existingChange = rPrElement.getElementsByTagNameNS(NS_W11, "rPrChange");
21457
+ Array.from(existingChange).forEach(removeNode2);
21458
+ rPrElement.appendChild(rPrChange);
21459
+ }
21460
+ return rPrElement;
21461
+ }
21462
+ function applyHighlightToOoxml(ooxmlString, targetText, color = "yellow", options = {}) {
21463
+ if (!targetText || !ooxmlString) return ooxmlString;
21464
+ const doc = parseOoxml(ooxmlString);
21465
+ if (!doc) return ooxmlString;
21466
+ let revisionIdAllocator;
21467
+ if (options?._revisionIdAllocator instanceof RevisionIdAllocator) {
21468
+ revisionIdAllocator = options._revisionIdAllocator;
21469
+ seedRevisionIdsFromDocument(doc, revisionIdAllocator);
21470
+ } else {
21471
+ revisionIdAllocator = createRevisionIdAllocator(doc);
21472
+ }
21473
+ const NS_W11 = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
21474
+ const getRunText = (run) => {
21475
+ const textNodes = run.getElementsByTagNameNS(NS_W11, "t");
21476
+ return Array.from(textNodes).map((t) => t.textContent).join("");
21477
+ };
21478
+ const allRuns = Array.from(doc.getElementsByTagNameNS(NS_W11, "r"));
21479
+ const sourceRunsWithClaimedRevisionIds = /* @__PURE__ */ new WeakSet();
21480
+ const cloneRunWithText2 = (sourceRun, text2, shouldHighlight) => {
21481
+ const clonedRun = sourceRun.cloneNode(true);
21482
+ if (sourceRunsWithClaimedRevisionIds.has(sourceRun)) {
21483
+ refreshRunPropertyChangeIds(clonedRun, revisionIdAllocator);
21484
+ } else {
21485
+ sourceRunsWithClaimedRevisionIds.add(sourceRun);
21486
+ }
21487
+ const textNodes = clonedRun.getElementsByTagNameNS(NS_W11, "t");
21488
+ Array.from(textNodes).forEach(removeNode2);
21489
+ const newText = createWordElement(doc, "w:t");
21490
+ newText.setAttribute("xml:space", "preserve");
21491
+ newText.textContent = text2;
21492
+ clonedRun.appendChild(newText);
21493
+ if (shouldHighlight) {
21494
+ const rPrElements = clonedRun.getElementsByTagNameNS(NS_W11, "rPr");
21495
+ const existingRPr = rPrElements.length > 0 ? rPrElements[0] : null;
21496
+ const newRPr = injectHighlightIntoRPr(doc, existingRPr, color, options);
21497
+ if (existingRPr) {
21498
+ clonedRun.replaceChild(newRPr, existingRPr);
21499
+ } else {
21500
+ clonedRun.insertBefore(newRPr, clonedRun.firstChild);
21501
+ }
21502
+ }
21503
+ return clonedRun;
21504
+ };
21505
+ for (const run of allRuns) {
21506
+ const runText = getRunText(run);
21507
+ if (!runText) continue;
21508
+ const matchIndexes = [];
21509
+ let searchOffset = 0;
21510
+ while (searchOffset <= runText.length - targetText.length) {
21511
+ const matchIndex = runText.indexOf(targetText, searchOffset);
21512
+ if (matchIndex === -1) break;
21513
+ matchIndexes.push(matchIndex);
21514
+ searchOffset = matchIndex + targetText.length;
21515
+ }
21516
+ if (matchIndexes.length === 0) continue;
21517
+ const parent = run.parentNode;
21518
+ if (!parent) {
21519
+ console.warn("[Highlight] Run parent is null; skipping. Likely already processed.");
21520
+ continue;
21521
+ }
21522
+ const fragment = doc.createDocumentFragment();
21523
+ let cursor = 0;
21524
+ for (const matchIndex of matchIndexes) {
21525
+ if (matchIndex > cursor) {
21526
+ fragment.appendChild(cloneRunWithText2(run, runText.slice(cursor, matchIndex), false));
21527
+ }
21528
+ fragment.appendChild(cloneRunWithText2(
21529
+ run,
21530
+ runText.slice(matchIndex, matchIndex + targetText.length),
21531
+ true
21532
+ ));
21533
+ cursor = matchIndex + targetText.length;
21534
+ }
21535
+ if (cursor < runText.length) {
21536
+ fragment.appendChild(cloneRunWithText2(run, runText.slice(cursor), false));
21537
+ }
21538
+ parent.replaceChild(fragment, run);
21539
+ }
21540
+ return serializeOoxml(doc);
21541
+ }
21542
+
21360
21543
  // orchestration/route-plan.js
21361
21544
  var RoutePlanKind = Object.freeze({
21362
21545
  STRUCTURED_LIST_DIRECT: "structured_list_direct",
@@ -22823,6 +23006,7 @@ var SUPPORTED_OPERATION_TYPES = /* @__PURE__ */ new Set([
22823
23006
  "highlight"
22824
23007
  ]);
22825
23008
  var EXISTING_REVISIONS_POLICIES = Object.freeze([
23009
+ "auto",
22826
23010
  "merge-same-author",
22827
23011
  "slice-cross-author",
22828
23012
  "reject-input",
@@ -25060,7 +25244,7 @@ function deriveParagraphThenTableAppend(modifiedText) {
25060
25244
  const tableLines = lines.slice(1);
25061
25245
  if (!tableLines.every((line) => line.trim().startsWith("|"))) return null;
25062
25246
  const separator = tableLines[1].replace(/\s+/g, "");
25063
- if (!/^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(separator)) return null;
25247
+ if (!/^\|:?-{3,}:?(?:(\|:?-{3,}:?)+\|?|\|)$/.test(separator)) return null;
25064
25248
  return { paragraphMarkdown: lines[0].trim(), tableText: tableLines.join("\n") };
25065
25249
  }
25066
25250
 
@@ -25690,7 +25874,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text2, revis
25690
25874
  String(text2 || ""),
25691
25875
  {
25692
25876
  author,
25693
- generateRedlines: false
25877
+ generateRedlines: false,
25878
+ // A caller's structuredContent: false keeps inserted paragraphs plain too: no headings,
25879
+ // and inline Markdown markers stay literal.
25880
+ ...options.plainTextContent === true ? { structuredContent: false, plainTextContent: true } : {}
25694
25881
  }
25695
25882
  );
25696
25883
  let sourceParagraph = null;
@@ -26877,13 +27064,17 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
26877
27064
  }
26878
27065
  };
26879
27066
  }
27067
+ const autoExistingRevisions = options.existingRevisions === "auto";
27068
+ if (autoExistingRevisions) {
27069
+ options = { ...options, existingRevisions: resolveExistingRevisionsPolicy("auto", targetParagraph, author) };
27070
+ }
26880
27071
  const hasRevisions = containsTrackedChanges(targetParagraph);
26881
27072
  const existingPolicy = options.existingRevisions || "merge-same-author";
26882
27073
  if (hasRevisions && existingPolicy !== "accept-all-first" && existingPolicy !== "accept-all-first-keep-normalized") {
26883
27074
  if (existingPolicy === "merge-same-author" || existingPolicy === "slice-cross-author") {
26884
27075
  const authors = getTrackedChangeAuthors(targetParagraph);
26885
27076
  const opAuthor = String(author || "").trim().toLowerCase();
26886
- const allSame = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === opAuthor);
27077
+ const allSame = authors.length > 0 && !hasUnattributedTrackedChange(targetParagraph) && authors.every((a) => a.trim().toLowerCase() === opAuthor);
26887
27078
  if (!allSame && existingPolicy === "merge-same-author") {
26888
27079
  return {
26889
27080
  documentXml,
@@ -26892,7 +27083,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
26892
27083
  status: "error",
26893
27084
  error: {
26894
27085
  code: "EXISTING_REVISIONS",
26895
- message: `Target paragraph 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.`,
27086
+ message: `Target paragraph contains tracked changes from another author (${[...authors, ...hasUnattributedTrackedChange(targetParagraph) ? ["unattributed"] : []].join(", ")}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
26896
27087
  revisionAuthors: authors,
26897
27088
  currentPolicy: existingPolicy
26898
27089
  }
@@ -27034,6 +27225,9 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
27034
27225
  onWarn
27035
27226
  }) : typeof targetText === "string" && /\r?\n/.test(targetText) ? findReconstructionParagraphRange(xmlDoc, targetText) : null;
27036
27227
  const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
27228
+ if (autoExistingRevisions && hasExplicitRangeScope) {
27229
+ options = { ...options, existingRevisions: resolveExistingRevisionsPolicy("auto", explicitRangeParagraphs, author) };
27230
+ }
27037
27231
  if (!useTableScope && hasExplicitRangeScope) {
27038
27232
  const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
27039
27233
  if (insertionEntries && insertionEntries.length > 0) {
@@ -27209,7 +27403,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
27209
27403
  author,
27210
27404
  // A wholly inserted paragraph must track its own paragraph mark; otherwise Word's
27211
27405
  // Reject All removes the text but leaves an empty paragraph behind.
27212
- { generateRedlines, trackParagraphMark: true }
27406
+ { generateRedlines, trackParagraphMark: true, plainTextContent: options.plainTextContent === true }
27213
27407
  );
27214
27408
  const imported = xmlDoc.importNode(plainParagraph, true);
27215
27409
  ensureParagraphIdsOnImportedNode(imported, operationSession);
@@ -27348,6 +27542,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
27348
27542
  existingRevisions: options.existingRevisions || "merge-same-author",
27349
27543
  structuredContent: options.structuredContent !== false,
27350
27544
  explicitStructuredContent: options.explicitStructuredContent === true,
27545
+ plainTextContent: options.plainTextContent === true,
27351
27546
  _revisionIdAllocator: revisionIdAllocator,
27352
27547
  _isolatedTableCell: useTableScope
27353
27548
  }) : await applyRedlineToOxml2(scopedXml, originalTextForApply, effectiveModifiedText, {
@@ -27359,6 +27554,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
27359
27554
  pairReplacements: options.pairReplacements === true,
27360
27555
  insertionAffinity: options.insertionAffinity || null,
27361
27556
  literalTextEdit: options.literalTextEdit === true,
27557
+ plainTextContent: options.plainTextContent === true,
27362
27558
  _revisionIdAllocator: revisionIdAllocator,
27363
27559
  _isolatedTableCell: useTableScope
27364
27560
  });
@@ -28369,7 +28565,31 @@ function verifyUntrackedLocality({ before, after, targetSourceIndex, beforeViews
28369
28565
  }
28370
28566
  return violations.length ? { ok: false, violations } : { ok: true };
28371
28567
  }
28372
- function verifyNumberingReferences({ before, after, numberingXmls, beforeViews = null, afterViews = null }) {
28568
+ var numberingDefinitionCache = /* @__PURE__ */ new Map();
28569
+ var NUMBERING_DEFINITION_CACHE_SIZE = 8;
28570
+ function parseNumberingDefinitions(text2) {
28571
+ const cached = numberingDefinitionCache.get(text2);
28572
+ if (cached) return cached;
28573
+ const abstractLevels = /* @__PURE__ */ new Map();
28574
+ for (const match of text2.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
28575
+ const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
28576
+ if (id == null) continue;
28577
+ const levels = abstractLevels.get(id) || /* @__PURE__ */ new Set();
28578
+ for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
28579
+ abstractLevels.set(id, levels);
28580
+ }
28581
+ const numToAbstract = /* @__PURE__ */ new Map();
28582
+ for (const match of text2.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
28583
+ const id = /numId="([^"]*)"/.exec(match[1])?.[1];
28584
+ const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
28585
+ if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
28586
+ }
28587
+ const definitions = { abstractLevels, numToAbstract };
28588
+ if (numberingDefinitionCache.size >= NUMBERING_DEFINITION_CACHE_SIZE) numberingDefinitionCache.delete(numberingDefinitionCache.keys().next().value);
28589
+ numberingDefinitionCache.set(text2, definitions);
28590
+ return definitions;
28591
+ }
28592
+ function verifyNumberingReferences({ before, after, numberingXmls }) {
28373
28593
  const references = (documentNode) => {
28374
28594
  const found = /* @__PURE__ */ new Map();
28375
28595
  for (const numIdNode of getAllReferencedNumIdElements(documentNode)) {
@@ -28385,21 +28605,7 @@ function verifyNumberingReferences({ before, after, numberingXmls, beforeViews =
28385
28605
  const used = references(after);
28386
28606
  if (used.size === 0) return { ok: true };
28387
28607
  const known = references(before);
28388
- const text2 = (numberingXmls || []).filter(Boolean).join("\n");
28389
- const abstractLevels = /* @__PURE__ */ new Map();
28390
- for (const match of text2.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
28391
- const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
28392
- if (id == null) continue;
28393
- const levels = abstractLevels.get(id) || /* @__PURE__ */ new Set();
28394
- for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
28395
- abstractLevels.set(id, levels);
28396
- }
28397
- const numToAbstract = /* @__PURE__ */ new Map();
28398
- for (const match of text2.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
28399
- const id = /numId="([^"]*)"/.exec(match[1])?.[1];
28400
- const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
28401
- if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
28402
- }
28608
+ const { abstractLevels, numToAbstract } = parseNumberingDefinitions((numberingXmls || []).filter(Boolean).join("\n"));
28403
28609
  const violations = [];
28404
28610
  for (const [numId, levels] of used) {
28405
28611
  if (known.has(numId)) continue;
@@ -28748,6 +28954,7 @@ async function applyOperationToDocumentXml(documentXml, op, author, runtimeConte
28748
28954
  ...operation.existingRevisions ? { existingRevisions: operation.existingRevisions } : {},
28749
28955
  structuredContent: typeof operation.structuredContent === "boolean" ? operation.structuredContent : options.structuredContent !== false,
28750
28956
  explicitStructuredContent: operation.structuredContent === true,
28957
+ plainTextContent: operation.structuredContent === false || operation.structuredContent == null && options.structuredContent === false,
28751
28958
  ...operation._localizedReplacementCompilation && operation.structuredContent !== true ? { literalTextEdit: true } : {},
28752
28959
  pairReplacements: typeof operation.pairReplacements === "boolean" ? operation.pairReplacements : options.pairReplacements !== false,
28753
28960
  ...operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {},
@@ -30002,35 +30209,44 @@ function preflightOperations(documentXml, operations, author, options = {}) {
30002
30209
  const anchor = operation.operationKind === "comment" ? operation.textToComment || paragraphText : operation.operationKind === "highlight" ? operation.textToHighlight : operation.operationKind === "format" ? operation.textToFormat : null;
30003
30210
  const anchorResolution = operation.operationKind === "comment" && anchor != null ? resolveTextInParagraphIndex(createParagraphTextIndex(paragraph, { revisionView: targetView }), anchor) : null;
30004
30211
  const anchorFound = anchor == null || (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
30212
+ const rangeParagraphs = (opType) => {
30213
+ if (operation.targetEndRef) {
30214
+ return resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
30215
+ opType,
30216
+ targetRefSnapshot: options.targetRefSnapshot || null,
30217
+ onInfo: options.onInfo,
30218
+ onWarn: options.onWarn
30219
+ });
30220
+ }
30221
+ if (!operation.targetEndDescriptor) return [paragraph];
30222
+ const endResolved = resolveTargetParagraph(xmlDoc, {
30223
+ targetText: operation.targetEndDescriptor.text,
30224
+ targetRef: operation.targetEndDescriptor.index,
30225
+ targetDescriptor: operation.targetEndDescriptor,
30226
+ opType,
30227
+ strictAmbiguity: strictTargets,
30228
+ paragraphMetadataIndex: currentMetadataIndex,
30229
+ metadataIndices,
30230
+ onInfo: options.onInfo,
30231
+ onWarn: options.onWarn
30232
+ });
30233
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
30234
+ const startIndex = allParagraphs.indexOf(paragraph);
30235
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
30236
+ return startIndex >= 0 && endIndex >= startIndex ? allParagraphs.slice(startIndex, endIndex + 1) : null;
30237
+ };
30005
30238
  const hasRevisions = containsTrackedChanges(paragraph);
30006
- const existingPolicy = operation.existingRevisions || options.existingRevisions || "merge-same-author";
30239
+ const requestedPolicy = operation.existingRevisions || options.existingRevisions || "merge-same-author";
30240
+ const existingPolicy = resolveExistingRevisionsPolicy(
30241
+ requestedPolicy,
30242
+ requestedPolicy === "auto" && operation.operationKind === "redline" && (operation.targetEndRef || operation.targetEndDescriptor) ? rangeParagraphs("redline") || paragraph : paragraph,
30243
+ authorUsed
30244
+ );
30007
30245
  const deletingWholeParagraph = operation.operationKind === "redline" && operation.modified === "";
30008
30246
  const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
30009
30247
  let error2 = null;
30010
30248
  if (operation.operationKind === "restore") {
30011
- let restorationParagraphs = operation.targetEndRef ? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
30012
- opType: "restore",
30013
- targetRefSnapshot: options.targetRefSnapshot || null,
30014
- onInfo: options.onInfo,
30015
- onWarn: options.onWarn
30016
- }) : [paragraph];
30017
- if (!operation.targetEndRef && operation.targetEndDescriptor) {
30018
- const endResolved = resolveTargetParagraph(xmlDoc, {
30019
- targetText: operation.targetEndDescriptor.text,
30020
- targetRef: operation.targetEndDescriptor.index,
30021
- targetDescriptor: operation.targetEndDescriptor,
30022
- opType: "restore",
30023
- strictAmbiguity: strictTargets,
30024
- paragraphMetadataIndex: currentMetadataIndex,
30025
- metadataIndices,
30026
- onInfo: options.onInfo,
30027
- onWarn: options.onWarn
30028
- });
30029
- const allParagraphs = getDocumentParagraphNodes(xmlDoc);
30030
- const startIndex = allParagraphs.indexOf(paragraph);
30031
- const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
30032
- restorationParagraphs = startIndex >= 0 && endIndex >= startIndex ? allParagraphs.slice(startIndex, endIndex + 1) : null;
30033
- }
30249
+ const restorationParagraphs = rangeParagraphs("restore");
30034
30250
  const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
30035
30251
  if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
30036
30252
  error2 = {
@@ -30077,11 +30293,11 @@ function preflightOperations(documentXml, operations, author, options = {}) {
30077
30293
  if (existingPolicy === "merge-same-author" || existingPolicy === "slice-cross-author") {
30078
30294
  const authors = getTrackedChangeAuthors(paragraph);
30079
30295
  const opAuthor = String(authorUsed || "").trim().toLowerCase();
30080
- const allSame = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === opAuthor);
30296
+ const allSame = authors.length > 0 && !hasUnattributedTrackedChange(paragraph) && authors.every((a) => a.trim().toLowerCase() === opAuthor);
30081
30297
  if (!allSame && existingPolicy === "merge-same-author") {
30082
30298
  error2 = {
30083
30299
  code: "EXISTING_REVISIONS",
30084
- message: `Target paragraph 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.`,
30300
+ message: `Target paragraph contains tracked changes from another author (${[...authors, ...hasUnattributedTrackedChange(paragraph) ? ["unattributed"] : []].join(", ")}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
30085
30301
  revisionAuthors: authors,
30086
30302
  currentPolicy: existingPolicy
30087
30303
  };