@ansonlai/docx-redline-js 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +646 -288
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -15,6 +15,78 @@ import {
15
15
  reconcileFormattingForTextSpan
16
16
  } from './surgical-diff-application.js';
17
17
  import { withOoxmlSourceType } from '../core/word-xml.js';
18
+ import { createReplacementRevisionEvent } from '../core/types.js';
19
+
20
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
21
+ const spans = [];
22
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
23
+ if (spans.length === 0) return { safe: false };
24
+
25
+ const firstRun = spans[0].runElement;
26
+ const parent = firstRun?.parentNode;
27
+ if (!parent) return { safe: false };
28
+
29
+ // All overlapping runs must share the exact same parent element
30
+ const sameParent = spans.every(s => s.runElement?.parentNode === parent);
31
+ if (!sameParent) return { safe: false, structuralBoundary: true };
32
+
33
+ // Parent container itself cannot be an existing revision or unsupported container
34
+ const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
35
+ if (
36
+ ['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
37
+ || (parentLocal === 'ins' && !allowInsertionCarrier)
38
+ ) {
39
+ return { safe: false, structuralBoundary: true };
40
+ }
41
+
42
+ const structuralTags = new Set([
43
+ 'hyperlink', 'fldSimple', 'sdt',
44
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference',
45
+ 'bookmarkStart', 'bookmarkEnd',
46
+ 'moveFrom', 'moveTo', 'ins', 'del'
47
+ ]);
48
+
49
+ for (const span of spans) {
50
+ const run = span.runElement;
51
+ for (const child of Array.from(run.childNodes || [])) {
52
+ if (child.nodeType === 1) {
53
+ const tag = child.localName || child.nodeName.replace(/^.*:/, '');
54
+ if (structuralTags.has(tag) || tag === 'fldChar') {
55
+ return { safe: false, structuralBoundary: true };
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ const lastRun = spans[spans.length - 1].runElement;
62
+ let curr = firstRun;
63
+ while (curr && curr !== lastRun) {
64
+ if (curr !== firstRun) {
65
+ const tag = curr.localName || curr.nodeName.replace(/^.*:/, '');
66
+ if (structuralTags.has(tag)) {
67
+ return { safe: false, structuralBoundary: true };
68
+ }
69
+ }
70
+ curr = curr.nextSibling;
71
+ }
72
+
73
+ function hasStructuralDescendant(node) {
74
+ if (!node || node.nodeType !== 1) return false;
75
+ const tag = node.localName || node.nodeName.replace(/^.*:/, '');
76
+ if (structuralTags.has(tag) || tag === 'fldChar') return true;
77
+ for (const child of Array.from(node.childNodes || [])) {
78
+ if (child.nodeType === 1 && hasStructuralDescendant(child)) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ // Inspect immediate adjacent siblings of the deleted range
84
+ if (hasStructuralDescendant(firstRun.previousSibling) || hasStructuralDescendant(lastRun.nextSibling)) {
85
+ return { safe: false, structuralBoundary: true };
86
+ }
87
+
88
+ return { safe: true };
89
+ }
18
90
 
19
91
  /**
20
92
  * Applies surgical mode reconciliation.
@@ -28,9 +100,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
28
100
  * @param {boolean} [generateRedlines=true] - Track change toggle
29
101
  * @param {Element|null} [targetParagraph=null] - Optional scope paragraph
30
102
  * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
31
- * @returns {{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment' }}
103
+ * @param {Object} [options={}] - Additional reconciliation options (e.g. pairReplacements)
104
+ * @returns {{ oxml: string, hasChanges: boolean, warnings?: string[], sourceType?: 'package'|'document'|'fragment' }}
32
105
  */
33
- export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
106
+ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}, options = {}) {
34
107
  void originalText;
35
108
 
36
109
  const allParagraphs = targetParagraph
@@ -40,12 +113,15 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
40
113
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
41
114
  const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
42
115
  const spanIndex = buildSpanIndex(textSpans);
116
+ const pairReplacements = options.pairReplacements === true;
117
+ const warnings = [];
43
118
 
44
119
  let originalPos = 0;
45
120
  let newPos = 0;
46
121
  let hasChanges = false;
47
122
 
48
- for (const [op, text] of diffs) {
123
+ for (let i = 0; i < diffs.length; i++) {
124
+ const [op, text] = diffs[i];
49
125
  if (op === 0) {
50
126
  const len = text.length;
51
127
  const startPos = originalPos;
@@ -67,14 +143,70 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
67
143
  originalPos += len;
68
144
  newPos += len;
69
145
  } else if (op === -1) {
70
- if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines)) {
146
+ const hasNextInsert = (i + 1 < diffs.length) && (diffs[i + 1][0] === 1);
147
+ let paired = false;
148
+ let delMetadata = null;
149
+ let insMetadata = null;
150
+
151
+ if (pairReplacements && generateRedlines && hasNextInsert) {
152
+ const nextText = diffs[i + 1][1];
153
+ const textWithoutNewlines = nextText.replace(/\n/g, ' ');
154
+ if (textWithoutNewlines.trim().length > 0) {
155
+ const checkResult = checkSafeAdjacencyForPairing(
156
+ spanIndex,
157
+ originalPos,
158
+ originalPos + text.length,
159
+ options?.existingRevisions === 'slice-cross-author'
160
+ );
161
+ if (checkResult.safe) {
162
+ const event = createReplacementRevisionEvent(author, xmlDoc);
163
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
164
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
165
+ paired = true;
166
+ } else if (checkResult.structuralBoundary) {
167
+ warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
168
+ }
169
+ }
170
+ }
171
+
172
+ if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
71
173
  hasChanges = true;
72
174
  }
73
175
  originalPos += text.length;
176
+
177
+ if (paired) {
178
+ i++;
179
+ const [, nextText] = diffs[i];
180
+ const textWithoutNewlines = nextText.replace(/\n/g, ' ');
181
+ if (textWithoutNewlines.trim().length > 0) {
182
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
183
+ if (insertResult && typeof insertResult === 'object' && insertResult.error) {
184
+ return withOoxmlSourceType({
185
+ oxml: serializer.serializeToString(xmlDoc),
186
+ hasChanges: false,
187
+ status: 'error',
188
+ error: insertResult.error
189
+ });
190
+ }
191
+ if (insertResult === true) {
192
+ hasChanges = true;
193
+ }
194
+ }
195
+ newPos += nextText.length;
196
+ }
74
197
  } else if (op === 1) {
75
198
  const textWithoutNewlines = text.replace(/\n/g, ' ');
76
199
  if (textWithoutNewlines.trim().length > 0) {
77
- if (processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null)) {
200
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
201
+ if (insertResult && typeof insertResult === 'object' && insertResult.error) {
202
+ return withOoxmlSourceType({
203
+ oxml: serializer.serializeToString(xmlDoc),
204
+ hasChanges: false,
205
+ status: 'error',
206
+ error: insertResult.error
207
+ });
208
+ }
209
+ if (insertResult === true) {
78
210
  hasChanges = true;
79
211
  }
80
212
  }
@@ -82,5 +214,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
82
214
  }
83
215
  }
84
216
 
85
- return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges });
217
+ return withOoxmlSourceType({
218
+ oxml: serializer.serializeToString(xmlDoc),
219
+ hasChanges,
220
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
221
+ });
86
222
  }
@@ -1,6 +1,15 @@
1
1
  import { createWordElement, isWordElement } from '../core/word-xml.js';
2
+ import {
3
+ NS_W,
4
+ RevisionIdAllocator,
5
+ createRevisionIdAllocator,
6
+ getRevisionIdAllocatorForDocument
7
+ } from '../core/types.js';
8
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
2
9
  import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
3
10
 
11
+ const TRACK_CHANGE_CARRIERS = new Set(['ins']);
12
+
4
13
  export function getRunContentPieces(runElement) {
5
14
  const pieces = [];
6
15
  let offset = 0;
@@ -62,6 +71,100 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
62
71
  return run;
63
72
  }
64
73
 
74
+ /**
75
+ * Splits a run-level tracked-change carrier at an accepted-view character
76
+ * offset without mutating the source carrier. The original revision ID stays
77
+ * with the leading fragment; an interior trailing fragment receives a fresh,
78
+ * document-scoped ID while all other carrier metadata remains unchanged.
79
+ *
80
+ * @param {Document} xmlDoc
81
+ * @param {Element} carrierElement
82
+ * @param {number} splitOffset
83
+ * @param {RevisionIdAllocator|null} [allocator=null]
84
+ * @returns {{ leftCarrier: Element|null, rightCarrier: Element|null }}
85
+ */
86
+ export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
87
+ const carrierName = getLocalName(carrierElement);
88
+ if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
89
+ throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
90
+ }
91
+ if (!Number.isInteger(splitOffset) || splitOffset < 0) {
92
+ throw new RangeError('splitOffset must be a non-negative integer.');
93
+ }
94
+
95
+ const children = Array.from(carrierElement.childNodes || []);
96
+ const totalLength = children.reduce((length, child) => {
97
+ return length + (isWordElement(child, 'r') ? getRunTextLength(getRunContentPieces(child)) : 0);
98
+ }, 0);
99
+ if (splitOffset > totalLength) {
100
+ throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
101
+ }
102
+
103
+ if (splitOffset === 0) {
104
+ return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
105
+ }
106
+ if (splitOffset === totalLength) {
107
+ return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
108
+ }
109
+
110
+ const leftCarrier = carrierElement.cloneNode(false);
111
+ const rightCarrier = carrierElement.cloneNode(false);
112
+ let offset = 0;
113
+
114
+ for (const child of children) {
115
+ if (!isWordElement(child, 'r')) {
116
+ const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
117
+ destination.appendChild(child.cloneNode(true));
118
+ continue;
119
+ }
120
+
121
+ const pieces = getRunContentPieces(child);
122
+ const runLength = getRunTextLength(pieces);
123
+ const runEnd = offset + runLength;
124
+
125
+ if (runEnd <= splitOffset) {
126
+ leftCarrier.appendChild(child.cloneNode(true));
127
+ } else if (offset >= splitOffset) {
128
+ rightCarrier.appendChild(child.cloneNode(true));
129
+ } else {
130
+ const localOffset = splitOffset - offset;
131
+ const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
132
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
133
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
134
+ leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
135
+ const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
136
+ refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
137
+ rightCarrier.appendChild(rightRun);
138
+ }
139
+ offset = runEnd;
140
+ }
141
+
142
+ const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
143
+ const nextId = resolvedAllocator.next();
144
+ setWordAttribute(rightCarrier, 'id', String(nextId));
145
+ resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
146
+
147
+ return { leftCarrier, rightCarrier };
148
+ }
149
+
150
+ function resolveAllocator(xmlDoc, allocator) {
151
+ return allocator instanceof RevisionIdAllocator
152
+ ? allocator
153
+ : (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
154
+ }
155
+
156
+ function setWordAttribute(element, localName, value) {
157
+ if (typeof element.setAttributeNS === 'function') {
158
+ element.setAttributeNS(NS_W, `w:${localName}`, value);
159
+ } else {
160
+ element.setAttribute(`w:${localName}`, value);
161
+ }
162
+ }
163
+
164
+ function getLocalName(element) {
165
+ return String(element?.localName || element?.nodeName || '').replace(/^.*:/, '');
166
+ }
167
+
65
168
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
66
169
  if (asDeletedText) {
67
170
  const delText = createWordElement(xmlDoc, 'w:delText');
@@ -7,6 +7,7 @@ export function getRunChildText(child) {
7
7
  if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
8
8
  if (isWordElement(child, 'tab')) return '\t';
9
9
  if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
10
+ if (isWordElement(child, 'softHyphen')) return '\u00ad';
10
11
  return '';
11
12
  }
12
13
 
@@ -15,7 +16,8 @@ export function isTextLikeRunChild(child) {
15
16
  || isWordElement(child, 'br')
16
17
  || isWordElement(child, 'cr')
17
18
  || isWordElement(child, 'tab')
18
- || isWordElement(child, 'noBreakHyphen');
19
+ || isWordElement(child, 'noBreakHyphen')
20
+ || isWordElement(child, 'softHyphen');
19
21
  }
20
22
 
21
23
  export function buildSurgicalTextSpans(paragraphs) {
@@ -34,6 +36,27 @@ export function buildSurgicalTextSpans(paragraphs) {
34
36
  fullText += processRunElement(hc, paragraph, container, fullText.length, textSpans).text;
35
37
  }
36
38
  }
39
+ } else if (isWordElement(child, 'ins')) {
40
+ for (let ic = child.firstChild; ic; ic = ic.nextSibling) {
41
+ if (isWordElement(ic, 'r')) {
42
+ fullText += processRunElement(ic, paragraph, container, fullText.length, textSpans).text;
43
+ }
44
+ }
45
+ } else if (isWordElement(child, 'sdt')) {
46
+ const sdtContent = Array.from(child.childNodes || []).find(n => isWordElement(n, 'sdtContent'));
47
+ if (sdtContent) {
48
+ for (let sc = sdtContent.firstChild; sc; sc = sc.nextSibling) {
49
+ if (isWordElement(sc, 'r')) {
50
+ fullText += processRunElement(sc, paragraph, container, fullText.length, textSpans).text;
51
+ }
52
+ }
53
+ }
54
+ } else if (isWordElement(child, 'smartTag')) {
55
+ for (let st = child.firstChild; st; st = st.nextSibling) {
56
+ if (isWordElement(st, 'r')) {
57
+ fullText += processRunElement(st, paragraph, container, fullText.length, textSpans).text;
58
+ }
59
+ }
37
60
  }
38
61
  }
