@ansonlai/docx-redline-js 0.1.4 → 0.2.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 (47) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +75 -11
  3. package/README.md +62 -3
  4. package/core/redline-validation.js +156 -0
  5. package/core/types.js +35 -8
  6. package/core/word-xml.js +90 -0
  7. package/dist/docx-redline-js.esm.js +3195 -2592
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +71 -67
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/docs/VALIDATION.md +104 -0
  12. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  13. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  14. package/docs/plans/2026-05-31-architectural changes.md +591 -0
  15. package/engine/format-application.js +13 -14
  16. package/engine/format-span-application.js +7 -6
  17. package/engine/formatting-removal.js +15 -12
  18. package/engine/oxml-engine.js +146 -55
  19. package/engine/reconstruction-mapper.js +35 -8
  20. package/engine/reconstruction-mode.js +14 -13
  21. package/engine/reconstruction-writer.js +97 -78
  22. package/engine/rpr-helpers.js +34 -32
  23. package/engine/run-builders.js +150 -39
  24. package/engine/surgical-diff-application.js +216 -0
  25. package/engine/surgical-mode.js +84 -519
  26. package/engine/surgical-run-splitting.js +96 -0
  27. package/engine/surgical-spans.js +169 -0
  28. package/engine/table-cell-context.js +15 -13
  29. package/engine/table-mode.js +39 -35
  30. package/index.d.ts +172 -0
  31. package/index.js +50 -47
  32. package/package.json +10 -2
  33. package/pipeline/ingestion-export.js +1 -0
  34. package/pipeline/ingestion-paragraph.js +37 -12
  35. package/pipeline/ingestion-table.js +11 -8
  36. package/scripts/build.mjs +40 -0
  37. package/scripts/check-types.mjs +29 -0
  38. package/scripts/export-validation-fixtures.mjs +125 -0
  39. package/scripts/lib/minimal-zip.mjs +155 -0
  40. package/scripts/run-tests.mjs +43 -0
  41. package/scripts/validate-fixtures-xsd.sh +37 -0
  42. package/scripts/word-com-differential.ps1 +133 -0
  43. package/scripts/word-com-smoke.ps1 +48 -0
  44. package/services/comment-locator.js +10 -9
  45. package/services/revision-comment-management.js +115 -1
  46. package/services/standalone-operation-runner.js +119 -69
  47. package/services/table-reconciliation.js +7 -8
package/index.js CHANGED
@@ -1,13 +1,13 @@
1
- /**
2
- * Standalone reconciliation entrypoint (no Word JS API dependencies).
3
- */
4
-
1
+ /**
2
+ * Standalone reconciliation entrypoint (no Word JS API dependencies).
3
+ */
4
+
5
5
  // Adapters
6
6
  export { configureXmlProvider } 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
-
10
- // Engine
9
+
10
+ // Engine
11
11
  import {
12
12
  applyRedlineToOxml as applyRedlineToOxmlEngine,
13
13
  sanitizeAiResponse,
@@ -25,27 +25,30 @@ import {
25
25
  enforceListBindingOnParagraphNodes,
26
26
  stripSingleLineListMarkerPrefix
27
27
  } from './orchestration/list-structural-fallback.js';
28
-
29
- /**
30
- * Standalone-safe redline wrapper.
31
- *
32
- * In non-Word runtimes, the engine can return `{ useNativeApi: true, hasChanges: true }`
33
- * without an OOXML payload for some format-only operations. Standalone callers cannot
34
- * complete that native fallback path, so normalize to a no-op with warnings.
35
- */
28
+ import { withOoxmlSourceType } from './core/word-xml.js';
29
+ export { containsTrackedChanges } from './core/word-xml.js';
30
+ export { validateRedlineOoxml } from './core/redline-validation.js';
31
+
32
+ /**
33
+ * Standalone-safe redline wrapper.
34
+ *
35
+ * In non-Word runtimes, the engine can return `{ useNativeApi: true, hasChanges: true }`
36
+ * without an OOXML payload for some format-only operations. Standalone callers cannot
37
+ * complete that native fallback path, so normalize to a no-op with warnings.
38
+ */
36
39
  export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
37
40
  const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
38
41
  if (result?.useNativeApi && typeof result?.oxml !== 'string') {
39
42
  const existingWarnings = Array.isArray(result?.warnings) ? result.warnings : [];
40
- return {
41
- ...result,
42
- oxml,
43
- hasChanges: false,
44
- warnings: [
45
- ...existingWarnings,
46
- 'Standalone mode cannot execute native Word API fallback for this operation.'
47
- ]
48
- };
43
+ return withOoxmlSourceType({
44
+ ...result,
45
+ oxml,
46
+ hasChanges: false,
47
+ warnings: [
48
+ ...existingWarnings,
49
+ 'Standalone mode cannot execute native Word API fallback for this operation.'
50
+ ]
51
+ });
49
52
  }
50
53
  return result;
51
54
  }
