@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
@@ -30,9 +30,10 @@ export async function executeListGeneration(options) {
30
30
  originalRunModel = [],
31
31
  originalText = '',
32
32
  generateRedlines = true,
33
- author = 'AI',
34
- font = null,
35
- numberingService
33
+ author = 'AI',
34
+ font = null,
35
+ revisionIdAllocator = null,
36
+ numberingService
36
37
  } = options;
37
38
 
38
39
  const normalizedListText = normalizeCompositeListMarkers(cleanText);
@@ -72,13 +73,18 @@ export async function executeListGeneration(options) {
72
73
  if (tableData.headers.length > 0 || tableData.rows.length > 0) {
73
74
  if (generateRedlines && results.length === 0 && deletionRuns.length > 0) {
74
75
  results.push(serializeToOoxml(deletionRuns, null, [], {
75
- author,
76
- generateRedlines,
77
- font
78
- }));
76
+ author,
77
+ generateRedlines,
78
+ font,
79
+ revisionIdAllocator
80
+ }));
79
81
  }
80
82
 
81
- results.push(generateTableOoxml(tableData, { generateRedlines, author }));
83
+ results.push(generateTableOoxml(tableData, {
84
+ generateRedlines,
85
+ author,
86
+ revisionIdAllocator
87
+ }));
82
88
  i = tableBlock.endIndex;
83
89
  continue;
84
90
  }
@@ -92,9 +98,10 @@ export async function executeListGeneration(options) {
92
98
  numberingContext,
93
99
  numberingService,
94
100
  generateRedlines,
95
- author,
96
- font,
97
- deletionRuns
101
+ author,
102
+ font,
103
+ revisionIdAllocator,
104
+ deletionRuns
98
105
  );
99
106
  results.push(entry.ooxml);
100
107
  }
@@ -226,9 +233,10 @@ function buildListEntry(
226
233
  numberingContext,
227
234
  numberingService,
228
235
  generateRedlines,
229
- author,
230
- font,
231
- deletionRuns
236
+ author,
237
+ font,
238
+ revisionIdAllocator,
239
+ deletionRuns
232
240
  ) {
233
241
  let pPrXml = '';
234
242
  let segmentText = '';
@@ -272,9 +280,10 @@ function buildListEntry(
272
280
 
273
281
  return {
274
282
  ooxml: serializeToOoxml(runModel, pPrXml, formatHints, {
275
- author,
276
- generateRedlines,
277
- font
278
- })
283
+ author,
284
+ generateRedlines,
285
+ font,
286
+ revisionIdAllocator
287
+ })
279
288
  };
280
289
  }
@@ -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`);