39
62
 
@@ -167,3 +190,31 @@ function lowerBound(values, target) {
167
190
 
168
191
  return left;
169
192
  }
193
+
194
+ export function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
195
+ if (!spanIndex || !spanIndex.spans || spanIndex.spans.length === 0) {
196
+ return {
197
+ leftSpan: null,
198
+ rightSpan: null,
199
+ containingSpan: null,
200
+ isInterior: false,
201
+ fallbackParagraph
202
+ };
203
+ }
204
+
205
+ const containingSpan = findContainingSpan(spanIndex, pos);
206
+ const isInterior = containingSpan !== null && pos > containingSpan.charStart && pos < containingSpan.charEnd;
207
+
208
+ // leftSpan ends at or before pos
209
+ const leftSpan = findFirstSpanEndingAt(spanIndex, pos) || (pos > 0 ? findLastSpanEndingBeforeOrAt(spanIndex, pos) : null);
210
+ // rightSpan starts at or after pos
211
+ const rightSpan = spanIndex.spans.find(s => s.charStart === pos) || (pos === 0 ? spanIndex.spans[0] : null);
212
+
213
+ return {
214
+ leftSpan,
215
+ rightSpan,
216
+ containingSpan,
217
+ isInterior,
218
+ fallbackParagraph
219
+ };
220
+ }
@@ -11,6 +11,7 @@ import { buildParagraphOnlyPackage } from '../services/package-builder.js';
11
11
  import { getElementsByTagNSOrTag } from '../core/xml-query.js';