@@ -97,9 +100,9 @@ export async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTa
97
100
  };
98
101
  }
99
102
 
100
- export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
101
- export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
102
-
103
+ export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
104
+ export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
105
+
103
106
  /**
104
107
  * Applies redline reconciliation, then forces single-line structural list
105
108
  * conversion when the redline is a no-op on marker-prefixed list text.
@@ -137,14 +140,14 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
137
140
  numberingXml: fallbackResult.numberingXml
138
141
  });
139
142
  const fallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
140
- return {
143
+ return withOoxmlSourceType({
141
144
  oxml: wrappedOxml,
142
145
  hasChanges: true,
143
146
  warnings: fallbackWarnings,
144
147
  listStructuralFallbackApplied: true,
145
148
  listStructuralFallbackKey: fallbackResult.listStructuralFallbackKey || null,
146
149
  listStructuralFallbackNumberingXml: fallbackResult.numberingXml || null
147
- };
150
+ });
148
151
  }
149
152
  preflightFallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
150
153
  }
@@ -202,7 +205,7 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
202
205
  const existingWarnings = Array.isArray(baseResult?.warnings) ? baseResult.warnings : [];
203
206
  const fallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
204
207
 
205
- return {
208
+ return withOoxmlSourceType({
206
209
  ...baseResult,
207
210
  oxml: wrappedOxml,
208
211
  hasChanges: true,
@@ -210,7 +213,7 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
210
213
  listStructuralFallbackApplied: true,
211
214
  listStructuralFallbackKey: fallbackResult.listStructuralFallbackKey || null,
212
215
  listStructuralFallbackNumberingXml: fallbackResult.numberingXml || null
213
- };
216
+ });
214
217
  }
215
218
 
216
219
  export { sanitizeAiResponse, parseOoxml, serializeOoxml };
@@ -232,8 +235,8 @@ export { ingestOoxml } from './pipeline/ingestion.js';
232
235
  export { ingestWordOoxmlToPlainText, ingestWordOoxmlToMarkdown } from './pipeline/ingestion-export.js';
233
236
  export { preprocessMarkdown } from './pipeline/markdown-processor.js';
234
237
  export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
235
-
236
- // Comment engine
238
+
239
+ // Comment engine
237
240
  export {
238
241
  injectCommentsIntoOoxml,
239
242
  injectCommentsIntoPackage,
@@ -245,15 +248,15 @@ export {
245
248
  rejectTrackedChangesInOoxml,
246
249
  deleteCommentsByAuthorInOoxml
247
250
  } from './services/revision-comment-management.js';
248
-
251
+
249
252
  // Formatting removal utilities
250
253
  export {
251
254
  removeFormattingFromRPr,
252
255
  applyFormattingRemovalToOoxml,
253
256
  applyHighlightToOoxml
254
257
  } from './engine/formatting-removal.js';
255
-
256
- // Table/list tools
258
+
259
+ // Table/list tools
257
260
  export { generateTableOoxml } from './services/table-reconciliation.js';
258
261
  export { NumberingService } from './services/numbering-service.js';
259
262
  export {
@@ -280,20 +283,20 @@ export {
280
283
  enforceListBindingOnParagraphNodes,
281
284
  stripSingleLineListMarkerPrefix
282
285
  } from './orchestration/list-structural-fallback.js';
283
-
284
- // Core types/constants
285
- export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
286
- export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
286
+
287
+ // Core types/constants
288
+ export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
289
+ export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
287
290
  export {
288
291
  WORD_MAIN_NS,
289
292
  getParagraphText,
290
293
  getDocumentParagraphNodes,
291
294
  normalizeWhitespaceForTargeting,
292
- isMarkdownTableText,
293
- parseParagraphReference,
294
- stripLeadingParagraphMarker,
295
- splitLeadingParagraphMarker,
296
- findContainingWordElement,
295
+ isMarkdownTableText,
296
+ parseParagraphReference,
297
+ stripLeadingParagraphMarker,
298
+ splitLeadingParagraphMarker,
299
+ findContainingWordElement,
297
300
  findParagraphByReference,
298
301
  findParagraphByStrictText,
299
302
  findParagraphByBestTextMatch,
@@ -301,7 +304,7 @@ export {
301
304
  buildTargetReferenceSnapshot,
302
305
  resolveTargetParagraphWithSnapshot
303
306
  } from './core/paragraph-targeting.js';
304
- export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
307
+ export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
305
308
  export {
306
309
  getParagraphListInfo,
307
310
  collectContiguousListParagraphBlock,
@@ -309,5 +312,5 @@ export {
309
312
  planListInsertionOnlyEdit,
310
313
  stripRedundantLeadingListMarkers
311
314
  } from './core/list-targeting.js';
312
-
313
-
315
+
316
+
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./index.js",
8
8
  "module": "./index.js",
9
+ "types": "./index.d.ts",
9
10
  "exports": {
10
11
  ".": {
12
+ "types": "./index.d.ts",
11
13
  "import": "./index.js",
12
14
  "default": "./index.js"
13
15
  },
@@ -27,7 +29,10 @@
27
29
  "pipeline/",
28
30
  "services/",
29
31
  "orchestration/",
32
+ "scripts/",
33
+ "docs/",
30
34
  "index.js",
35
+ "index.d.ts",
31
36
  "dist/",
32
37
  "ARCHITECTURE.md",
33
38
  "AGENTS.md",
@@ -51,6 +56,9 @@
51
56
  },
52
57
  "scripts": {
53
58
  "build": "node scripts/build.mjs",
59
+ "check:types": "node scripts/check-types.mjs",
60
+ "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
61
+ "smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
54
62
  "test": "node scripts/run-tests.mjs",
55
63
  "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
56
64
  "prepublishOnly": "npm run test:isolation && npm run build"
@@ -71,6 +79,6 @@
71
79
  "url": "https://github.com/AnsonLai/docx-redline-js.git"
72
80
  },
73
81
  "engines": {
74
- "node": ">=18.0.0"
82
+ "node": ">=20.0.0"
75
83
  }
76
84
  }
@@ -112,6 +112,7 @@ function collectParagraphSegments(paragraph) {
112
112
  const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
113
113
  for (const run of runs) {
114
114
  if (hasWordAncestorWithin(run, 'del', paragraph)) continue;
115
+ if (hasWordAncestorWithin(run, 'moveFrom', paragraph)) continue;
115
116
  const text = readRunText(run);
116
117
  if (!text) continue;
117
118
  segments.push({
@@ -217,13 +217,36 @@ function getNodeHandlers(runModel) {
217
217
  return contentResult;
218
218
  });
219
219
 
220
- handlers.set('del', (child, offset) => {
221
- const deletionEntry = processDeletion(child, offset);
222
- if (deletionEntry) {
223
- runModel.push(deletionEntry);
224
- }
225
- return { offset, text: '' };
226
- });
220
+ handlers.set('del', (child, offset) => {
221
+ const deletionEntry = processDeletion(child, offset);
222
+ if (deletionEntry) {
223
+ runModel.push(deletionEntry);
224
+ }
225
+ return { offset, text: '' };
226
+ });
227
+
228
+ handlers.set('moveFrom', (child, offset) => {
229
+ const deletionEntry = processDeletion(child, offset);
230
+ if (deletionEntry) {
231
+ runModel.push(deletionEntry);
232
+ }
233
+ return { offset, text: '' };
234
+ });
235
+
236
+ handlers.set('moveTo', (child, offset) => processNodeRecursive(child, offset, runModel));
237
+
238
+ for (const markerName of ['moveFromRangeStart', 'moveFromRangeEnd', 'moveToRangeStart', 'moveToRangeEnd']) {
239
+ handlers.set(markerName, (child, offset) => {
240
+ runModel.push({
241
+ kind: RunKind.BOOKMARK,
242
+ nodeXml: serializeXml(child),
243
+ startOffset: offset,
244
+ endOffset: offset,
245
+ text: ''
246
+ });
247
+ return { offset, text: '' };
248
+ });
249
+ }
227
250
 
228
251
  handlers.set('bookmarkStart', (child, offset) => {
229
252
  runModel.push({
@@ -320,11 +343,13 @@ function processRun(runElement, startOffset) {
320
343
  };
321
344
  }
322
345
 
323
- function processDeletion(delElement, offset) {
324
- const author = delElement.getAttribute('w:author') || '';
325
-
326
- let text = '';
327
- const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
346
+ function processDeletion(delElement, offset) {
347
+ const author = delElement.getAttribute('w:author') || '';
348
+
349
+ // Deleted text is retained as a zero-width deletion model entry for revision-aware callers.
350
+ // It is intentionally not added to acceptedText by the w:del handler above.
351
+ let text = '';
352
+ const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
328
353
  for (const delText of delTexts) {
329
354
  text += delText.textContent || '';
330
355
  }
@@ -20,13 +20,16 @@ import { ingestParagraphElement } from './ingestion-paragraph.js';
20
20
  * @param {Element} tableNode - w:tbl element
21
21
  * @returns {Object}
22
22
  */
