@ansonlai/docx-redline-js 0.2.0 → 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 (87) 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 +1148 -572
  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 +95 -104
  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 +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. 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++;
@@ -5,7 +5,7 @@
5
5
  * and `w:pPrChange` elements used by surgical and reconstruction modes.
6
6
  */
7
7
 
8
- import { RPR_SCHEMA_ORDER } from './rpr-helpers.js';
8
+ import { extractFormatFromRPr, RPR_SCHEMA_ORDER } from './rpr-helpers.js';
9
9
  import { createRevisionMetadata } from '../core/types.js';
10
10
  import { getFirstElementByTag } from '../core/xml-query.js';
11
11
  import { createWordElement } from '../core/word-xml.js';
@@ -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);
@@ -140,8 +140,8 @@ export function createTextRun(xmlDoc, text, rPr, isDelete) {
140
140
  * @param {boolean} [generateRedlines] - Whether to create rPrChange
141
141
  * @returns {Element[]}
142
142
  */
143
- export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
144
- if (!text) return [];
143
+ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
144
+ if (!text) return [];
145
145
 
146
146
  const breaks = new Set([0, text.length]);
147
147
  for (const hint of formatHints) {
@@ -167,13 +167,17 @@ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffs
167
167
  h.start <= segmentBaseOffset && h.end >= segmentEndOffset
168
168
  );
169
169
 
170
- const combinedFormat = {};
171
- applicableHints.forEach(h => {
172
- if (h.format) Object.assign(combinedFormat, h.format);
173
- });
174
-
175
- const formattedRPr = injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines);
176
- runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
170
+ const combinedFormat = { ...extractFormatFromRPr(baseRPr) };
171
+ applicableHints.forEach(h => {
172
+ if (h.format) Object.assign(combinedFormat, h.format);
173
+ });
174
+
175
+ // During a text edit, missing Markdown is not an instruction to clear
176
+ // the source run's formatting. Only synchronize explicitly hinted spans.
177
+ const formattedRPr = applicableHints.length > 0
178
+ ? injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines)
179
+ : baseRPr?.cloneNode(true) || null;
180
+ runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
177
181
  }
178
182
 
179
183
  return runs;
@@ -307,7 +311,7 @@ export function injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateR
307
311
  */
308
312
  export function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
309
313
  const rPrChange = createWordElement(xmlDoc, 'w:rPrChange');
310
- const metadata = createRevisionMetadata(author);
314
+ const metadata = createRevisionMetadata(author, xmlDoc);
311
315
  rPrChange.setAttribute('w:id', String(metadata.id));
312
316
  rPrChange.setAttribute('w:author', metadata.author);
313
317
  rPrChange.setAttribute('w:date', dateStr || metadata.date);
@@ -1,5 +1,4 @@
1
1
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
2
- import { isWordElement } from '../core/word-xml.js';
3
2
  import {
4
3
  createTrackChange,
5
4
  createTextRun,
@@ -20,30 +19,17 @@ import {
20
19
  findLastSpanEndingBeforeOrAt,
21
20
  forEachOverlappingSpan
22
21
  } from './surgical-spans.js';
22
+ import { extractFormatFromRPr } from './rpr-helpers.js';
23
23
 
24
24
  export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
25
- const desiredFormat = {};
26
- if (applicableHints.length > 0) {
27
- applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
28
- }
25
+ // Plain modified text carries no negative formatting instruction. Preserve
26
+ // unchanged source formatting unless Markdown explicitly targets this span.
27
+ if (applicableHints.length === 0) return false;
29
28
 
30
29
  const rPr = span.rPr;
31
- const hasElement = (localName) => {
32
- if (!rPr) return false;
33
- for (let node = rPr.firstChild; node; node = node.nextSibling) {
34
- if (isWordElement(node, localName)) {
35
- return true;
36
- }
37
- }
38
- return false;
39
- };
40
-
41
- const existingFormat = {
42
- bold: hasElement('b'),
43
- italic: hasElement('i'),
44
- underline: hasElement('u'),
45
- strikethrough: hasElement('strike')
46
- };
30
+ const existingFormat = extractFormatFromRPr(rPr);
31
+ const desiredFormat = { ...existingFormat };
32
+ applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
47
33
 
48
34
  const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
49
35
  const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
@@ -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;