@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.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1124 -545
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +84 -99
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +3 -3
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +22 -4
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- package/services/table-reconciliation.js +23 -11
package/pipeline/patching.js
CHANGED
|
@@ -182,9 +182,8 @@ export function applyPatches(splitModel, diffOps, options) {
|
|
|
182
182
|
|
|
183
183
|
function processInsertionOperation(context) {
|
|
184
184
|
const {
|
|
185
|
-
insertOp,
|
|
186
|
-
|
|
187
|
-
styleLookup,
|
|
185
|
+
insertOp,
|
|
186
|
+
styleLookup,
|
|
188
187
|
patchedModel,
|
|
189
188
|
state,
|
|
190
189
|
options,
|
package/pipeline/pipeline.js
CHANGED
|
@@ -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 {
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
log(
|
|
136
|
-
|
|
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
|
|
198
|
-
const doc =
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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));
|
package/scripts/check-types.mjs
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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 {
|
|
8
|
-
|
|
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
|
|
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, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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}:
|
|
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
|
|