@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
@@ -182,9 +182,8 @@ export function applyPatches(splitModel, diffOps, options) {
182
182
 
183
183
  function processInsertionOperation(context) {
184
184
  const {
185
- insertOp,
186
- splitModel,
187
- styleLookup,
185
+ insertOp,
186
+ styleLookup,
188
187
  patchedModel,
189
188
  state,
190
189
  options,
@@ -16,7 +16,7 @@ import { detectNumberingContext } from './ingestion.js';
16
16
  import { generateTableOoxml } from '../services/table-reconciliation.js';
17
17
  import { executeListGeneration, detectIndentationStep } from './list-generation.js';
18
18
  import { detectContentType, parseListItems, parseTable } from './content-analysis.js';
19
- import { createParser } from '../adapters/xml-adapter.js';
19
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
20
20
  import { log, error as logError } from '../adapters/logger.js';
21
21
  import { getFirstElementByTagNS, getXmlParseError } from '../core/xml-query.js';
22
22
  import { getPlatform } from '../adapters/config.js';
@@ -54,6 +54,7 @@ export class ReconciliationPipeline {
54
54
  this.validationMode = options.validationMode ?? 'auto';
55
55
  this.numberingService = options.numberingService || new NumberingService();
56
56
  this.font = options.font || null;
57
+ this.revisionIdAllocator = options.revisionIdAllocator || null;
57
58
  this.platform = options.platform ?? getPlatform();
58
59
  this.isWebPlatform = options.isWebPlatform ?? isWebPlatform(this.platform);
59
60
  this.enableEventLoopYielding = options.enableEventLoopYielding ?? this.isWebPlatform;
@@ -75,10 +76,20 @@ export class ReconciliationPipeline {
75
76
 
76
77
  try {
77
78
  // Stage 1: Ingest OOXML
78
- const doc = options.xmlDoc || (() => {
79
- const parser = createParser();
80
- return parser.parseFromString(originalOoxml, 'application/xml');
81
- })();
79
+ const parsed = options.xmlDoc
80
+ ? { doc: options.xmlDoc, error: null, warnings: [] }
81
+ : parseOoxmlSafe(originalOoxml, 'application/xml');
82
+ if (parsed.error || !parsed.doc) {
83
+ return {
84
+ ooxml: originalOoxml,
85
+ isValid: false,
86
+ status: 'error',
87
+ error: parsed.error,
88
+ warnings: parsed.warnings || []
89
+ };
90
+ }
91
+ warnings.push(...(parsed.warnings || []));
92
+ const doc = parsed.doc;
82
93
  const pElement = getFirstElementByTagNS(doc, '*', 'p');
83
94
 
84
95
  const { runModel, acceptedText, pPr } = ingestOoxml(originalOoxml, { xmlDoc: doc });
@@ -122,19 +133,28 @@ export class ReconciliationPipeline {
122
133
  }
123
134
  await this.maybeYield(runModel.length, Math.max(acceptedText.length, cleanText.length));
124
135
 
125
- // Count actual paragraph elements ingested
126
- const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
127
-
136
+ // Count actual paragraph elements ingested
137
+ const paragraphCount = runModel.filter(r => r.kind === RunKind.PARAGRAPH_START).length;
138
+ const sourceIsMarkedMultilineList = isListTargetStrict(acceptedText) || isListTargetLoose(acceptedText);
139
+ const canPatchExistingMarkedList = isTargetList
140
+ && sourceIsMarkedMultilineList
141
+ && paragraphCount > 1
142
+ && acceptedText !== cleanText;
143
+
128
144
  log(`[Reconcile] isTargetList: ${isTargetList}, paragraphCount: ${paragraphCount}`);
129
-
130
- // If target is a list, always use list generation logic
131
- // This handles both expansion (1 para -> N items) and conversion (N paras -> M items)
132
- if (isTargetList) {
133
- log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
134
- log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
135
- log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
136
- return this.executeListGeneration(cleanText, numberingContext, runModel);
137
- }
145
+
146
+ // Preserve already-marked multi-paragraph lists during ordinary text edits.
147
+ // Rebuilding the whole block would discard unchanged run formatting and
148
+ // place all source deletion runs in the first generated paragraph, which
149
+ // makes rejection merge the original paragraph boundaries.
150
+ if (canPatchExistingMarkedList) {
151
+ log('[Reconcile] Existing marked list edit detected; using run-aware patching to preserve formatting and paragraph boundaries.');
152
+ } else if (isTargetList) {
153
+ log('[Reconcile] 🎯 ENTERING LIST GENERATION PATH');
154
+ log(`[Reconcile] cleanText preview: ${cleanText.substring(0, 100)}...`);
155
+ log(`[Reconcile] acceptedText preview: ${acceptedText.substring(0, 100)}...`);
156
+ return this.executeListGeneration(cleanText, numberingContext, runModel);
157
+ }
138
158
 
139
159
  log(`[Reconcile] Computed ${diffOps.length} diff operations`);
140
160
 
@@ -153,9 +173,10 @@ export class ReconciliationPipeline {
153
173
  await this.maybeYield(patchedModel.length, Math.max(acceptedText.length, cleanText.length));
154
174
 
155
175
  // Stage 6: Serialize to OOXML
156
- const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
157
- author: this.author,
158
- generateRedlines: this.generateRedlines
176
+ const resultOoxml = serializeToOoxml(patchedModel, pPr, formatHints, {
177
+ author: this.author,
178
+ generateRedlines: this.generateRedlines,
179
+ revisionIdAllocator: this.revisionIdAllocator
159
180
  });
160
181
 
161
182
  // Stage 7: Basic validation
@@ -172,14 +193,15 @@ export class ReconciliationPipeline {
172
193
  warnings
173
194
  };
174
195
 
175
- } catch (error) {
176
- logError('[Reconcile] Pipeline error:', error);
177
- return {
178
- ooxml: originalOoxml,
179
- isValid: false,
180
- warnings: [`Pipeline error: ${error.message}`]
181
- };
182
- }
196
+ } catch (error) {
197
+ logError('[Reconcile] Pipeline error:', error);
198
+ return {
199
+ ooxml: originalOoxml,
200
+ isValid: false,
201
+ warnings: [`Pipeline error: ${error.message}`],
202
+ error: error?.code ? { code: error.code, message: error.message } : undefined
203
+ };
204
+ }
183
205
  }
184
206
 
185
207
  /**
@@ -194,8 +216,13 @@ export class ReconciliationPipeline {
194
216
  try {
195
217
  // Check for well-formed XML by wrapping in namespace container
196
218
  const wrappedXml = `<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${ooxml}</root>`;
197
- const parser = createParser();
198
- const doc = parser.parseFromString(wrappedXml, 'application/xml');
219
+ const parsed = parseOoxmlSafe(wrappedXml, 'application/xml');
220
+ const doc = parsed.doc;
221
+
222
+ if (parsed.error || !doc) {
223
+ errors.push('Generated OOXML is not well-formed XML: ' + (parsed.error?.message || 'parse error'));
224
+ return { isValid: false, errors };
225
+ }
199
226
 
200
227
  const parseError = getXmlParseError(doc);
201
228
  if (parseError) {
@@ -276,9 +303,10 @@ export class ReconciliationPipeline {
276
303
  originalRunModel,
277
304
  originalText,
278
305
  generateRedlines: this.generateRedlines,
279
- author: this.author,
280
- font: this.font,
281
- numberingService: this.numberingService
306
+ author: this.author,
307
+ font: this.font,
308
+ revisionIdAllocator: this.revisionIdAllocator,
309
+ numberingService: this.numberingService
282
310
  });
283
311
  }
284
312
 
@@ -309,9 +337,10 @@ export class ReconciliationPipeline {
309
337
  };
310
338
  }
311
339
 
312
- const tableOoxml = generateTableOoxml(tableData, {
313
- generateRedlines: this.generateRedlines,
314
- author: this.author
340
+ const tableOoxml = generateTableOoxml(tableData, {
341
+ generateRedlines: this.generateRedlines,
342
+ author: this.author,
343
+ revisionIdAllocator: this.revisionIdAllocator
315
344
  });
316
345
 
317
346
  return {
@@ -24,7 +24,7 @@ const XMLNS_ATTR_REGEX = /\s+xmlns:[^=]+="[^"]*"/g;
24
24
  */
25
25
  export function serializeToOoxml(patchedModel, pPr, formatHints = [], options = {}) {
26
26
  const serializationOptions = normalizeSerializationOptions(options);
27
- const { author, generateRedlines } = serializationOptions;
27
+ const { generateRedlines } = serializationOptions;
28
28
  const paragraphs = [];
29
29
  let currentPPrXml = '';
30
30
  let currentPPrElement = null;
@@ -140,7 +140,8 @@ function normalizeSerializationOptions(options) {
140
140
  return {
141
141
  author: getDefaultAuthor(),
142
142
  generateRedlines: true,
143
- font: options
143
+ font: options,
144
+ revisionIdAllocator: null
144
145
  };
145
146
  }
146
147
 
@@ -152,7 +153,8 @@ function normalizeSerializationOptions(options) {
152
153
  return {
153
154
  author: resolvedAuthor,
154
155
  generateRedlines: normalized.generateRedlines ?? true,
155
- font: normalized.font ?? null
156
+ font: normalized.font ?? null,
157
+ revisionIdAllocator: normalized.revisionIdAllocator ?? null
156
158
  };
157
159
  }
158
160
 
@@ -228,7 +230,10 @@ function buildSimpleRun(text, rPrXml) {
228
230
  * @returns {string}
229
231
  */
230
232
  function buildDeletionXml(item, options = {}) {
231
- const metadata = createRevisionMetadata(options.author ?? getDefaultAuthor());
233
+ const metadata = createRevisionMetadata(
234
+ options.author ?? getDefaultAuthor(),
235
+ options.revisionIdAllocator
236
+ );
232
237
  const font = options.font ?? null;
233
238
  let rPr = item.rPrXml ? stripNamespaceDeclarations(item.rPrXml) : '';
234
239
 
@@ -250,7 +255,10 @@ function buildDeletionXml(item, options = {}) {
250
255
  * @returns {string}
251
256
  */
252
257
  function buildInsertionXml(item, formatHints, options = {}) {
253
- const metadata = createRevisionMetadata(options.author ?? getDefaultAuthor());
258
+ const metadata = createRevisionMetadata(
259
+ options.author ?? getDefaultAuthor(),
260
+ options.revisionIdAllocator
261
+ );
254
262
  const font = options.font ?? null;
255
263
 
256
264
  // Build the inner run content with format hints
@@ -0,0 +1,43 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, resolve } from 'path';
4
+
5
+ const repoRoot = process.cwd();
6
+ const syntheticDir = join(repoRoot, 'tmp', 'dashboard-docx');
7
+ const corpusSourceDir = join(repoRoot, 'tmp', 'superdoc-corpus');
8
+ const corpusFixturesArgIndex = process.argv.indexOf('--corpus-fixtures-dir');
9
+ if (corpusFixturesArgIndex >= 0 && !process.argv[corpusFixturesArgIndex + 1]) {
10
+ throw new Error('--corpus-fixtures-dir requires a path');
11
+ }
12
+ const suppliedCorpusFixturesDir = corpusFixturesArgIndex >= 0;
13
+ const corpusFixturesDir = suppliedCorpusFixturesDir
14
+ ? resolve(repoRoot, process.argv[corpusFixturesArgIndex + 1])
15
+ : join(repoRoot, 'tmp', 'superdoc-word-fixtures');
16
+ const manifest = JSON.parse(readFileSync(
17
+ join(repoRoot, 'tests', 'corpus', 'superdoc-english-legal-administrative.json'),
18
+ 'utf8'
19
+ ));
20
+ const run = (script, args = []) => execFileSync(process.execPath, [script, ...args], {
21
+ cwd: repoRoot,
22
+ stdio: 'inherit'
23
+ });
24
+
25
+ run('scripts/export-validation-fixtures.mjs', ['--output-dir', syntheticDir]);
26
+
27
+ const corpusReady = manifest.documents.every(item =>
28
+ existsSync(join(corpusSourceDir, `${item.id}.docx`))
29
+ );
30
+ if (corpusReady && suppliedCorpusFixturesDir) {
31
+ if (!existsSync(join(corpusFixturesDir, 'suite.json'))) {
32
+ throw new Error(`Supplied corpus fixture directory has no suite.json: ${corpusFixturesDir}`);
33
+ }
34
+ } else if (corpusReady) {
35
+ run('scripts/prepare-superdoc-word-corpus.mjs');
36
+ } else {
37
+ console.warn('Real-document corpus is not downloaded; embedding synthetic DOCX previews only.');
38
+ console.warn('Run npm run test:corpus:word once to fetch and validate the pinned corpus.');
39
+ }
40
+
41
+ const args = ['scripts/generate-test-dashboard.mjs', '--fixtures-dir', syntheticDir];
42
+ if (corpusReady) args.push('--corpus-fixtures-dir', corpusFixturesDir);
43
+ run(args[0], args.slice(1));
@@ -1,29 +1,21 @@
1
- import { readFileSync } from 'fs';
1
+ import { readFileSync } from 'node:fs';
2
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
- }
3
+ const declarationPath = new URL('../index.d.ts', import.meta.url);
4
+ const declarationText = readFileSync(declarationPath, 'utf8');
5
+ const declaredRuntimeNames = new Set();
6
+ const declarationPattern = /^export\s+(?:declare\s+)?(?:function|class|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/gm;
7
+ for (const match of declarationText.matchAll(declarationPattern)) {
8
+ declaredRuntimeNames.add(match[1]);
19
9
  }
20
10
 
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');
11
+ const runtimeModule = await import('../index.js');
12
+ const runtimeNames = Object.keys(runtimeModule).sort();
13
+ const missingDeclarations = runtimeNames.filter(name => !declaredRuntimeNames.has(name));
14
+
15
+ if (missingDeclarations.length > 0) {
16
+ throw new Error(
17
+ `Runtime exports missing from index.d.ts:\n${missingDeclarations.map(name => `- ${name}`).join('\n')}`
18
+ );
26
19
  }
27
- if (balance !== 0) throw new Error('index.d.ts has unbalanced braces');
28
20
 
29
- console.log('PASS: index.d.ts declaration smoke check');
21
+ console.log(`PASS: ${runtimeNames.length} runtime exports have declarations`);
@@ -1,71 +1,158 @@
1
+ import { createHash } from 'crypto';
1
2
  import { mkdirSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
3
+ import { join, resolve } from 'path';
3
4
 
4
5
  import { configureXmlProvider } from '../adapters/xml-adapter.js';
5
6
  import { validateRedlineOoxml } from '../core/redline-validation.js';
6
7
  import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
7
- import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
8
- import { buildMinimalDocx } from './lib/minimal-zip.mjs';
8
+ import {
9
+ applyOperationToDocumentXml,
10
+ applyOperationsToDocumentXml
11
+ } from '../services/standalone-operation-runner.js';
12
+ import { buildMinimalDocx, buildMinimalDocxEntries } from './lib/minimal-zip.mjs';
13
+ import { unzipEntries } from './lib/zip-reader.mjs';
14
+ import {
15
+ acceptTrackedChangesInOoxml,
16
+ rejectTrackedChangesInOoxml
17
+ } from '../services/revision-comment-management.js';
18
+ import { WORD_TASK_CASES } from '../tests/fixtures/word-task-cases.mjs';
9
19
 
10
20
  const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
11
21
  configureXmlProvider({ DOMParser, XMLSerializer });
12
22
 
13
23
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
14
- const outputDir = join(process.cwd(), 'tmp', 'validation-docx');
24
+ const outputArgIndex = process.argv.indexOf('--output-dir');
25
+ const requestedOutputDir = outputArgIndex >= 0 ? process.argv[outputArgIndex + 1] : null;
26
+ if (outputArgIndex >= 0 && !requestedOutputDir) throw new Error('--output-dir requires a path');
27
+ const outputDir = requestedOutputDir
28
+ ? resolve(process.cwd(), requestedOutputDir)
29
+ : join(process.cwd(), 'tmp', 'validation-docx');
15
30
  mkdirSync(outputDir, { recursive: true });
16
31
 
32
+ const escapeXmlText = text => String(text)
33
+ .replace(/&/g, '&amp;')
34
+ .replace(/</g, '&lt;')
35
+ .replace(/>/g, '&gt;');
36
+
17
37
  const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
18
38
  <w:document xmlns:w="${NS_W}">
19
39
  <w:body>
20
- <w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>
40
+ ${String(text).split(/\r?\n/).map(paragraphText =>
41
+ `<w:p><w:r><w:t xml:space="preserve">${escapeXmlText(paragraphText)}</w:t></w:r></w:p>`
42
+ ).join('\n ')}
21
43
  <w:sectPr/>
22
44
  </w:body>
23
45
  </w:document>`;
24
46
 
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
- ];
47
+ const cases = WORD_TASK_CASES;
52
48
 
53
49
  let failures = 0;
54
50
 
55
51
  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') {
52
+ const sourceDocumentXml = testCase.sourceDocumentXml || baseDocument(testCase.sourceText || testCase.original);
53
+ const operationOptions = { generateRedlines: true, ...(testCase.operationOptions || {}) };
54
+ const result = Array.isArray(testCase.batchOperations)
55
+ ? await applyOperationsToDocumentXml(
56
+ sourceDocumentXml,
57
+ testCase.batchOperations,
58
+ 'Validation',
59
+ null,
60
+ operationOptions
61
+ )
62
+ : await applyOperationToDocumentXml(
63
+ sourceDocumentXml,
64
+ testCase.operation || { type: 'redline', target: testCase.original, modified: testCase.modified },
65
+ 'Validation',
66
+ null,
67
+ operationOptions
68
+ );
69
+
70
+ if (testCase.expectAtomicRollback) {
71
+ if (
72
+ result?.hasChanges ||
73
+ result?.rolledBack !== true ||
74
+ result?.error?.code !== 'BATCH_OPERATION_FAILED' ||
75
+ result?.documentXml !== sourceDocumentXml
76
+ ) {
77
+ console.error(`FAIL ${testCase.name}: expected an atomic batch rollback`);
78
+ failures++;
79
+ continue;
80
+ }
81
+ } else if ((!result?.hasChanges && !testCase.expectNoOp) || result?.status === 'error') {
65
82
  console.error(`FAIL ${testCase.name}: redline did not apply (status=${result?.status}, error=${result?.error?.message})`);
66
83
  failures++;
67
84
  continue;
68
85
  }
86
+ if (testCase.expectNoOp && (result?.hasChanges || result?.status !== 'no-op' || result?.documentXml !== sourceDocumentXml)) {
87
+ console.error(`FAIL ${testCase.name}: expected a byte-identical no-op preserving prior revisions`);
88
+ failures++;
89
+ continue;
90
+ }
91
+
92
+ if (Number.isInteger(testCase.maxRevisionId)) {
93
+ const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
94
+ const revisionNames = ['ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange', 'cellIns', 'cellDel'];
95
+ const revisionIds = revisionNames
96
+ .flatMap(name => Array.from(resultDoc.getElementsByTagNameNS(NS_W, name)))
97
+ .map(node => Number.parseInt(node.getAttribute('w:id') || node.getAttribute('id') || '', 10))
98
+ .filter(Number.isFinite);
99
+ if (revisionIds.length === 0 || revisionIds.some(id => id > testCase.maxRevisionId)) {
100
+ console.error(`FAIL ${testCase.name}: revision IDs exceeded ${testCase.maxRevisionId}: ${revisionIds.join(', ')}`);
101
+ failures++;
102
+ continue;
103
+ }
104
+ }
105
+
106
+ if (testCase.requiredElements) {
107
+ const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
108
+ let missingRequiredElement = false;
109
+ for (const [localName, minimumCount] of Object.entries(testCase.requiredElements)) {
110
+ const actualCount = resultDoc.getElementsByTagNameNS(NS_W, localName).length;
111
+ if (actualCount < minimumCount) {
112
+ console.error(`FAIL ${testCase.name}: expected at least ${minimumCount} w:${localName} element(s), found ${actualCount}`);
113
+ failures++;
114
+ missingRequiredElement = true;
115
+ }
116
+ }
117
+ if (missingRequiredElement) continue;
118
+ }
119
+
120
+ if (testCase.requiredNumberingFormats) {
121
+ const numberingXml = testCase.packageParts?.numberingXml || result.numberingXml || '';
122
+ for (const format of testCase.requiredNumberingFormats) {
123
+ if (!numberingXml.includes(`<w:numFmt w:val="${format}"`)) {
124
+ console.error(`FAIL ${testCase.name}: required numbering format ${format} is missing`);
125
+ failures++;
126
+ continue;
127
+ }
128
+ }
129
+ }
130
+
131
+ if (testCase.requiredElementParents || testCase.requiredElementText) {
132
+ const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
133
+ let structuralRequirementFailed = false;
134
+
135
+ for (const [localName, parentLocalName] of Object.entries(testCase.requiredElementParents || {})) {
136
+ const nodes = Array.from(resultDoc.getElementsByTagNameNS(NS_W, localName));
137
+ const invalidNodes = nodes.filter(node => node.parentNode?.namespaceURI !== NS_W || node.parentNode?.localName !== parentLocalName);
138
+ if (invalidNodes.length > 0) {
139
+ console.error(`FAIL ${testCase.name}: ${invalidNodes.length} w:${localName} element(s) were not direct children of w:${parentLocalName}`);
140
+ failures++;
141
+ structuralRequirementFailed = true;
142
+ }
143
+ }
144
+
145
+ for (const [localName, expectedTexts] of Object.entries(testCase.requiredElementText || {})) {
146
+ const actualTexts = Array.from(resultDoc.getElementsByTagNameNS(NS_W, localName), node => node.textContent || '');
147
+ if (JSON.stringify(actualTexts) !== JSON.stringify(expectedTexts)) {
148
+ console.error(`FAIL ${testCase.name}: w:${localName} text mismatch; expected ${JSON.stringify(expectedTexts)}, found ${JSON.stringify(actualTexts)}`);
149
+ failures++;
150
+ structuralRequirementFailed = true;
151
+ }
152
+ }
153
+
154
+ if (structuralRequirementFailed) continue;
155
+ }
69
156
 
70
157
  const validation = validateRedlineOoxml(result.documentXml);
71
158
  const validationErrors = validation.issues.filter(issue => issue.severity === 'error');
@@ -80,22 +167,79 @@ for (const testCase of cases) {
80
167
  writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
81
168
  }
82
169
 
83
- const docx = buildMinimalDocx(result.documentXml, { numberingXml: result.numberingXml || null });
170
+ const packageParts = {
171
+ numberingXml: result.numberingXml || null,
172
+ ...(testCase.packageParts || {})
173
+ };
174
+ let packageEntries;
175
+ let docx;
176
+ try {
177
+ packageEntries = buildMinimalDocxEntries(result.documentXml, packageParts);
178
+ docx = buildMinimalDocx(result.documentXml, packageParts);
179
+ } catch (error) {
180
+ console.error(`FAIL ${testCase.name}: package validation failed: ${error.message}`);
181
+ failures++;
182
+ continue;
183
+ }
184
+
185
+ const unpacked = unzipEntries(docx);
186
+ const untouchedPartSha256 = {};
187
+ for (const entry of packageEntries.filter(item => /^word\/(?:comments|footnotes|endnotes|header[0-9]+|footer[0-9]+)\.xml$/.test(item.name))) {
188
+ const expectedBytes = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
189
+ const actualBytes = unpacked.get(entry.name);
190
+ if (!actualBytes?.equals(expectedBytes)) {
191
+ console.error(`FAIL ${testCase.name}: packaged ${entry.name} was not byte-identical to the configured source part`);
192
+ failures++;
193
+ docx = null;
194
+ break;
195
+ }
196
+ untouchedPartSha256[entry.name] = createHash('sha256').update(actualBytes).digest('hex');
197
+ }
198
+ if (!docx) continue;
84
199
  writeFileSync(join(outputDir, `${testCase.name}.docx`), docx);
85
200
 
201
+ const sourceDocx = buildMinimalDocx(sourceDocumentXml, testCase.packageParts || {});
202
+ const acceptedXml = acceptTrackedChangesInOoxml(result.documentXml, { allAuthors: true }).oxml;
203
+ const rejectedXml = rejectTrackedChangesInOoxml(result.documentXml, { allAuthors: true }).oxml;
204
+ const acceptedDocx = buildMinimalDocx(acceptedXml, packageParts);
205
+ const rejectedDocx = buildMinimalDocx(rejectedXml, packageParts);
206
+ writeFileSync(join(outputDir, `${testCase.name}.source.docx`), sourceDocx);
207
+ writeFileSync(join(outputDir, `${testCase.name}.accepted.docx`), acceptedDocx);
208
+ writeFileSync(join(outputDir, `${testCase.name}.rejected.docx`), rejectedDocx);
209
+
86
210
  // Expected text is derived from edit *intent*, not from this library's
87
211
  // accept/reject transforms, so external consumers (Word COM, LibreOffice)
88
212
  // act as independent oracles.
89
213
  const expected = {
90
214
  name: testCase.name,
91
- expectedAcceptedText: preprocessMarkdown(testCase.modified).cleanText,
92
- expectedRejectedText: testCase.original
215
+ category: testCase.category,
216
+ task: testCase.task,
217
+ coverageMetadata: testCase.coverageMetadata,
218
+ textFidelity: testCase.textFidelity || 'exact',
219
+ assertionMode: testCase.assertionMode || 'exact',
220
+ expectedAcceptedText: testCase.expectedAcceptedText ?? preprocessMarkdown(testCase.modified).cleanText,
221
+ expectedRejectedText: testCase.expectedRejectedText ?? testCase.original,
222
+ ...(testCase.assertionMode === 'contains' ? {
223
+ expectedAcceptedContains: testCase.expectedAcceptedContains || [],
224
+ expectedAcceptedAbsent: testCase.expectedAcceptedAbsent || [],
225
+ expectedRejectedContains: testCase.expectedRejectedContains || [],
226
+ expectedRejectedAbsent: testCase.expectedRejectedAbsent || []
227
+ } : {}),
228
+ sourceText: testCase.sourceText || testCase.original,
229
+ modifiedText: preprocessMarkdown(testCase.modified).cleanText,
230
+ requiredNumberingFormats: testCase.requiredNumberingFormats || [],
231
+ untouchedPartSha256
93
232
  };
94
233
  writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
95
234
 
96
- console.log(`wrote ${testCase.name}: .document.xml, .docx, .expected.json`);
235
+ console.log(`wrote ${testCase.name}: source, tracked, accepted, rejected, XML, and expectations`);
97
236
  }
98
237
 
238
+ writeFileSync(join(outputDir, 'suite.json'), `${JSON.stringify({
239
+ name: 'English legal and administrative Word differential suite',
240
+ cases: cases.map(testCase => testCase.name)
241
+ }, null, 2)}\n`, 'utf8');
242
+
99
243
  writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
100
244
 
101
245
  Generated by \`node scripts/export-validation-fixtures.mjs\`.
@@ -106,6 +250,8 @@ Each case produces:
106
250
  XSD validation and manual inspection).
107
251
  - \`<name>.docx\` — a minimal package assembled by release tooling only (the
108
252
  published library still has no zip dependency).
253
+ - \`<name>.source.docx\`, \`<name>.accepted.docx\`, and
254
+ \`<name>.rejected.docx\` — comparison states for the local HTML dashboard.
109
255
  - \`<name>.expected.json\` — the accept-all / reject-all plain-text outcomes
110
256
  derived from edit intent, used by external-consumer differential checks.
111
257