23
- export function ingestTableToVirtualGrid(tableNode) {
24
- const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
25
- const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
26
- const colCount = gridCols.length;
27
-
28
- const trElements = getElementsByTagNS(tableNode, NS_W, 'tr');
29
- const rowCount = trElements.length;
23
+ export function ingestTableToVirtualGrid(tableNode) {
24
+ const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
25
+ const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
26
+ const trElements = getElementsByTagNSOrTag(tableNode, NS_W, 'tr');
27
+ const rowCount = trElements.length;
28
+ const inferredColCount = trElements.reduce((max, tr) => {
29
+ const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
30
+ return Math.max(max, tcElements.length);
31
+ }, 0);
32
+ const colCount = gridCols.length || inferredColCount;
30
33
 
31
34
  const grid = Array.from({ length: rowCount }, () =>
32
35
  Array.from({ length: colCount }, () => null)
@@ -36,7 +39,7 @@ export function ingestTableToVirtualGrid(tableNode) {
36
39
 
37
40
  for (let rowIdx = 0; rowIdx < trElements.length; rowIdx++) {
38
41
  const tr = trElements[rowIdx];
39
- const tcElements = getElementsByTagNS(tr, NS_W, 'tc');
42
+ const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
40
43
  let gridCol = 0;
41
44
 
42
45
  for (let tcIdx = 0; tcIdx < tcElements.length; tcIdx++) {
@@ -0,0 +1,40 @@
1
+ import { build } from 'esbuild';
2
+ import { readFileSync } from 'fs';
3
+ import { dirname, resolve } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ const entryPoint = resolve(repoRoot, 'index.js');
8
+ const distDir = resolve(repoRoot, 'dist');
9
+ const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
10
+
11
+ // ESM bundle with diff-match-patch inlined (for CDN/browser <script type="module">)
12
+ await build({
13
+ entryPoints: [entryPoint],
14
+ bundle: true,
15
+ format: 'esm',
16
+ outfile: resolve(distDir, 'docx-redline-js.esm.js'),
17
+ platform: 'neutral', // no Node builtins assumed
18
+ target: 'es2020',
19
+ minify: false, // keep readable for debugging
20
+ sourcemap: true,
21
+ banner: {
22
+ js: `// @ansonlai/docx-redline-js v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
23
+ },
24
+ external: ['@xmldom/xmldom'] // never bundle the Node-only XML parser
25
+ });
26
+
27
+ // Minified version for production CDN use
28
+ await build({
29
+ entryPoints: [entryPoint],
30
+ bundle: true,
31
+ format: 'esm',
32
+ outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
33
+ platform: 'neutral',
34
+ target: 'es2020',
35
+ minify: true,
36
+ sourcemap: true,
37
+ external: ['@xmldom/xmldom']
38
+ });
39
+
40
+ console.log('Build complete: dist/docx-redline-js.esm.js, dist/docx-redline-js.esm.min.js');
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from 'fs';
2
+
3
+ const dts = readFileSync(new URL('../index.d.ts', import.meta.url), 'utf8');
4
+
5
+ const requiredSnippets = [
6
+ 'export interface RedlineOptions',
7
+ 'export interface RedlineResult',
8
+ 'export function applyRedlineToOxml',
9
+ 'export function acceptTrackedChangesInOoxml',
10
+ 'export function rejectTrackedChangesInOoxml',
11
+ 'export function deleteCommentsByAuthorInOoxml',
12
+ 'export function validateRedlineOoxml'
13
+ ];
14
+
15
+ for (const snippet of requiredSnippets) {
16
+ if (!dts.includes(snippet)) {
17
+ throw new Error(`Missing declaration snippet: ${snippet}`);
18
+ }
19
+ }
20
+
21
+ let balance = 0;
22
+ for (const char of dts) {
23
+ if (char === '{') balance += 1;
24
+ if (char === '}') balance -= 1;
25
+ if (balance < 0) throw new Error('index.d.ts has unbalanced braces');
26
+ }
27
+ if (balance !== 0) throw new Error('index.d.ts has unbalanced braces');
28
+
29
+ console.log('PASS: index.d.ts declaration smoke check');
@@ -0,0 +1,125 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ import { configureXmlProvider } from '../adapters/xml-adapter.js';
5
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
6
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
7
+ import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
8
+ import { buildMinimalDocx } from './lib/minimal-zip.mjs';
9
+
10
+ const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
11
+ configureXmlProvider({ DOMParser, XMLSerializer });
12
+
13
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
14
+ const outputDir = join(process.cwd(), 'tmp', 'validation-docx');
15
+ mkdirSync(outputDir, { recursive: true });
16
+
17
+ const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
18
+ <w:document xmlns:w="${NS_W}">
19
+ <w:body>
20
+ <w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>
21
+ <w:sectPr/>
22
+ </w:body>
23
+ </w:document>`;
24
+
25
+ const cases = [
26
+ {
27
+ name: 'simple-redline',
28
+ original: 'The old sentence.',
29
+ modified: 'The new sentence.'
30
+ },
31
+ {
32
+ name: 'paragraph-insert',
33
+ original: 'one',
34
+ modified: 'one\ntwo'
35
+ },
36
+ {
37
+ name: 'format-only',
38
+ original: 'Make word bold',
39
+ modified: 'Make **word** bold'
40
+ },
41
+ {
42
+ name: 'whitespace-heavy',
43
+ original: 'Alpha beta gamma delta.',
44
+ modified: 'Alpha beta REPLACED delta.'
45
+ },
46
+ {
47
+ name: 'unicode-replace',
48
+ original: 'Term 条款 applies to café.',
49
+ modified: 'Term 合同 applies to café 🚀.'
50
+ }
51
+ ];
52
+
53
+ let failures = 0;
54
+
55
+ for (const testCase of cases) {
56
+ const result = await applyOperationToDocumentXml(
57
+ baseDocument(testCase.original),
58
+ { type: 'redline', target: testCase.original, modified: testCase.modified },
59
+ 'Validation',
60
+ null,
61
+ { generateRedlines: true }
62
+ );
63
+
64
+ if (!result?.hasChanges || result?.status === 'error') {
65
+ console.error(`FAIL ${testCase.name}: redline did not apply (status=${result?.status}, error=${result?.error?.message})`);
66
+ failures++;
67
+ continue;
68
+ }
69
+
70
+ const validation = validateRedlineOoxml(result.documentXml);
71
+ const validationErrors = validation.issues.filter(issue => issue.severity === 'error');
72
+ if (validationErrors.length > 0) {
73
+ console.error(`FAIL ${testCase.name}: validateRedlineOoxml reported ${validationErrors.map(issue => issue.code).join(', ')}`);
74
+ failures++;
75
+ continue;
76
+ }
77
+
78
+ writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
79
+ if (result.numberingXml) {
80
+ writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
81
+ }
82
+
83
+ const docx = buildMinimalDocx(result.documentXml, { numberingXml: result.numberingXml || null });
84
+ writeFileSync(join(outputDir, `${testCase.name}.docx`), docx);
85
+
86
+ // Expected text is derived from edit *intent*, not from this library's
87
+ // accept/reject transforms, so external consumers (Word COM, LibreOffice)
88
+ // act as independent oracles.
89
+ const expected = {
90
+ name: testCase.name,
91
+ expectedAcceptedText: preprocessMarkdown(testCase.modified).cleanText,
92
+ expectedRejectedText: testCase.original
93
+ };
94
+ writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
95
+
96
+ console.log(`wrote ${testCase.name}: .document.xml, .docx, .expected.json`);
97
+ }
98
+
99
+ writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
100
+
101
+ Generated by \`node scripts/export-validation-fixtures.mjs\`.
102
+
103
+ Each case produces:
104
+
105
+ - \`<name>.document.xml\` — the generated \`word/document.xml\` payload (for
106
+ XSD validation and manual inspection).
107
+ - \`<name>.docx\` — a minimal package assembled by release tooling only (the
108
+ published library still has no zip dependency).
109
+ - \`<name>.expected.json\` — the accept-all / reject-all plain-text outcomes
110
+ derived from edit intent, used by external-consumer differential checks.
111
+
112
+ Validation entry points:
113
+
114
+ - Word (differential accept/reject): \`npm run smoke:word:diff\`
115
+ - LibreOffice parse check: \`soffice --headless --convert-to pdf *.docx\`
116
+ - Schema check: \`xmllint --noout --schema wml.xsd *.document.xml\`
117
+ (transitional schemas from ECMA-376 Part 4; see docs/VALIDATION.md)
118
+ `, 'utf8');
119
+
120
+ if (failures > 0) {
121
+ console.error(`\n${failures} fixture case(s) failed.`);
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log(`Wrote validation fixtures to ${outputDir}`);