@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
@@ -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, '&')
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
 
@@ -0,0 +1,61 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
7
+ const repoRoot = dirname(scriptDir);
8
+ const manifestPath = join(repoRoot, 'tests', 'corpus', 'superdoc-english-legal-administrative.json');
9
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
10
+ const requestedIds = [];
11
+
12
+ for (let index = 2; index < process.argv.length; index++) {
13
+ if (process.argv[index] === '--id' && process.argv[index + 1]) {
14
+ requestedIds.push(process.argv[++index]);
15
+ } else {
16
+ throw new Error(`Unknown argument: ${process.argv[index]}. Use --id <pinned-sha256>.`);
17
+ }
18
+ }
19
+
20
+ if (requestedIds.length === 0) {
21
+ console.error('No document selected. Fetch one or more pinned references with:');
22
+ console.error(' npm run corpus:fetch:superdoc -- --id <sha256> [--id <sha256>]');
23
+ console.error('Pinned ids:');
24
+ for (const document of manifest.documents) console.error(` ${document.id} ${document.type} ${document.filename}`);
25
+ process.exit(2);
26
+ }
27
+
28
+ const byId = new Map(manifest.documents.map(document => [document.id, document]));
29
+ const outputDir = join(repoRoot, 'tmp', 'superdoc-corpus');
30
+ mkdirSync(outputDir, { recursive: true });
31
+
32
+ for (const id of requestedIds) {
33
+ const document = byId.get(id);
34
+ if (!document) throw new Error(`Document ${id} is not in the pinned SuperDoc reference manifest.`);
35
+
36
+ const outputPath = join(outputDir, `${id}.docx`);
37
+ let bytes = existsSync(outputPath) ? readFileSync(outputPath) : null;
38
+ let fetched = false;
39
+ const expectedDigest = document.downloadSha256 || id;
40
+ let digest = bytes ? createHash('sha256').update(bytes).digest('hex') : null;
41
+ if (digest !== expectedDigest) {
42
+ const response = await fetch(document.downloadUrl);
43
+ if (!response.ok) throw new Error(`Failed to fetch ${id}: HTTP ${response.status}`);
44
+ bytes = Buffer.from(await response.arrayBuffer());
45
+ digest = createHash('sha256').update(bytes).digest('hex');
46
+ fetched = true;
47
+ }
48
+ if (digest !== expectedDigest) {
49
+ throw new Error(`Hash mismatch for ${id}: expected ${expectedDigest}, received ${digest}`);
50
+ }
51
+
52
+ writeFileSync(outputPath, bytes);
53
+ writeFileSync(join(outputDir, `${id}.source.json`), `${JSON.stringify({
54
+ ...document,
55
+ dataset: manifest.name,
56
+ attribution: manifest.attribution,
57
+ datasetLicense: manifest.datasetLicense,
58
+ licenseUrl: manifest.licenseUrl
59
+ }, null, 2)}\n`, 'utf8');
60
+ console.log(`${fetched ? 'Fetched' : 'Verified cached'} ${id}.docx (${bytes.length} bytes)`);
61
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import {
6
+ COVERAGE_ORACLES,
7
+ COVERAGE_STRUCTURES,
8
+ COVERAGE_TASKS
9
+ } from './lib/word-coverage-metadata.mjs';
10
+ import { loadCoverageCatalogue } from './lib/word-coverage-catalogue.mjs';
11
+ import { selectVisualReviewCases } from './prepare-word-visual-review.mjs';
12
+
13
+ function parseArgs(argv) {
14
+ const outputIndex = argv.indexOf('--output');
15
+ if (outputIndex >= 0 && !argv[outputIndex + 1]) throw new Error('--output requires a path');
16
+ const fixturesIndex = argv.indexOf('--fixtures-dir');
17
+ if (fixturesIndex >= 0 && !argv[fixturesIndex + 1]) throw new Error('--fixtures-dir requires a path');
18
+ const corpusIndex = argv.indexOf('--corpus-fixtures-dir');
19
+ if (corpusIndex >= 0 && !argv[corpusIndex + 1]) throw new Error('--corpus-fixtures-dir requires a path');
20
+ return {
21
+ outputPath: outputIndex >= 0
22
+ ? resolve(process.cwd(), argv[outputIndex + 1])
23
+ : join(process.cwd(), 'docs', 'test-comparison-dashboard.html'),
24
+ fixturesDir: fixturesIndex >= 0
25
+ ? resolve(process.cwd(), argv[fixturesIndex + 1])
26
+ : join(process.cwd(), 'tmp', 'dashboard-docx'),
27
+ corpusFixturesDir: corpusIndex >= 0
28
+ ? resolve(process.cwd(), argv[corpusIndex + 1])
29
+ : null
30
+ };
31
+ }
32
+
33
+ export function buildDashboardData(fixturesDir = null, corpusFixturesDir = null) {
34
+ const { cases, priorities } = loadCoverageCatalogue();
35
+ const corpusSuitePath = corpusFixturesDir ? join(corpusFixturesDir, 'suite.json') : null;
36
+ const corpusSuite = corpusSuitePath && existsSync(corpusSuitePath)
37
+ ? JSON.parse(readFileSync(corpusSuitePath, 'utf8'))
38
+ : { cases: [] };
39
+ const corpusNames = new Map(corpusSuite.cases.map(item => [item.scenarioKey || item.sourceId, item.name]));
40
+ const visualEligible = new Set(
41
+ selectVisualReviewCases().map(testCase => `synthetic:${testCase.name}`)
42
+ );
43
+ return {
44
+ generatedAt: new Date().toISOString(),
45
+ tasks: COVERAGE_TASKS,
46
+ structures: COVERAGE_STRUCTURES,
47
+ oracles: COVERAGE_ORACLES,
48
+ priorities,
49
+ cases: cases.map(item => {
50
+ const syntheticName = item.identity.startsWith('synthetic:')
51
+ ? item.identity.slice('synthetic:'.length)
52
+ : null;
53
+ const sourceId = item.identity.startsWith('superdoc:')
54
+ ? item.identity.slice('superdoc:'.length)
55
+ : null;
56
+ const name = syntheticName || corpusNames.get(sourceId) || null;
57
+ const variantDir = syntheticName ? fixturesDir : corpusFixturesDir;
58
+ const readBase64 = suffix => {
59
+ const path = name && variantDir ? join(variantDir, `${name}${suffix}.docx`) : null;
60
+ return path && existsSync(path) ? readFileSync(path).toString('base64') : null;
61
+ };
62
+ const expectedPath = name && variantDir ? join(variantDir, `${name}.expected.json`) : null;
63
+ const expected = expectedPath && existsSync(expectedPath)
64
+ ? JSON.parse(readFileSync(expectedPath, 'utf8'))
65
+ : null;
66
+ const documentXmlPath = name && variantDir ? join(variantDir, `${name}.document.xml`) : null;
67
+ const documentXml = documentXmlPath && existsSync(documentXmlPath)
68
+ ? readFileSync(documentXmlPath, 'utf8')
69
+ : '';
70
+ return {
71
+ identity: item.identity,
72
+ displayName: sourceId
73
+ ? `${name} — ${expected?.originalTarget || item.detail}`
74
+ : name,
75
+ lane: item.lane,
76
+ category: item.category,
77
+ task: item.metadata.task,
78
+ structures: item.metadata.structures,
79
+ oracles: item.metadata.oracles,
80
+ manualReview: item.metadata.manualReview,
81
+ visualEligible: visualEligible.has(item.identity) || Boolean(sourceId),
82
+ docxVariants: name ? {
83
+ source: readBase64('.source'),
84
+ tracked: readBase64(''),
85
+ accepted: readBase64('.accepted'),
86
+ rejected: readBase64('.rejected')
87
+ } : null,
88
+ expectations: expected ? {
89
+ source: expected.sourceText || expected.originalTarget,
90
+ accepted: expected.expectedAcceptedText || expected.modifiedTarget,
91
+ rejected: expected.expectedRejectedText || expected.originalTarget
92
+ } : null,
93
+ revisions: {
94
+ insertions: (documentXml.match(/<w:ins\b/g) || []).length,
95
+ deletions: (documentXml.match(/<w:del\b/g) || []).length,
96
+ formatting: (documentXml.match(/<w:(?:rPrChange|pPrChange)\b/g) || []).length
97
+ }
98
+ };
99
+ })
100
+ };
101
+ }
102
+
103
+ function escapeJsonForHtml(value) {
104
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
105
+ }
106
+
107
+ export function renderDashboardHtml(data, libraries = {}) {
108
+ const encoded = escapeJsonForHtml(data);
109
+ const jszipSource = String(libraries.jszipSource || '').replace(/<\/script/gi, '<\\/script');
110
+ const docxPreviewSource = String(libraries.docxPreviewSource || '').replace(/<\/script/gi, '<\\/script');
111
+ return `<!doctype html>
112
+ <html lang="en">
113
+ <head>
114
+ <meta charset="utf-8">
115
+ <meta name="viewport" content="width=device-width, initial-scale=1">
116
+ <title>DOCX Redline Test Comparison Dashboard</title>
117
+ <style>
118
+ :root{color-scheme:light dark;--bg:#f4f7fb;--surface:#fff;--surface2:#eef3f9;--text:#172033;--muted:#68758a;--line:#d9e1ec;--blue:#2563eb;--blue2:#dbeafe;--green:#138a5b;--green2:#d9f5e9;--amber:#b66a00;--amber2:#fff1cf;--red:#be3b45;--red2:#ffe2e5;--purple:#7656c8;--purple2:#eee8ff;--shadow:0 12px 30px rgba(38,55,80,.09)}
119
+ @media(prefers-color-scheme:dark){:root{--bg:#10141d;--surface:#181e29;--surface2:#222a37;--text:#edf2fb;--muted:#a8b3c5;--line:#323c4d;--blue:#78a7ff;--blue2:#223d68;--green:#58d2a0;--green2:#183f33;--amber:#ffc66d;--amber2:#533d1e;--red:#ff929a;--red2:#55282d;--purple:#b9a1ff;--purple2:#382f59;--shadow:none}}
120
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}button,select,input{font:inherit;color:inherit}.shell{max-width:1480px;margin:auto;padding:28px;transition:max-width .18s ease}.top{display:flex;justify-content:space-between;gap:24px;align-items:end;margin-bottom:22px}.eyebrow{text-transform:uppercase;letter-spacing:.12em;color:var(--blue);font-weight:700;font-size:11px}h1{margin:4px 0 2px;font-size:clamp(25px,4vw,40px);line-height:1.12}h2{font-size:18px;margin:0 0 14px}.stamp{color:var(--muted);text-align:right}.controls{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 18px}.controls label{display:flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 11px}.controls select,.controls input{border:0;background:transparent;outline:none}.stats{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:12px;margin-bottom:18px}.stat,.panel{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.stat{padding:16px}.stat strong{display:block;font-size:25px;line-height:1.1}.stat span{color:var(--muted);font-size:12px}.layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(300px,.7fr);gap:18px}.sidebar-hidden .shell{max-width:none}.sidebar-hidden .layout{grid-template-columns:minmax(0,1fr)}.sidebar-hidden #dashboard-sidebar{display:none}.panel{padding:18px;margin-bottom:18px}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:12px}.sub{color:var(--muted);font-size:12px;margin-top:-8px;margin-bottom:14px}.matrix-wrap{overflow:auto}.matrix{display:grid;min-width:1040px;gap:4px;align-items:stretch}.matrix .label{font-size:11px;color:var(--muted);padding:8px 5px;display:flex;align-items:end}.matrix .row-label{justify-content:flex-end;text-align:right;align-items:center}.cell{border:0;border-radius:7px;min-height:42px;padding:4px;cursor:pointer;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-weight:700}.cell:hover,.cell:focus{outline:2px solid var(--blue);outline-offset:1px}.cell.tested{background:var(--green2);color:var(--green)}.cell.planned{background:var(--amber2);color:var(--amber)}.cell.missing{background:var(--red2);color:var(--red)}.cell.empty{color:var(--muted);font-weight:400}.cell.selected{box-shadow:inset 0 0 0 3px currentColor}.legend{display:flex;flex-wrap:wrap;gap:15px;margin-top:12px;color:var(--muted);font-size:12px}.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px}.bars{display:grid;gap:9px}.bar-row{display:grid;grid-template-columns:130px minmax(0,1fr) 34px;gap:8px;align-items:center}.track{height:12px;background:var(--surface2);border-radius:99px;overflow:hidden;display:flex}.seg-syn{background:var(--blue)}.seg-real{background:var(--purple)}.bar-value{text-align:right;font-variant-numeric:tabular-nums}.detail{min-height:180px}.detail h3{font-size:16px;margin:0 0 8px}.detail ul{margin:8px 0 0;padding-left:18px;max-height:310px;overflow:auto}.detail code{font-size:11px;overflow-wrap:anywhere}.badge{display:inline-block;border-radius:99px;padding:3px 8px;margin:3px 4px 3px 0;background:var(--surface2);font-size:11px}.badge.syn{background:var(--blue2);color:var(--blue)}.badge.real{background:var(--purple2);color:var(--purple)}.gap{padding:11px 0;border-top:1px solid var(--line)}.gap:first-of-type{border-top:0}.gap strong{display:block}.gap p{margin:4px 0;color:var(--muted);font-size:12px}.oracle{display:grid;grid-template-columns:1fr 44px;gap:8px;align-items:center;margin:9px 0}.oracle .track{height:8px}.search-results{margin-top:8px}.case-row{display:grid;grid-template-columns:minmax(220px,1.4fr) 100px 130px;gap:12px;padding:9px 0;border-top:1px solid var(--line);align-items:center}.case-row:first-child{border-top:0}.case-row code{overflow-wrap:anywhere;font-size:11px}.empty-state{color:var(--muted);padding:18px 0}.docx-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.docx-toolbar select{min-width:300px;max-width:100%;border:1px solid var(--line);background:var(--surface2);border-radius:8px;padding:7px 9px}.docx-status{color:var(--muted);font-size:12px}.docx-compare{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.docx-pane{min-width:0}.docx-pane h3{font-size:13px;margin:0 0 7px}.docx-view{background:var(--surface2);border:1px solid var(--line);border-radius:10px;min-height:330px;max-height:620px;overflow:auto}.docx-view .docx-wrapper{background:var(--surface2)!important;padding:12px!important}.docx-view .docx-wrapper>section.docx{background:#fff!important;color:#111!important;width:100%!important;min-height:380px!important;padding:44px!important;margin:0!important;box-shadow:none!important}.docx-view ins{background:#dcfce7;color:#166534;text-decoration:none}.docx-view del{background:#fee2e2;color:#991b1b}.screen-reader{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:980px){.stats{grid-template-columns:repeat(3,1fr)}.layout{grid-template-columns:1fr}.stamp{text-align:left}.top{align-items:start;flex-direction:column}}@media(max-width:760px){.docx-compare{grid-template-columns:1fr}}@media(max-width:620px){.shell{padding:16px}.stats{grid-template-columns:repeat(2,1fr)}.bar-row{grid-template-columns:100px minmax(0,1fr) 30px}.case-row{grid-template-columns:1fr}.controls label{width:100%;justify-content:space-between}.docx-toolbar select{min-width:0;width:100%}.docx-view .docx-wrapper>section.docx{padding:24px!important}}
121
+ .action{border:1px solid var(--line);background:var(--surface2);border-radius:8px;padding:7px 10px;cursor:pointer}.action:hover,.action:focus{border-color:var(--blue)}.action.primary{background:var(--blue);border-color:var(--blue);color:#fff}.preset-row{display:flex;gap:7px;flex-wrap:wrap;margin:0 0 14px}.docx-meta{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px}.pane-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:7px}.pane-head h3{margin:0}.pane-controls{display:flex;gap:7px}.pane-controls select{border:1px solid var(--line);background:var(--surface2);border-radius:7px;padding:5px}.expectations{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:14px}.expectation{background:var(--surface2);border-radius:9px;padding:11px;white-space:pre-wrap;overflow-wrap:anywhere}.expectation strong{display:block;margin-bottom:5px}.sync-control{display:inline-flex;align-items:center;gap:5px}.case-row{grid-template-columns:minmax(220px,1.4fr) 100px 120px auto}@media(max-width:760px){.expectations{grid-template-columns:1fr}}@media(max-width:620px){.case-row{grid-template-columns:1fr}.pane-head{align-items:flex-start;flex-direction:column}.pane-controls{width:100%}.pane-controls select{flex:1}}
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <main class="shell">
126
+ <header class="top"><div><div class="eyebrow">Reliability coverage</div><h1>What is actually being tested?</h1><div class="sub">Task × structure × oracle comparison</div></div><div class="stamp" id="stamp"></div></header>
127
+ <div class="controls" aria-label="Dashboard filters">
128
+ <label>Lane <select id="lane"><option value="all">All cases</option><option value="synthetic">Synthetic Word</option><option value="superdoc">Real documents</option></select></label>
129
+ <label>Category <select id="category"><option value="all">All categories</option><option value="legal">Legal</option><option value="administrative">Administrative</option></select></label>
130
+ <label>Find case <input id="search" type="search" placeholder="Name or structure"></label>
131
+ <button class="action" id="sidebar-toggle" type="button" aria-controls="dashboard-sidebar" aria-expanded="true">Hide sidebar</button>
132
+ </div>
133
+ <section class="stats" id="stats" aria-label="Coverage summary"></section>
134
+ <div class="layout">
135
+ <div>
136
+ <section class="panel"><div class="panel-head"><div><h2>Task × structure matrix</h2><div class="sub">Select a cell to inspect its cases. Planned cells are high-priority gaps with recorded dependencies.</div></div></div><div class="matrix-wrap"><div class="matrix" id="matrix"></div></div><div class="legend"><span><i style="background:var(--green2)"></i>Tested</span><span><i style="background:var(--amber2)"></i>Planned high priority</span><span><i style="background:var(--red2)"></i>Unplanned high priority</span><span><i style="background:var(--surface2)"></i>Not prioritized</span></div></section>
137
+ <section class="panel" id="docx-comparison"><h2>DOCX comparison workbench</h2><div class="sub">Compare source, tracked, accepted, and rejected states from synthetic fixtures and reviewed real legal/administrative documents. Tracked markup uses docxjs experimental revision rendering.</div><div class="docx-toolbar"><label for="docx-case">Document</label><select id="docx-case"></select><label class="sync-control"><input id="sync-scroll" type="checkbox" checked> Sync scroll</label><span class="docx-status" id="docx-status"></span></div><div class="preset-row"><button class="action primary" type="button" data-preset="source,tracked">Source ↔ tracked</button><button class="action" type="button" data-preset="source,accepted">Source ↔ accepted</button><button class="action" type="button" data-preset="accepted,rejected">Accepted ↔ rejected</button></div><div class="docx-meta" id="docx-meta"></div><div class="docx-compare"><div class="docx-pane"><div class="pane-head"><h3>Left document</h3><div class="pane-controls"><select id="left-view" aria-label="Left document state"></select><button class="action" id="left-download" type="button">Download</button></div></div><div class="docx-view" id="docx-left"></div></div><div class="docx-pane"><div class="pane-head"><h3>Right document</h3><div class="pane-controls"><select id="right-view" aria-label="Right document state"></select><button class="action" id="right-download" type="button">Download</button></div></div><div class="docx-view" id="docx-right"></div></div></div><div class="expectations"><div class="expectation"><strong>Expected rejected/source text</strong><span id="expected-before"></span></div><div class="expectation"><strong>Expected accepted text</strong><span id="expected-after"></span></div></div></section>
138
+ <section class="panel"><h2>Coverage by structure</h2><div class="sub">Unique case count, split between synthetic packages and reviewed real documents.</div><div class="bars" id="structure-bars"></div></section>
139
+ <section class="panel"><h2>Matching cases</h2><div class="sub" id="case-caption"></div><div class="search-results" id="case-list"></div></section>
140
+ </div>
141
+ <aside id="dashboard-sidebar">
142
+ <section class="panel detail" id="detail"><h2>Cell detail</h2><div class="empty-state">Select a matrix cell to see exact scenarios and gap rationale.</div></section>
143
+ <section class="panel"><h2>Oracle comparison</h2><div class="sub">How many filtered cases are checked by each independent oracle.</div><div id="oracles"></div></section>
144
+ <section class="panel"><h2>Planned high-priority gaps</h2><div id="gaps"></div></section>
145
+ </aside>
146
+ </div>
147
+ </main>
148
+ <script id="dashboard-data" type="application/json">${encoded}</script>
149
+ <script>${jszipSource}</script>
150
+ <script>${docxPreviewSource}</script>
151
+ <script>
152
+ const DATA=JSON.parse(document.getElementById('dashboard-data').textContent);
153
+ const $=id=>document.getElementById(id);let selected=null;
154
+ const label=s=>s.replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase());
155
+ const dispositionMap=new Map(DATA.priorities.emptyCellDispositions.map(d=>[d.task+'|'+d.structure,d]));
156
+ const prioritySet=new Set(DATA.priorities.highPriorityCells.map(d=>d.task+'|'+d.structure));
157
+ function filtered(){const lane=$('lane').value,cat=$('category').value,q=$('search').value.trim().toLowerCase();return DATA.cases.filter(c=>(lane==='all'||c.lane===lane)&&(cat==='all'||c.category===cat)&&(!q||c.identity.toLowerCase().includes(q)||c.task.includes(q)||c.structures.some(s=>s.includes(q))||c.oracles.some(o=>o.includes(q))))}
158
+ function countUnique(cases,key){return new Set(cases.flatMap(c=>c[key])).size}
159
+ function renderStats(cases){const high=DATA.priorities.highPriorityCells.length;const coveredHigh=DATA.priorities.highPriorityCells.filter(p=>cases.some(c=>c.task===p.task&&c.structures.includes(p.structure))).length;const values=[['Cases',cases.length],['Synthetic',cases.filter(c=>c.lane==='synthetic').length],['Real documents',cases.filter(c=>c.lane==='superdoc').length],['Structures',countUnique(cases,'structures')],['High-priority cells',coveredHigh+' / '+high],['Visual-render eligible',cases.filter(c=>c.visualEligible).length]];$('stats').innerHTML=values.map(([k,v])=>'<div class="stat"><strong>'+v+'</strong><span>'+k+'</span></div>').join('')}
160
+ function cellCases(cases,t,s){return cases.filter(c=>c.task===t&&c.structures.includes(s))}
161
+ function renderMatrix(cases){const grid=$('matrix');grid.style.gridTemplateColumns='150px repeat('+DATA.structures.length+',minmax(50px,1fr))';let html='<div></div>'+DATA.structures.map(s=>'<div class="label">'+label(s)+'</div>').join('');for(const t of DATA.tasks){html+='<div class="label row-label">'+label(t)+'</div>';for(const s of DATA.structures){const matches=cellCases(cases,t,s),key=t+'|'+s,priority=prioritySet.has(key),plan=dispositionMap.get(key);let cls=matches.length?'tested':priority?(plan?'planned':'missing'):'empty';html+='<button class="cell '+cls+(selected===key?' selected':'')+'" data-task="'+t+'" data-structure="'+s+'" aria-label="'+label(t)+' with '+label(s)+': '+(matches.length?matches.length+' cases':plan?'planned':'not covered')+'">'+(matches.length|| (plan?'P':priority?'!':'·'))+'</button>'}}grid.innerHTML=html;grid.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{selected=b.dataset.task+'|'+b.dataset.structure;renderMatrix(cases);renderDetail(cases,b.dataset.task,b.dataset.structure);const preview=cellCases(cases,b.dataset.task,b.dataset.structure).find(c=>c.docxVariants?.tracked);if(preview)selectDocx(preview.identity)}))}
162
+ function renderDetail(cases,t,s){const matches=cellCases(cases,t,s),plan=dispositionMap.get(t+'|'+s);let html='<h2>Cell detail</h2><h3>'+label(t)+' × '+label(s)+'</h3>';if(matches.length){html+='<span class="badge">'+matches.length+' case'+(matches.length===1?'':'s')+'</span><ul>'+matches.map(c=>'<li><code>'+c.identity+'</code> <span class="badge '+(c.lane==='synthetic'?'syn':'real')+'">'+(c.lane==='synthetic'?'synthetic':'real doc')+'</span></li>').join('')+'</ul>'}else if(plan){html+='<span class="badge">Planned</span><p>'+plan.reason+'</p><p><strong>Dependency:</strong> '+plan.dependency+'</p>'}else{html+='<div class="empty-state">No case is declared for this combination'+(prioritySet.has(t+'|'+s)?', and it is a high-priority gap.':'.')+'</div>'}$('detail').innerHTML=html}
163
+ function renderBars(cases){const counts=DATA.structures.map(s=>{const set=cases.filter(c=>c.structures.includes(s));return{s,syn:set.filter(c=>c.lane==='synthetic').length,real:set.filter(c=>c.lane==='superdoc').length,total:set.length}});const max=Math.max(1,...counts.map(x=>x.total));$('structure-bars').innerHTML=counts.map(x=>'<div class="bar-row"><span>'+label(x.s)+'</span><div class="track" aria-label="'+x.total+' cases"><span class="seg-syn" style="width:'+(x.syn/max*100)+'%"></span><span class="seg-real" style="width:'+(x.real/max*100)+'%"></span></div><span class="bar-value">'+x.total+'</span></div>').join('')}
164
+ function renderOracles(cases){const max=Math.max(1,cases.length);$('oracles').innerHTML=DATA.oracles.map(o=>{const n=cases.filter(c=>c.oracles.includes(o)).length;return'<div class="oracle"><div><div>'+label(o)+'</div><div class="track"><span class="seg-syn" style="width:'+(n/max*100)+'%"></span></div></div><strong>'+n+'</strong></div>'}).join('')}
165
+ function renderGaps(){const gaps=DATA.priorities.emptyCellDispositions;$('gaps').innerHTML=gaps.map(g=>'<div class="gap"><strong>'+label(g.task)+' × '+label(g.structure)+'</strong><p>'+g.reason+'</p><span class="badge">'+g.dependency+'</span></div>').join('')}
166
+ function renderCases(cases){$('case-caption').textContent=cases.length+' cases match the current filters.';$('case-list').innerHTML=cases.length?cases.map(c=>'<div class="case-row"><code>'+c.identity+'</code><span class="badge '+(c.lane==='synthetic'?'syn':'real')+'">'+(c.lane==='synthetic'?'synthetic':'real doc')+'</span><span>'+label(c.task)+'</span>'+(c.docxVariants?.tracked?'<button class="action view-case" type="button" data-identity="'+c.identity+'">Compare</button>':'')+'</div>').join(''):'<div class="empty-state">No cases match.</div>';$('case-list').querySelectorAll('.view-case').forEach(button=>button.addEventListener('click',()=>{selectDocx(button.dataset.identity);$('docx-comparison').scrollIntoView({behavior:'smooth',block:'start'})}))}
167
+ const VIEW_LABELS={source:'Source',tracked:'Tracked changes',accepted:'Accepted',rejected:'Rejected'};
168
+ const escapeHtml=value=>String(value??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
169
+ function decodeDocx(value){const raw=atob(value),bytes=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);return bytes}
170
+ function activeDocx(){return DATA.cases.find(c=>c.identity===$('docx-case').value)}
171
+ function viewOptions(selected){return Object.entries(VIEW_LABELS).map(([value,text])=>'<option value="'+value+'"'+(value===selected?' selected':'')+'>'+text+'</option>').join('')}
172
+ function renderDocxMeta(item){const r=item.revisions;$('docx-meta').innerHTML='<span class="badge '+(item.lane==='superdoc'?'real':'syn')+'">'+(item.lane==='superdoc'?'Reviewed real document':'Synthetic fixture')+'</span><span class="badge">'+label(item.category)+'</span><span class="badge">'+label(item.task)+'</span>'+item.structures.map(s=>'<span class="badge">'+label(s)+'</span>').join('')+'<span class="badge">'+r.insertions+' insertion revision'+(r.insertions===1?'':'s')+'</span><span class="badge">'+r.deletions+' deletion revision'+(r.deletions===1?'':'s')+'</span>'+(r.formatting?'<span class="badge">'+r.formatting+' formatting revision'+(r.formatting===1?'':'s')+'</span>':'');$('expected-before').textContent=item.expectations?.rejected||item.expectations?.source||'';$('expected-after').textContent=item.expectations?.accepted||''}
173
+ async function renderPane(side,item){const state=$(side+'-view').value,target=$('docx-'+side),payload=item.docxVariants?.[state];target.innerHTML='';if(!payload){target.innerHTML='<div class="empty-state">This document state is unavailable.</div>';return}const base={experimental:true,renderHeaders:true,renderFooters:true,renderFootnotes:true,renderEndnotes:true,renderComments:true,ignoreWidth:true,ignoreHeight:true,breakPages:false,useBase64URL:true,renderChanges:state==='tracked'};await window.docx.renderAsync(decodeDocx(payload),target,null,base)}
174
+ async function renderDocxComparison(){const item=activeDocx();if(!item?.docxVariants?.tracked){$('docx-status').textContent='No embedded DOCX is available.';return}if(!window.docx?.renderAsync){$('docx-status').textContent='docxjs failed to load.';return}$('docx-status').textContent='Rendering both documents…';renderDocxMeta(item);try{await Promise.all([renderPane('left',item),renderPane('right',item)]);$('docx-status').textContent='Ready · docx-preview 0.4.0';}catch(error){$('docx-status').textContent='Render error: '+error.message}}
175
+ function setComparison(left,right){$('left-view').value=left;$('right-view').value=right;renderDocxComparison()}
176
+ function selectDocx(identity){if(!$('docx-case').querySelector('option[value="'+identity+'"]'))return;$('docx-case').value=identity;renderDocxComparison()}
177
+ function downloadView(side){const item=activeDocx(),state=$(side+'-view').value,payload=item?.docxVariants?.[state];if(!payload)return;const blob=new Blob([decodeDocx(payload)],{type:'application/vnd.openxmlformats-officedocument.wordprocessingml.document'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=item.identity.replace(/^(synthetic|superdoc):/,'')+'.'+state+'.docx';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
178
+ function initDocx(){const available=DATA.cases.filter(c=>c.docxVariants?.tracked),options=lane=>available.filter(c=>c.lane===lane).map(c=>'<option value="'+c.identity+'">'+escapeHtml(c.displayName||c.identity)+'</option>').join('');$('docx-case').innerHTML='<optgroup label="Reviewed real documents">'+options('superdoc')+'</optgroup><optgroup label="Synthetic fixtures">'+options('synthetic')+'</optgroup>';$('left-view').innerHTML=viewOptions('source');$('right-view').innerHTML=viewOptions('tracked');const preferred=available.find(c=>c.lane==='superdoc')||available.find(c=>c.identity==='synthetic:administrative-tab-aligned-status')||available[0];if(preferred)$('docx-case').value=preferred.identity;$('docx-case').addEventListener('change',renderDocxComparison);$('left-view').addEventListener('change',renderDocxComparison);$('right-view').addEventListener('change',renderDocxComparison);document.querySelectorAll('[data-preset]').forEach(button=>button.addEventListener('click',()=>setComparison(...button.dataset.preset.split(','))));$('left-download').addEventListener('click',()=>downloadView('left'));$('right-download').addEventListener('click',()=>downloadView('right'));let syncing=false;for(const [from,to] of [[$('docx-left'),$('docx-right')],[$('docx-right'),$('docx-left')]])from.addEventListener('scroll',()=>{if(!$('sync-scroll').checked||syncing)return;syncing=true;const maxFrom=from.scrollHeight-from.clientHeight,maxTo=to.scrollHeight-to.clientHeight;to.scrollTop=maxFrom>0?from.scrollTop/maxFrom*maxTo:0;to.scrollLeft=from.scrollLeft;requestAnimationFrame(()=>{syncing=false})});renderDocxComparison()}
179
+ function render(){const cases=filtered();renderStats(cases);renderMatrix(cases);renderBars(cases);renderOracles(cases);renderCases(cases)}
180
+ function setSidebarHidden(hidden){document.body.classList.toggle('sidebar-hidden',hidden);const toggle=$('sidebar-toggle');toggle.textContent=hidden?'Show sidebar':'Hide sidebar';toggle.setAttribute('aria-expanded',String(!hidden));try{localStorage.setItem('docx-dashboard-sidebar-hidden',hidden?'1':'0')}catch{}}
181
+ function initSidebar(){let hidden=false;try{hidden=localStorage.getItem('docx-dashboard-sidebar-hidden')==='1'}catch{}setSidebarHidden(hidden);$('sidebar-toggle').addEventListener('click',()=>setSidebarHidden(!document.body.classList.contains('sidebar-hidden')))}
182
+ $('lane').addEventListener('change',render);$('category').addEventListener('change',render);$('search').addEventListener('input',render);$('stamp').textContent='Generated '+new Date(DATA.generatedAt).toLocaleString();renderGaps();render();initSidebar();initDocx();
183
+ </script>
184
+ </body>
185
+ </html>\n`;
186
+ }
187
+
188
+ const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
189
+ if (isCli) {
190
+ const { outputPath, fixturesDir, corpusFixturesDir } = parseArgs(process.argv.slice(2));
191
+ const libraries = {
192
+ jszipSource: readFileSync(join(process.cwd(), 'node_modules', 'jszip', 'dist', 'jszip.min.js'), 'utf8'),
193
+ docxPreviewSource: readFileSync(join(process.cwd(), 'node_modules', 'docx-preview', 'dist', 'docx-preview.min.js'), 'utf8')
194
+ };
195
+ const html = renderDashboardHtml(buildDashboardData(fixturesDir, corpusFixturesDir), libraries);
196
+ mkdirSync(dirname(outputPath), { recursive: true });
197
+ writeFileSync(outputPath, html, 'utf8');
198
+ console.log(`Wrote test comparison dashboard: ${outputPath}`);
199
+ }