@ansonlai/docx-redline-js 0.2.1 → 0.4.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 (86) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1124 -545
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +84 -99
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +3 -3
  36. package/engine/surgical-mode.js +3 -2
  37. package/engine/table-mode.js +27 -16
  38. package/index.d.ts +95 -3
  39. package/index.js +14 -13
  40. package/orchestration/list-structural-fallback.js +16 -39
  41. package/package.json +22 -4
  42. package/pipeline/diff-engine.js +174 -55
  43. package/pipeline/ingestion-export.js +39 -24
  44. package/pipeline/ingestion-paragraph.js +7 -5
  45. package/pipeline/list-generation.js +27 -18
  46. package/pipeline/patching.js +2 -3
  47. package/pipeline/pipeline.js +65 -36
  48. package/pipeline/serialization.js +13 -5
  49. package/scripts/build-test-dashboard.mjs +43 -0
  50. package/scripts/check-types.mjs +16 -24
  51. package/scripts/export-validation-fixtures.mjs +191 -45
  52. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  53. package/scripts/generate-test-dashboard.mjs +199 -0
  54. package/scripts/inspect-visual-evidence.mjs +271 -0
  55. package/scripts/lib/minimal-zip.mjs +199 -18
  56. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  57. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  58. package/scripts/lib/zip-reader.mjs +64 -0
  59. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  60. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  61. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  62. package/scripts/prepare-word-review.mjs +77 -0
  63. package/scripts/prepare-word-visual-review.mjs +90 -0
  64. package/scripts/render-agenda-multilevel.mjs +70 -0
  65. package/scripts/render-case22.mjs +73 -0
  66. package/scripts/render-case40.ps1 +35 -0
  67. package/scripts/render-multilevel-bullet-images.py +58 -0
  68. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  69. package/scripts/render-multilevel-cases.mjs +80 -0
  70. package/scripts/report-coverage-gaps.mjs +103 -0
  71. package/scripts/report-word-coverage.mjs +71 -0
  72. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  73. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  74. package/scripts/word-com-corpus-suite.ps1 +43 -0
  75. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  76. package/scripts/word-com-differential.ps1 +158 -16
  77. package/scripts/word-com-suite.ps1 +19 -0
  78. package/scripts/word-com-visual-suite.ps1 +132 -0
  79. package/services/comment-engine.js +51 -46
  80. package/services/comment-locator.js +0 -1
  81. package/services/comment-package.js +11 -10
  82. package/services/numbering-service.js +1 -1
  83. package/services/revision-comment-management.js +31 -10
  84. package/services/standalone-docx-plumbing.js +45 -34
  85. package/services/standalone-operation-runner.js +315 -75
  86. package/services/table-reconciliation.js +23 -11
@@ -11,8 +11,9 @@ import {
11
11
  markParagraphMarkDeleted,
12
12
  markParagraphMarkInserted
13
13
  } from './run-builders.js';
14
- import { getFirstElementByTag } from '../core/xml-query.js';
15
- import { createWordElement } from '../core/word-xml.js';
14
+ import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
15
+ import { NS_W } from '../core/types.js';
16
+ import { createWordElement, isWordElement } from '../core/word-xml.js';
16
17
 
17
18
  /**
18
19
  * Applies diffs to reconstruction context and writes updated XML.
@@ -28,9 +29,8 @@ import { createWordElement } from '../core/word-xml.js';
28
29
  */