12
12
  import { NS_W } from '../core/types.js';
13
13
  import { isWordElement } from '../core/word-xml.js';
14
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
14
15
 
15
16
  const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
16
17
 
@@ -69,12 +70,8 @@ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
69
70
  if (originalText && originalText.trim()) {
70
71
  const normalizedTarget = originalText.trim();
71
72
  if (!targetParagraph) {
72
- for (const p of paragraphsInCells) {
73
- const textNodes = getElementsByTagNSOrTag(p, NS_W, 't');
74
- let paragraphText = '';
75
- for (const t of textNodes) {
76
- paragraphText += t.textContent || '';
77
- }
73
+ for (const p of paragraphsInCells) {
74
+ const paragraphText = extractCanonicalParagraphText(p);
78
75
 
79
76
  if (paragraphText.trim() === normalizedTarget) {
80
77
  targetParagraph = p;
@@ -165,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
165
165
  });
166
166
 
167
167
  const del = createWordElement(workingDoc, 'w:del');
168
- const metadata = createRevisionMetadata(author, workingDoc);
168
+ const metadata = createRevisionMetadata(author, workingDoc, 'del');
169
169
  del.setAttribute('w:id', String(metadata.id));
170
170
  del.setAttribute('w:author', metadata.author);
171
171
  del.setAttribute('w:date', metadata.date);