@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
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const pythonVenvExe = join(repoRoot, 'tmp', 'visual-qa-venv', 'Scripts', 'python.exe');
|
|
8
|
+
|
|
9
|
+
export function loadManifest(manifestPath) {
|
|
10
|
+
if (!existsSync(manifestPath)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
let content = readFileSync(manifestPath, 'utf8');
|
|
14
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
15
|
+
content = content.slice(1);
|
|
16
|
+
}
|
|
17
|
+
return JSON.parse(content);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function inspectManifest(manifest, baseDir) {
|
|
21
|
+
const results = {
|
|
22
|
+
totalCases: 0,
|
|
23
|
+
renderedCases: 0,
|
|
24
|
+
passedCases: 0,
|
|
25
|
+
anomalies: [],
|
|
26
|
+
caseSummaries: []
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (!manifest || !Array.isArray(manifest.cases)) {
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
results.totalCases = manifest.cases.length;
|
|
34
|
+
|
|
35
|
+
for (const testCase of manifest.cases) {
|
|
36
|
+
const caseName = testCase.name || testCase.identity;
|
|
37
|
+
const views = testCase.views || {};
|
|
38
|
+
let caseRendered = true;
|
|
39
|
+
let caseValid = true;
|
|
40
|
+
const viewDetails = {};
|
|
41
|
+
|
|
42
|
+
for (const viewName of ['allMarkup', 'acceptAll', 'rejectAll']) {
|
|
43
|
+
const viewInfo = views[viewName];
|
|
44
|
+
if (!viewInfo || viewInfo.status !== 'rendered') {
|
|
45
|
+
caseRendered = false;
|
|
46
|
+
caseValid = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pdfFileName = viewInfo.pdf;
|
|
51
|
+
const pdfPath = join(baseDir, pdfFileName);
|
|
52
|
+
const exists = existsSync(pdfPath);
|
|
53
|
+
const pages = viewInfo.pages;
|
|
54
|
+
const bytes = viewInfo.bytes;
|
|
55
|
+
|
|
56
|
+
viewDetails[viewName] = { exists, pages, bytes };
|
|
57
|
+
|
|
58
|
+
if (!exists) {
|
|
59
|
+
results.anomalies.push({
|
|
60
|
+
caseName,
|
|
61
|
+
view: viewName,
|
|
62
|
+
reason: `Missing PDF file: ${pdfFileName}`
|
|
63
|
+
});
|
|
64
|
+
caseValid = false;
|
|
65
|
+
} else if (!bytes || bytes < 1000) {
|
|
66
|
+
results.anomalies.push({
|
|
67
|
+
caseName,
|
|
68
|
+
view: viewName,
|
|
69
|
+
reason: `Suspiciously small PDF size: ${bytes} bytes`
|
|
70
|
+
});
|
|
71
|
+
caseValid = false;
|
|
72
|
+
} else if (!pages || pages < 1) {
|
|
73
|
+
results.anomalies.push({
|
|
74
|
+
caseName,
|
|
75
|
+
view: viewName,
|
|
76
|
+
reason: `Invalid page count: ${pages}`
|
|
77
|
+
});
|
|
78
|
+
caseValid = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (caseRendered) {
|
|
83
|
+
results.renderedCases++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Layout sanity check between acceptAll and rejectAll
|
|
87
|
+
const acceptPages = viewDetails.acceptAll?.pages;
|
|
88
|
+
const rejectPages = viewDetails.rejectAll?.pages;
|
|
89
|
+
if (acceptPages !== undefined && rejectPages !== undefined) {
|
|
90
|
+
const pageDiff = Math.abs(acceptPages - rejectPages);
|
|
91
|
+
if (pageDiff > 2 && acceptPages > 0 && rejectPages > 0) {
|
|
92
|
+
results.anomalies.push({
|
|
93
|
+
caseName,
|
|
94
|
+
reason: `Unusual page count disparity: acceptAll=${acceptPages} vs rejectAll=${rejectPages}`
|
|
95
|
+
});
|
|
96
|
+
caseValid = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (caseValid) {
|
|
101
|
+
results.passedCases++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
results.caseSummaries.push({
|
|
105
|
+
name: caseName,
|
|
106
|
+
status: caseValid ? 'valid' : 'flagged',
|
|
107
|
+
rendered: caseRendered,
|
|
108
|
+
views: viewDetails
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function generateContactSheetWithPyMuPdf(pdfPath, outputPngPath, maxPages = 20) {
|
|
116
|
+
if (!existsSync(pythonVenvExe)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pythonScript = `
|
|
121
|
+
import sys
|
|
122
|
+
import fitz # PyMuPDF
|
|
123
|
+
from PIL import Image
|
|
124
|
+
|
|
125
|
+
pdf_path = sys.argv[1]
|
|
126
|
+
output_png = sys.argv[2]
|
|
127
|
+
max_pages = int(sys.argv[3]) if len(sys.argv) > 3 else 20
|
|
128
|
+
|
|
129
|
+
doc = fitz.open(pdf_path)
|
|
130
|
+
page_count = min(len(doc), max_pages)
|
|
131
|
+
|
|
132
|
+
# Render pages as pixmaps
|
|
133
|
+
images = []
|
|
134
|
+
for i in range(page_count):
|
|
135
|
+
page = doc.load_page(i)
|
|
136
|
+
pix = page.get_pixmap(dpi=72)
|
|
137
|
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
|
138
|
+
images.append(img)
|
|
139
|
+
|
|
140
|
+
doc.close()
|
|
141
|
+
|
|
142
|
+
if not images:
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
# Tile into contact sheet grid (up to 5 columns)
|
|
146
|
+
cols = min(5, len(images))
|
|
147
|
+
rows = (len(images) + cols - 1) // cols
|
|
148
|
+
page_w, page_h = images[0].size
|
|
149
|
+
sheet = Image.new("RGB", (cols * page_w, rows * page_h), color=(240, 240, 240))
|
|
150
|
+
|
|
151
|
+
for idx, img in enumerate(images):
|
|
152
|
+
c = idx % cols
|
|
153
|
+
r = idx // cols
|
|
154
|
+
sheet.paste(img, (c * page_w, r * page_h))
|
|
155
|
+
|
|
156
|
+
sheet.save(output_png)
|
|
157
|
+
print("Saved contact sheet:", output_png)
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
execFileSync(pythonVenvExe, ['-c', pythonScript, pdfPath, outputPngPath, String(maxPages)], {
|
|
162
|
+
stdio: 'pipe',
|
|
163
|
+
encoding: 'utf8'
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function runCli() {
|
|
172
|
+
console.log('=== Word Visual Evidence Inspection ===\n');
|
|
173
|
+
|
|
174
|
+
const syntheticDir = join(repoRoot, 'tmp', 'word-visual-review', 'rendered');
|
|
175
|
+
const syntheticManifestPath = join(syntheticDir, 'manifest.json');
|
|
176
|
+
const corpusDir = join(repoRoot, 'tmp', 'superdoc-word-visual-review', 'rendered');
|
|
177
|
+
const corpusManifestPath = join(corpusDir, 'manifest.json');
|
|
178
|
+
|
|
179
|
+
let totalCases = 0;
|
|
180
|
+
let totalRendered = 0;
|
|
181
|
+
let totalValid = 0;
|
|
182
|
+
let totalAnomalies = 0;
|
|
183
|
+
|
|
184
|
+
// Check synthetic lane
|
|
185
|
+
const syntheticManifest = loadManifest(syntheticManifestPath);
|
|
186
|
+
if (syntheticManifest) {
|
|
187
|
+
const syntheticResults = inspectManifest(syntheticManifest, syntheticDir);
|
|
188
|
+
console.log(`Synthetic Word Visual Evidence (${syntheticDir}):`);
|
|
189
|
+
console.log(` Word Version: ${syntheticManifest.word?.version || 'unknown'} (Build: ${syntheticManifest.word?.build || 'unknown'})`);
|
|
190
|
+
console.log(` Total Cases: ${syntheticResults.totalCases}`);
|
|
191
|
+
console.log(` Rendered Cases: ${syntheticResults.renderedCases}`);
|
|
192
|
+
console.log(` Valid Cases: ${syntheticResults.passedCases}`);
|
|
193
|
+
if (syntheticResults.anomalies.length > 0) {
|
|
194
|
+
console.log(' Anomalies:');
|
|
195
|
+
for (const a of syntheticResults.anomalies) {
|
|
196
|
+
console.log(` - [${a.caseName}] ${a.view ? a.view + ': ' : ''}${a.reason}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
console.log('');
|
|
200
|
+
totalCases += syntheticResults.totalCases;
|
|
201
|
+
totalRendered += syntheticResults.renderedCases;
|
|
202
|
+
totalValid += syntheticResults.passedCases;
|
|
203
|
+
totalAnomalies += syntheticResults.anomalies.length;
|
|
204
|
+
} else {
|
|
205
|
+
console.log(`Synthetic lane manifest not found at ${syntheticManifestPath}\n Run: npm run test:word:visual\n`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check corpus lane
|
|
209
|
+
const corpusManifest = loadManifest(corpusManifestPath);
|
|
210
|
+
if (corpusManifest) {
|
|
211
|
+
const corpusResults = inspectManifest(corpusManifest, corpusDir);
|
|
212
|
+
console.log(`SuperDoc Real-Document Visual Evidence (${corpusDir}):`);
|
|
213
|
+
console.log(` Word Version: ${corpusManifest.word?.version || 'unknown'} (Build: ${corpusManifest.word?.build || 'unknown'})`);
|
|
214
|
+
console.log(` Total Cases: ${corpusResults.totalCases}`);
|
|
215
|
+
console.log(` Rendered Cases: ${corpusResults.renderedCases}`);
|
|
216
|
+
console.log(` Valid Cases: ${corpusResults.passedCases}`);
|
|
217
|
+
if (corpusResults.anomalies.length > 0) {
|
|
218
|
+
console.log(' Anomalies:');
|
|
219
|
+
for (const a of corpusResults.anomalies) {
|
|
220
|
+
console.log(` - [${a.caseName}] ${a.view ? a.view + ': ' : ''}${a.reason}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
console.log('');
|
|
224
|
+
totalCases += corpusResults.totalCases;
|
|
225
|
+
totalRendered += corpusResults.renderedCases;
|
|
226
|
+
totalValid += corpusResults.passedCases;
|
|
227
|
+
totalAnomalies += corpusResults.anomalies.length;
|
|
228
|
+
} else {
|
|
229
|
+
console.log(`SuperDoc corpus manifest not found at ${corpusManifestPath}\n Run: npm run test:corpus:word:visual\n`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log(`Summary: ${totalRendered}/${totalCases} rendered, ${totalValid} valid, ${totalAnomalies} anomalies.\n`);
|
|
233
|
+
|
|
234
|
+
if (process.argv.includes('--contact-sheets')) {
|
|
235
|
+
console.log('Generating visual review contact sheets...');
|
|
236
|
+
const sheetsDir = join(repoRoot, 'tmp', 'word-visual-review', 'inspected-sheets');
|
|
237
|
+
mkdirSync(sheetsDir, { recursive: true });
|
|
238
|
+
|
|
239
|
+
let generated = 0;
|
|
240
|
+
if (syntheticManifest) {
|
|
241
|
+
for (const c of syntheticManifest.cases.slice(0, 10)) {
|
|
242
|
+
for (const [viewName, viewInfo] of Object.entries(c.views)) {
|
|
243
|
+
const pdfPath = join(syntheticDir, viewInfo.pdf);
|
|
244
|
+
const outPath = join(sheetsDir, `synthetic--${c.name}--${viewName}.png`);
|
|
245
|
+
if (existsSync(pdfPath)) {
|
|
246
|
+
const success = generateContactSheetWithPyMuPdf(pdfPath, outPath, 10);
|
|
247
|
+
if (success) generated++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (corpusManifest) {
|
|
253
|
+
for (const c of corpusManifest.cases.slice(0, 10)) {
|
|
254
|
+
for (const [viewName, viewInfo] of Object.entries(c.views)) {
|
|
255
|
+
const pdfPath = join(corpusDir, viewInfo.pdf);
|
|
256
|
+
const outPath = join(sheetsDir, `superdoc--${c.scenarioKey || c.name}--${viewName}.png`);
|
|
257
|
+
if (existsSync(pdfPath)) {
|
|
258
|
+
const success = generateContactSheetWithPyMuPdf(pdfPath, outPath, 10);
|
|
259
|
+
if (success) generated++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
console.log(`Generated ${generated} visual contact sheets in ${sheetsDir}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
269
|
+
if (isCli) {
|
|
270
|
+
runCli();
|
|
271
|
+
}
|