29
30
  export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true) {
30
31
  const {
31
- paragraphs,
32
- paragraphMap,
33
- containerFragments,
32
+ paragraphs,
33
+ containerFragments,
34
34
  sentinelMapByStart,
35
35
  referenceMap,
36
36
  replacementContainers,
@@ -55,12 +55,23 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
55
55
 
56
56
  let currentOriginalIndex = 0;
57
57
  let currentInsertOffset = 0;
58
+ // A replacement is represented as one or more deletions followed by an
59
+ // insertion. Remember where that deleted range began so replacement text
60
+ // inherits the formatting at the start of the range, rather than the last
61
+ // deleted character (which may be a superscript ordinal, footnote-style
62
+ // run, or another narrow formatting boundary).
63
+ let pendingReplacementStart = null;
58
64
  const emittedCommentMarkers = new WeakSet();
59
-
60
- for (const [op, text] of diffs) {
61
- if (op === 0 || op === -1) {
62
- const type = op === 0 ? 'equal' : 'delete';
63
- let offset = 0;
65
+
66
+ for (const [op, text] of diffs) {
67
+ if (op === 0 || op === -1) {
68
+ const type = op === 0 ? 'equal' : 'delete';
69
+ if (op === 0) {
70
+ pendingReplacementStart = null;
71
+ } else if (pendingReplacementStart === null) {
72
+ pendingReplacementStart = currentOriginalIndex;
73
+ }
74
+ let offset = 0;
64
75
 
65
76
  while (offset < text.length) {
66
77
  const chunkStart = currentOriginalIndex + offset;
@@ -99,11 +110,14 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
99
110
  currentOriginalIndex += text.length;
100
111
  continue;
101
112
  }
102
-
103
- if (op === 1) {
104
- const properties = currentOriginalIndex > 0 && !isParagraphStart(currentOriginalIndex)
105
- ? getRunProperties(currentOriginalIndex - 1)
106
- : getRunProperties(currentOriginalIndex);
113
+
114
+ if (op === 1) {
115
+ const propertyIndex = pendingReplacementStart !== null
116
+ ? pendingReplacementStart
117
+ : (currentOriginalIndex > 0 && !isParagraphStart(currentOriginalIndex)
118
+ ? currentOriginalIndex - 1
119
+ : currentOriginalIndex);
120
+ const properties = getRunProperties(propertyIndex);
107
121
 
108
122
  const appendResult = appendTextToCurrent(
109
123
  xmlDoc,
@@ -127,69 +141,93 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
127
141
  );
128
142
  currentParagraph = appendResult.currentParagraph;
129
143
  currentInsertOffset += text.length;
144
+ pendingReplacementStart = null;
130
145
  }
131
146
  }
132
147
 
133
- paragraphs.forEach(paragraph => {
134
- if (paragraph.parentNode) {
135
- paragraph.parentNode.removeChild(paragraph);
136
- }
137
- });
138
-
139
- containerFragments.forEach((fragment, container) => {
140
- const replacement = replacementContainers.get(container);
141
- const target = replacement || container;
142
-
143
- if (target.nodeType === 9) {
144
- const firstChild = fragment.firstChild;
145
- if (firstChild) {
146
- target.appendChild(firstChild);
147
- while (fragment.firstChild) {
148
- target.documentElement.appendChild(fragment.firstChild);
149
- }
150
- }
151
- return;
152
- }
153
-
154
- target.appendChild(fragment);
155
- });
156
-
157
- return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
158
- }
159
-
160
- function appendTextToCurrent(
161
- xmlDoc,
162
- text,
163
- type,
164
- rPr,
165
- wrapper,
166
- baseIndex,
167
- currentParagraphRef,
168
- containerFragments,
169
- sentinelMapByStart,
170
- referenceMap,
171
- replacementContainers,
172
- getParagraphInfo,
173
- createNewParagraph,
174
- author,
175
- formatHints = [],
176
- insertOffset = 0,
148
+ const paragraphSet = new Set(paragraphs);
149
+ const insertionAnchors = new Map();
150
+ paragraphs.forEach(paragraph => {
151
+ const container = paragraph.parentNode;
152
+ if (!container || insertionAnchors.has(container)) return;
153
+ let anchor = paragraph.nextSibling;
154
+ while (anchor && paragraphSet.has(anchor)) anchor = anchor.nextSibling;
155
+ insertionAnchors.set(container, anchor);
156
+ });
157
+
158
+ paragraphs.forEach(paragraph => {
159
+ if (paragraph.parentNode) {
160
+ paragraph.parentNode.removeChild(paragraph);
161
+ }
162
+ });
163
+
164
+ let hasDocumentTarget = false;
165
+ let serializedDocumentOutput = '';
166
+
167
+ containerFragments.forEach((fragment, container) => {
168
+ const replacement = replacementContainers.get(container);
169
+ const target = replacement || container;
170
+
171
+ if (target.nodeType === 9) {
172
+ hasDocumentTarget = true;
173
+ if (fragment.childNodes.length === 1) {
174
+ target.appendChild(fragment.firstChild);
175
+ } else {
176
+ serializedDocumentOutput = Array.from(fragment.childNodes)
177
+ .map(node => serializer.serializeToString(node))
178
+ .join('');
179
+ }
180
+ return;
181
+ }
182
+
183
+ const anchor = replacement ? null : insertionAnchors.get(container);
184
+ if (anchor && anchor.parentNode === target) {
185
+ target.insertBefore(fragment, anchor);
186
+ } else {
187
+ target.appendChild(fragment);
188
+ }
189
+ });
190
+
191
+ const oxml = (hasDocumentTarget && serializedDocumentOutput)
192
+ ? serializedDocumentOutput
193
+ : serializer.serializeToString(xmlDoc);
194
+
195
+ return { oxml, hasChanges: true };
196
+ }
197
+
198
+ function appendTextToCurrent(
199
+ xmlDoc,
200
+ text,
201
+ type,
202
+ rPr,
203
+ wrapper,
204
+ baseIndex,
205
+ currentParagraphRef,
206
+ containerFragments,
207
+ sentinelMapByStart,
208
+ referenceMap,
209
+ replacementContainers,
210
+ getParagraphInfo,
211
+ createNewParagraph,
212
+ author,
213
+ formatHints = [],
214
+ insertOffset = 0,
177
215
  generateRedlines = true,
178
216
  emittedCommentMarkers = new WeakSet()
179
217
  ) {
180
- let localBaseIndex = baseIndex;
181
- let localInsertOffset = insertOffset;
182
- let localParagraph = currentParagraphRef;
183
-
184
- const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
185
-
186
- parts.forEach(part => {
187
- const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
218
+ let localBaseIndex = baseIndex;
219
+ let localInsertOffset = insertOffset;
220
+ let localParagraph = currentParagraphRef;
221
+
222
+ const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
223
+
224
+ parts.forEach(part => {
225
+ const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
188
226
  const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker && !emittedCommentMarkers.has(sentinel.node));
189
227
 
190
228
  commentMarkers.forEach(marker => {
191
229
  emittedCommentMarkers.add(marker.node);
192
- if (marker.node.nodeName === 'w:commentReference') {
230
+ if (isWordElement(marker.node, 'commentReference')) {
193
231
  const run = createWordElement(xmlDoc, 'w:r');
194
232
  run.appendChild(marker.node.cloneNode(true));
195
233
  localParagraph.appendChild(run);
@@ -202,9 +240,9 @@ function appendTextToCurrent(
202
240
  const info = getParagraphInfo(localBaseIndex + 1);
203
241
  const nextParagraph = createNewParagraph(info.pPr);
204
242
  if (generateRedlines && type === 'insert') {
205
- markParagraphMarkInserted(xmlDoc, nextParagraph, author);
243
+ markParagraphMarkInserted(xmlDoc, localParagraph, author);
206
244
  } else if (generateRedlines && type === 'delete') {
207
- markParagraphMarkDeleted(xmlDoc, nextParagraph, author);
245
+ markParagraphMarkDeleted(xmlDoc, localParagraph, author);
208
246
  }
209
247
 
210
248
  const fragment = containerFragments.get(info.container);
@@ -222,13 +260,20 @@ function appendTextToCurrent(
222
260
  if (sentinel) {
223
261
  const clone = sentinel.node.cloneNode(true);
224
262
  if (sentinel.isTextBox && sentinel.originalContainer) {
225
- const newContainer = getFirstElementByTag(clone, 'w:txbxContent');
263
+ const newContainer = getFirstElementByTagNSOrTag(clone, NS_W, 'txbxContent');
226
264
  if (newContainer) {
227
265
  while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
228
266
  replacementContainers.set(sentinel.originalContainer, newContainer);
229
267
  }
230
268
  }
231
- localParagraph.appendChild(clone);
269
+ if (sentinel.wrapInRun) {
270
+ const run = createWordElement(xmlDoc, 'w:r');
271
+ if (sentinel.rPr) run.appendChild(sentinel.rPr.cloneNode(true));
272
+ run.appendChild(clone);
273
+ localParagraph.appendChild(run);
274
+ } else {
275
+ localParagraph.appendChild(clone);
276
+ }
232
277
  }
233
278
  localBaseIndex++;
234
279
  if (type !== 'delete') localInsertOffset++;
@@ -21,7 +21,7 @@ import { createWordElement } from '../core/word-xml.js';
21
21
  */
22
22
  export function createTrackChange(xmlDoc, type, run, author) {
23
23
  const wrapper = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
24
- const metadata = createRevisionMetadata(author);
24
+ const metadata = createRevisionMetadata(author, xmlDoc);
25
25
  wrapper.setAttribute('w:id', String(metadata.id));
26
26
  wrapper.setAttribute('w:author', metadata.author);
27
27
  wrapper.setAttribute('w:date', metadata.date);
@@ -70,7 +70,7 @@ function markParagraphMark(xmlDoc, paragraph, author, type) {
70
70
  }
71
71
 
72
72
  const marker = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
73
- const metadata = createRevisionMetadata(author);
73
+ const metadata = createRevisionMetadata(author, xmlDoc);
74
74
  marker.setAttribute('w:id', String(metadata.id));
75
75
  marker.setAttribute('w:author', metadata.author);
76
76
  marker.setAttribute('w:date', metadata.date);
@@ -311,7 +311,7 @@ export function injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateR
311
311
  */
312
312
  export function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
313
313
  const rPrChange = createWordElement(xmlDoc, 'w:rPrChange');
314
- const metadata = createRevisionMetadata(author);
314
+ const metadata = createRevisionMetadata(author, xmlDoc);
315
315
  rPrChange.setAttribute('w:id', String(metadata.id));
316
316
  rPrChange.setAttribute('w:author', metadata.author);
317
317
  rPrChange.setAttribute('w:date', dateStr || metadata.date);
@@ -27,9 +27,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
27
27
  * @param {Array} formatHints - Format hints
28
28
  * @param {boolean} [generateRedlines=true] - Track change toggle
29
29
  * @param {Element|null} [targetParagraph=null] - Optional scope paragraph
30
+ * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
30
31
  * @returns {{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment' }}
31
32
  */
32
- export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
33
+ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
33
34
  void originalText;
34
35
 
35
36
  const allParagraphs = targetParagraph
@@ -37,7 +38,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
37
38
  : getDocumentParagraphs(xmlDoc);
38
39
 
39
40
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
40
- const diffs = computeWordDiffs(fullText, modifiedText);
41
+ const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
41
42
  const spanIndex = buildSpanIndex(textSpans);
42
43
 
43
44
  let originalPos = 0;
@@ -2,16 +2,20 @@
2
2
  * Table-specific reconciliation and transformation flows.
3
3
  */
4
4
 
5
- import { NS_W, createRevisionMetadata } from '../core/types.js';
5
+ import {
6
+ NS_W,
7
+ createRevisionMetadata,
8
+ getRevisionIdAllocatorForDocument,
9
+ setRevisionIdAllocatorForDocument
10
+ } from '../core/types.js';
6
11
  import {
7
12
  getElementsByTagNS,
8
13
  getElementsByTagNSOrTag,
9
- getFirstElementByTag,
10
14
  getFirstElementByTagNS,
11
15
  getFirstElementByTagNSOrTag,
12
16
  getXmlParseError
13
17
  } from '../core/xml-query.js';
14
- import { createParser } from '../adapters/xml-adapter.js';
18
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
15
19
  import { log, error } from '../adapters/logger.js';
16
20
  import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
17
21
  import { parseTable } from '../pipeline/pipeline.js';
@@ -51,11 +55,15 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
51
55
  return noChanges(serializer, xmlDoc);
52
56
  }
53
57
 
54
- const options = { generateRedlines, author };
58
+ const options = {
59
+ generateRedlines,
60
+ author,
61
+ revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
62
+ };
55
63
  const reconciledOxml = serializeVirtualGridToOoxml(oldGrid, operations, options);
56
64
  const wrappedOxml = `<root xmlns:w="${NS_W}">${reconciledOxml}</root>`;
57
- const reconcileParser = parser || createParser();
58
- const reconciledDoc = reconcileParser.parseFromString(wrappedOxml, 'application/xml');
65
+ const reconciledDoc = parseOoxmlSafe(wrappedOxml, 'application/xml').doc;
66
+ if (!reconciledDoc) return noChanges(serializer, xmlDoc);
59
67
 
60
68
  const parseError = getXmlParseError(reconciledDoc);
61
69
  if (parseError) {
@@ -86,16 +94,17 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
86
94
  * @param {boolean} generateRedlines - Track change toggle
87
95
  * @returns {{ oxml: string, hasChanges: boolean }}
88
96
  */
89
- export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
97
+ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
98
+ const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
90
99
  const tableData = parseTable(modifiedText);
91
100
  if (!tableData || (tableData.rows.length === 0 && tableData.headers.length === 0)) {
92
101
  log('[OxmlEngine] Failed to parse table data from Markdown');
93
102
  return noChanges(serializer, xmlDoc);
94
103
  }
95
104
 
96
- const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author });
97
- const activeParser = parser || createParser();
98
- const tableDoc = activeParser.parseFromString(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml');
105
+ const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator });
106
+ const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml').doc;
107
+ if (!tableDoc) return noChanges(serializer, xmlDoc);
99
108
 
100
109
  const tableParseError = getXmlParseError(tableDoc);
101
110
  if (tableParseError) {
@@ -123,14 +132,16 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
123
132
  let parent = firstParagraph.parentNode;
124
133
 
125
134
  if (parent && parent.nodeType === 9) {
126
- const wrappedDoc = activeParser.parseFromString(
127
- `<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
128
- 'application/xml'
129
- );
135
+ const wrappedDoc = parseOoxmlSafe(
136
+ `<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
137
+ 'application/xml'
138
+ ).doc;
139
+ if (!wrappedDoc) return noChanges(serializer, workingDoc);
130
140
  const wrappedBody = getFirstElementByTagNS(wrappedDoc, NS_W, 'body');
131
141
  paragraphs.forEach(p => wrappedBody.appendChild(wrappedDoc.importNode(p, true)));
132
142
 
133
- workingDoc = wrappedDoc;
143
+ workingDoc = wrappedDoc;
144
+ setRevisionIdAllocatorForDocument(workingDoc, revisionIdAllocator);
134
145
  paragraphs = getElementsByTagNS(workingDoc, NS_W, 'p');
135
146
  firstParagraph = paragraphs[0];
136
147
  parent = firstParagraph.parentNode;
@@ -154,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
154
165
  });
155
166
 
156
167
  const del = createWordElement(workingDoc, 'w:del');
157
- const metadata = createRevisionMetadata(author);
168
+ const metadata = createRevisionMetadata(author, workingDoc);
158
169
  del.setAttribute('w:id', String(metadata.id));
159
170
  del.setAttribute('w:author', metadata.author);
160
171
  del.setAttribute('w:date', metadata.date);
package/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export type OoxmlSourceType = 'package' | 'document' | 'fragment';
2
2
  export type RedlineStatus = 'ok' | 'no-op' | 'error';
3
- export type ExistingRevisionsPolicy = 'reject-input' | 'accept-all-first';
3
+ export type ExistingRevisionsPolicy = 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
4
4
 
5
5
  export interface RedlineError {
6
- code: 'PARSE_ERROR' | 'TARGET_NOT_FOUND' | 'EXISTING_REVISIONS' | string;
6
+ code: 'PARSE_ERROR' | 'TARGET_NOT_FOUND' | 'EXISTING_REVISIONS' | 'DIFF_TOKEN_LIMIT' | string;
7
7
  message: string;
8
8
  }
9
9
 
@@ -12,6 +12,8 @@ export interface RedlineOptions {
12
12
  author?: string;
13
13
  targetParagraphId?: string | null;
14
14
  existingRevisions?: ExistingRevisionsPolicy;
15
+ removeFormatting?: boolean;
16
+ sanitizeInput?: boolean;
15
17
  [key: string]: unknown;
16
18
  }
17
19
 
@@ -46,6 +48,8 @@ export interface AcceptTrackedChangesResult {
46
48
  hasChanges: boolean;
47
49
  acceptedCount: number;
48
50
  warnings: string[];
51
+ status?: RedlineStatus;
52
+ error?: RedlineError;
49
53
  }
50
54
 
51
55
  export interface RejectTrackedChangesResult {
@@ -53,6 +57,8 @@ export interface RejectTrackedChangesResult {
53
57
  hasChanges: boolean;
54
58
  rejectedCount: number;
55
59
  warnings: string[];
60
+ status?: RedlineStatus;
61
+ error?: RedlineError;
56
62
  }
57
63
 
58
64
  export interface DeleteCommentsResult {
@@ -61,6 +67,15 @@ export interface DeleteCommentsResult {
61
67
  commentsRemoved: number;
62
68
  referencesRemoved: number;
63
69
  warnings: string[];
70
+ status?: RedlineStatus;
71
+ error?: RedlineError;
72
+ }
73
+
74
+ export interface IngestionTextResult {
75
+ text: string;
76
+ status: 'ok' | 'error';
77
+ error?: RedlineError;
78
+ warnings?: string[];
64
79
  }
65
80
 
66
81
  export interface XmlProvider {
@@ -85,6 +100,7 @@ export interface DocumentOperationResult {
85
100
  }
86
101
 
87
102
  export function configureXmlProvider(provider: XmlProvider): void;
103
+ export function parseOoxmlSafe(oxml: unknown, contentType?: string): { doc: Document | null; error: RedlineError | null; warnings: string[] };
88
104
  export function configureLogger(logger: LoggerConfig): void;
89
105
  export function setDefaultAuthor(name: string): void;
90
106
  export function getDefaultAuthor(): string;
@@ -114,6 +130,8 @@ export function reconcileMarkdownTableOoxml(
114
130
 
115
131
  export function ingestWordOoxmlToPlainText(oxml: string): string;
116
132
  export function ingestWordOoxmlToMarkdown(oxml: string): string;
133
+ export function ingestWordOoxmlToPlainTextResult(oxml: unknown): IngestionTextResult;
134
+ export function ingestWordOoxmlToMarkdownResult(oxml: unknown): IngestionTextResult;
117
135
  export function ingestOoxml(oxml: string): unknown;
118
136
  export function preprocessMarkdown(text: string): { cleanText: string; formatHints: unknown[] };
119
137
  export function serializeToOoxml(runModel: unknown[], pPrXml?: string | null, formatHints?: unknown[], options?: Record<string, unknown>): string;
@@ -158,7 +176,7 @@ export function ensureNumberingArtifactsInZip(zip: unknown, numberingXml: string
158
176
  export function ensureCommentsArtifactsInZip(zip: unknown, commentsXml: string): Promise<unknown> | unknown;
159
177
  export function createDynamicNumberingIdState(numberingXml?: string): unknown;
160
178
 
161
- export function parseOoxml(ooxml: string): Document;
179
+ export function parseOoxml(ooxml: string): Document | null;
162
180
  export function serializeOoxml(node: Node): string;
163
181
  export function sanitizeAiResponse(text: string): string;
164
182
 
@@ -170,3 +188,77 @@ export class ReconciliationPipeline {
170
188
  export class NumberingService {
171
189
  constructor(...args: unknown[]);
172
190
  }
191
+
192
+ // Supporting public utilities. These declarations intentionally expose stable
193
+ // boundary shapes while leaving internal planning records extensible.
194
+ export const NS_W: string;
195
+ export const WORD_MAIN_NS: string;
196
+ export const DiffOp: Readonly<Record<'EQUAL' | 'DELETE' | 'INSERT', string>>;
197
+ export const RunKind: Readonly<Record<string, string>>;
198
+ export const ContainerKind: Readonly<Record<string, string>>;
199
+ export const ContentType: Readonly<Record<string, string>>;
200
+ export const RoutePlanKind: Readonly<Record<string, string>>;
201
+
202
+ export function escapeXml(value: unknown): string;
203
+ export function applyFormattingRemovalToOoxml(oxml: string, options?: Record<string, unknown>): string;
204
+ export function removeFormattingFromRPr(rPr: Element, options?: Record<string, unknown>): Element;
205
+
206
+ export function buildCommentElement(comment: Record<string, unknown>, id?: string | number): string;
207
+ export function buildCommentsPartXml(comments: unknown[]): string;
208
+
209
+ export function reserveNextNumberingId(state: unknown): number;
210
+ export function reserveNextNumberingIdPair(state: unknown): { abstractNumId: number; numId: number };
211
+ export function overwriteParagraphNumIds(oxml: string, numId: string | number): string;
212
+ export function extractFirstParagraphNumId(oxml: string): string | null;
213
+ export function buildExplicitDecimalMultilevelNumberingXml(options?: Record<string, unknown>): string;
214
+ export function remapNumberingPayloadForDocument(numberingXml: string, state: unknown): unknown;
215
+ export function mergeNumberingXmlBySchemaOrder(baseXml: string, incomingXml: string): string;
216
+
217
+ export function buildListMarkdown(items: unknown[], options?: Record<string, unknown>): string;
218
+ export function inferNumberingStyleFromMarker(marker: string): string;
219
+ export function normalizeListItemsWithLevels(items: unknown[]): unknown[];
220
+ export function parseMarkdownListContent(text: string): unknown;
221
+ export function hasListItems(parsed: unknown): boolean;
222
+
223
+ export function buildReconciliationPlan(params?: Record<string, unknown>): Record<string, unknown>;
224
+ export function normalizeContentEscapesForRouting(content: string): string;
225
+ export function buildSingleLineListStructuralFallbackPlan(options?: Record<string, unknown>): Record<string, unknown> | null;
226
+ export function executeSingleLineListStructuralFallback(plan: unknown, options?: Record<string, unknown>): Promise<RedlineResult>;
227
+ export function resolveSingleLineListFallbackNumberingAction(plan: unknown, sequenceState?: unknown): Record<string, unknown>;
228
+ export function recordSingleLineListFallbackExplicitSequence(sequenceState: unknown, numberingKey: string | null, numId: string | number | null, startAt: number | null): void;
229
+ export function clearSingleLineListFallbackExplicitSequence(sequenceState: unknown, numberingKey: string | null): void;
230
+ export function enforceListBindingOnParagraphNodes(nodes: Node[], options?: Record<string, unknown>): number;
231
+ export function stripSingleLineListMarkerPrefix(text: string): string;
232
+
233
+ export function getParagraphText(paragraph: Element | null | undefined): string;
234
+ export function getDocumentParagraphNodes(xmlDoc: Document | Element | null | undefined): Element[];
235
+ export function normalizeWhitespaceForTargeting(text: string): string;
236
+ export function isMarkdownTableText(text: string): boolean;
237
+ export function parseParagraphReference(reference: unknown): unknown;
238
+ export function stripLeadingParagraphMarker(text: string): string;
239
+ export function splitLeadingParagraphMarker(text: string): Record<string, unknown>;
240
+ export function findContainingWordElement(node: Node | null, localName: string): Element | null;
241
+ export function findParagraphByReference(xmlDoc: Document | Element, reference: unknown): Element | null;
242
+ export function findParagraphByStrictText(xmlDoc: Document | Element, text: string): Element | null;
243
+ export function findParagraphByBestTextMatch(xmlDoc: Document | Element, text: string): Element | null;
244
+ export function resolveTargetParagraph(xmlDoc: Document | Element, options?: Record<string, unknown>): Element | null;
245
+ export function buildTargetReferenceSnapshot(paragraph: Element, options?: Record<string, unknown>): Record<string, unknown>;
246
+ export function resolveTargetParagraphWithSnapshot(xmlDoc: Document | Element, snapshot: unknown): Element | null;
247
+ export function resolveParagraphRangeByRefs(xmlDoc: Document | Element, references: unknown[]): Element[];
248
+ export function extractParagraphIdFromOoxml(oxml: string): string | null;
249
+
250
+ export function getParagraphListInfo(paragraph: Element): Record<string, unknown> | null;
251
+ export function collectContiguousListParagraphBlock(paragraph: Element): Element[];
252
+ export function synthesizeExpandedListScopeEdit(options: Record<string, unknown>): Record<string, unknown> | null;
253
+ export function planListInsertionOnlyEdit(options: Record<string, unknown>): Record<string, unknown> | null;
254
+ export function stripRedundantLeadingListMarkers(text: string): string;
255
+
256
+ export function inferTableReplacementParagraphBlock(options: Record<string, unknown>): Record<string, unknown> | null;
257
+ export function isLikelyStructuredTableSourceParagraph(paragraph: Element): boolean;
258
+ export function synthesizeTableMarkdownFromMultilineCellEdit(options: Record<string, unknown>): string | null;
259
+
260
+ export function getBodyElementFromDocument(xmlDoc: Document): Element | null;
261
+ export function insertBodyElementBeforeSectPr(body: Element, element: Element): Element;
262
+ export function normalizeBodySectionOrderStandalone(documentXml: string): string;
263
+ export function sanitizeNestedParagraphsInTables(documentXml: string): string;
264
+ export function getPackagePartName(part: unknown): string | null;
package/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  // Adapters
6
- export { configureXmlProvider } from './adapters/xml-adapter.js';
6
+ export { configureXmlProvider, parseOoxmlSafe } from './adapters/xml-adapter.js';
7
7
  export { configureLogger } from './adapters/logger.js';
8
8
  export { setDefaultAuthor, getDefaultAuthor, setPlatform, getPlatform } from './adapters/config.js';
9
9
 
@@ -18,12 +18,7 @@ import { parseTable as parseMarkdownTable } from './pipeline/pipeline.js';
18
18
  import { wrapInDocumentFragment as wrapInDocumentFragmentShared } from './pipeline/serialization.js';
19
19
  import {
20
20
  buildSingleLineListStructuralFallbackPlan,
21
- executeSingleLineListStructuralFallback,
22
- resolveSingleLineListFallbackNumberingAction,
23
- recordSingleLineListFallbackExplicitSequence,
24
- clearSingleLineListFallbackExplicitSequence,
25
- enforceListBindingOnParagraphNodes,
26
- stripSingleLineListMarkerPrefix
21
+ executeSingleLineListStructuralFallback
27
22
  } from './orchestration/list-structural-fallback.js';
28
23
  import { withOoxmlSourceType } from './core/word-xml.js';
29
24
  export { containsTrackedChanges } from './core/word-xml.js';
@@ -119,10 +114,14 @@ export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParag
119
114
  */
120
115
  export async function applyRedlineToOxmlWithListFallback(oxml, originalText, modifiedText, options = {}) {
121
116
  const allowExistingListForFallback = options.listFallbackAllowExistingList !== false;
117
+ const fallbackModifiedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
118
+ const sanitizationWarnings = fallbackModifiedText !== modifiedText
119
+ ? ['Input was sanitized; pass sanitizeInput: false to disable.']
120
+ : [];
122
121
  const plan = buildSingleLineListStructuralFallbackPlan({
123
122
  oxml,
124
123
  originalText,
125
- modifiedText,
124
+ modifiedText: fallbackModifiedText,
126
125
  allowExistingList: allowExistingListForFallback
127
126
  });
128
127
  const preferListFallback = options.preferListStructuralFallback !== false;
@@ -143,7 +142,7 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
143
142
  return withOoxmlSourceType({
144
143
  oxml: wrappedOxml,
145
144
  hasChanges: true,
146
- warnings: fallbackWarnings,
145
+ warnings: [...sanitizationWarnings, ...fallbackWarnings],
147
146
  listStructuralFallbackApplied: true,
148
147
  listStructuralFallbackKey: fallbackResult.listStructuralFallbackKey || null,
149
148
  listStructuralFallbackNumberingXml: fallbackResult.numberingXml || null
@@ -232,7 +231,12 @@ export {
232
231
  // Pipeline components
233
232
  export { ReconciliationPipeline } from './pipeline/pipeline.js';
234
233
  export { ingestOoxml } from './pipeline/ingestion.js';
235
- export { ingestWordOoxmlToPlainText, ingestWordOoxmlToMarkdown } from './pipeline/ingestion-export.js';
234
+ export {
235
+ ingestWordOoxmlToPlainText,
236
+ ingestWordOoxmlToMarkdown,
237
+ ingestWordOoxmlToPlainTextResult,
238
+ ingestWordOoxmlToMarkdownResult
239
+ } from './pipeline/ingestion-export.js';
236
240
  export { preprocessMarkdown } from './pipeline/markdown-processor.js';
237
241
  export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
238
242
 
@@ -260,7 +264,6 @@ export {
260
264
  export { generateTableOoxml } from './services/table-reconciliation.js';
261
265
  export { NumberingService } from './services/numbering-service.js';
262
266
  export {
263
- parseXmlStrictStandalone,
264
267
  getBodyElementFromDocument,
265
268
  insertBodyElementBeforeSectPr,
266
269
  normalizeBodySectionOrderStandalone,
@@ -312,5 +315,3 @@ export {
312
315
  planListInsertionOnlyEdit,
313
316
  stripRedundantLeadingListMarkers
314
317
  } from './core/list-targeting.js';
315
-
316
-