@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.
- 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 +1148 -572
- 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 +95 -104
- 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 +17 -13
- package/engine/surgical-diff-application.js +7 -21
- 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 +23 -5
- 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,58 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import fitz
|
|
3
|
+
from PIL import Image
|
|
4
|
+
|
|
5
|
+
out_dir = r'tmp/multilevel-bullet-visual'
|
|
6
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
7
|
+
|
|
8
|
+
cases = [
|
|
9
|
+
{
|
|
10
|
+
'id': 'synthetic-nested-child',
|
|
11
|
+
'dir': r'tmp/word-visual-review/rendered',
|
|
12
|
+
'prefix': 'administrative-list-change-nested-child',
|
|
13
|
+
'page': 0
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
'id': 'superdoc-board-agenda-multiple-children',
|
|
17
|
+
'dir': r'tmp/superdoc-word-visual-review/rendered',
|
|
18
|
+
'prefix': '38-administrative-administrative-list-change-board-agenda-multiple-children',
|
|
19
|
+
'page': 0
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
'id': 'superdoc-bylaws-nested-list-batch',
|
|
23
|
+
'dir': r'tmp/superdoc-word-visual-review/rendered',
|
|
24
|
+
'prefix': '28-legal-legal-bylaws-nested-list-batch',
|
|
25
|
+
'page': 0
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
views = ['allMarkup', 'acceptAll', 'rejectAll']
|
|
30
|
+
|
|
31
|
+
for c in cases:
|
|
32
|
+
case_images = []
|
|
33
|
+
for v in views:
|
|
34
|
+
pdf_path = os.path.join(c['dir'], f"{c['prefix']}-{v}.pdf")
|
|
35
|
+
doc = fitz.open(pdf_path)
|
|
36
|
+
page = doc.load_page(c['page'])
|
|
37
|
+
pix = page.get_pixmap(dpi=150)
|
|
38
|
+
img_name = f"{c['id']}--{v}.png"
|
|
39
|
+
img_path = os.path.join(out_dir, img_name)
|
|
40
|
+
pix.save(img_path)
|
|
41
|
+
print(f"Saved {img_path} ({pix.width}x{pix.height})")
|
|
42
|
+
case_images.append(img_path)
|
|
43
|
+
doc.close()
|
|
44
|
+
|
|
45
|
+
# Create 3-view contact sheet
|
|
46
|
+
imgs = [Image.open(p) for p in case_images]
|
|
47
|
+
total_w = sum(im.width for im in imgs) + 40
|
|
48
|
+
max_h = max(im.height for im in imgs) + 20
|
|
49
|
+
sheet = Image.new('RGB', (total_w, max_h), (240, 240, 240))
|
|
50
|
+
x = 10
|
|
51
|
+
for im in imgs:
|
|
52
|
+
sheet.paste(im, (x, 10))
|
|
53
|
+
x += im.width + 10
|
|
54
|
+
sheet_path = os.path.join(out_dir, f"{c['id']}--contact-sheet.png")
|
|
55
|
+
sheet.save(sheet_path)
|
|
56
|
+
print(f"Saved contact sheet {sheet_path}")
|
|
57
|
+
|
|
58
|
+
print("ALL MULTILEVEL VISUAL CASES RENDERED.")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$DocxPath,
|
|
3
|
+
[string]$OutputDir
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
$resolvedDocx = (Resolve-Path -LiteralPath $DocxPath).Path
|
|
8
|
+
$resolvedOut = (Resolve-Path -LiteralPath $OutputDir).Path
|
|
9
|
+
|
|
10
|
+
$word = New-Object -ComObject Word.Application
|
|
11
|
+
$word.Visible = $false
|
|
12
|
+
$word.DisplayAlerts = 0
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
foreach ($view in @('allMarkup', 'acceptAll', 'rejectAll')) {
|
|
16
|
+
$pdfPath = Join-Path $resolvedOut "multilevel-bullet--$view.pdf"
|
|
17
|
+
$doc = $word.Documents.OpenNoRepairDialog($resolvedDocx)
|
|
18
|
+
try {
|
|
19
|
+
if ($view -eq 'acceptAll') { $doc.AcceptAllRevisions(); $exportItem = 0 }
|
|
20
|
+
elseif ($view -eq 'rejectAll') { $doc.RejectAllRevisions(); $exportItem = 0 }
|
|
21
|
+
else { $doc.ShowRevisions = $true; $doc.PrintRevisions = $true; $exportItem = 7 }
|
|
22
|
+
$pages = $doc.ComputeStatistics(2)
|
|
23
|
+
$doc.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)
|
|
24
|
+
$item = Get-Item -LiteralPath $pdfPath
|
|
25
|
+
Write-Output "Rendered $view -> $pdfPath (pages=$pages, bytes=$($item.Length))"
|
|
26
|
+
} finally {
|
|
27
|
+
$doc.Close(0)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} finally {
|
|
31
|
+
$word.Quit()
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
const cases = [
|
|
6
|
+
'37-administrative-administrative-list-change-board-agenda-child',
|
|
7
|
+
'40-administrative-administrative-list-change-ppg-agenda-addition'
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const fixtureDir = 'tmp/superdoc-word-fixtures';
|
|
11
|
+
const visualDir = 'tmp/multilevel-bullet-visual';
|
|
12
|
+
const brainArtifactDir = 'C:/Users/Phara/.gemini/antigravity-ide/brain/ee12439b-cade-4f45-ab6e-a2a7b1bf1610';
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(visualDir)) {
|
|
15
|
+
fs.mkdirSync(visualDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const baseName of cases) {
|
|
19
|
+
console.log('\n=== Rendering Word COM for', baseName, '===');
|
|
20
|
+
const docxPath = `${fixtureDir}/${baseName}.docx`;
|
|
21
|
+
const acceptedDocx = `${fixtureDir}/${baseName}.accepted.docx`;
|
|
22
|
+
const rejectedDocx = `${fixtureDir}/${baseName}.rejected.docx`;
|
|
23
|
+
|
|
24
|
+
const psScript = `
|
|
25
|
+
$ErrorActionPreference = 'Stop'
|
|
26
|
+
$word = New-Object -ComObject Word.Application
|
|
27
|
+
$word.Visible = $false
|
|
28
|
+
$word.DisplayAlerts = 0
|
|
29
|
+
try {
|
|
30
|
+
Write-Output 'Rendering allMarkup...'
|
|
31
|
+
$doc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${docxPath}').Path)
|
|
32
|
+
$doc.ShowRevisions = $true
|
|
33
|
+
$doc.PrintRevisions = $true
|
|
34
|
+
$doc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--allMarkup.pdf', 17, $false, 0, 0, 1, 1, 7, $true, $true, 0, $true, $true, $false)
|
|
35
|
+
$doc.Close(0)
|
|
36
|
+
|
|
37
|
+
Write-Output 'Rendering acceptAll...'
|
|
38
|
+
$docAcc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${acceptedDocx}').Path)
|
|
39
|
+
$docAcc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--acceptAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
40
|
+
$docAcc.Close(0)
|
|
41
|
+
|
|
42
|
+
Write-Output 'Rendering rejectAll...'
|
|
43
|
+
$docRej = $word.Documents.OpenNoRepairDialog((Resolve-Path '${rejectedDocx}').Path)
|
|
44
|
+
$docRej.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--rejectAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
45
|
+
$docRej.Close(0)
|
|
46
|
+
Write-Output 'SUCCESS: Rendered all 3 views for ${baseName}.'
|
|
47
|
+
} finally {
|
|
48
|
+
$word.Quit()
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const psRes = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], { encoding: 'utf8' });
|
|
53
|
+
console.log(psRes.stdout);
|
|
54
|
+
if (psRes.stderr) console.error(psRes.stderr);
|
|
55
|
+
|
|
56
|
+
const pyScript = `
|
|
57
|
+
import pymupdf
|
|
58
|
+
import shutil
|
|
59
|
+
views = ['allMarkup', 'acceptAll', 'rejectAll']
|
|
60
|
+
base = '${baseName}'
|
|
61
|
+
vis_dir = '${visualDir}'
|
|
62
|
+
art_dir = '${brainArtifactDir}'
|
|
63
|
+
|
|
64
|
+
for v in views:
|
|
65
|
+
pdf_file = f"{vis_dir}/{base}--{v}.pdf"
|
|
66
|
+
doc = pymupdf.open(pdf_file)
|
|
67
|
+
print(f"{base} {v} page count: {len(doc)}")
|
|
68
|
+
for i, page in enumerate(doc):
|
|
69
|
+
pix = page.get_pixmap(dpi=150)
|
|
70
|
+
out_png = f"{vis_dir}/{base}--{v}-p{i+1}.png"
|
|
71
|
+
pix.save(out_png)
|
|
72
|
+
art_png = f"{art_dir}/{base}--{v}-p{i+1}.png"
|
|
73
|
+
shutil.copyfile(out_png, art_png)
|
|
74
|
+
print(f" Rendered {out_png} -> {art_png}")
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const pyRes = spawnSync('tmp/visual-qa-venv/Scripts/python.exe', ['-c', pyScript], { encoding: 'utf8' });
|
|
78
|
+
console.log(pyRes.stdout);
|
|
79
|
+
if (pyRes.stderr) console.error(pyRes.stderr);
|
|
80
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { relative, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
6
|
+
const coveragePath = resolve(repoRoot, 'coverage', 'coverage-final.json');
|
|
7
|
+
const baselinePath = resolve(repoRoot, 'tests', 'coverage-data', 'phase3-baseline.json');
|
|
8
|
+
const reviewedGapsPath = resolve(repoRoot, 'tests', 'coverage-data', 'phase3-reviewed-gaps.json');
|
|
9
|
+
const productionRoots = ['index.js', 'adapters/', 'core/', 'engine/', 'pipeline/', 'services/', 'orchestration/'];
|
|
10
|
+
const priorities = new Map([
|
|
11
|
+
['services/numbering-helpers.js', 'P0'],
|
|
12
|
+
['orchestration/route-plan.js', 'P0'],
|
|
13
|
+
['orchestration/list-markdown.js', 'P0'],
|
|
14
|
+
['pipeline/patching.js', 'P0'],
|
|
15
|
+
['engine/format-span-application.js', 'P0'],
|
|
16
|
+
['orchestration/list-structural-fallback.js', 'P1'],
|
|
17
|
+
['engine/table-mode.js', 'P1'],
|
|
18
|
+
['core/table-targeting.js', 'P1'],
|
|
19
|
+
['services/standalone-operation-runner.js', 'P1'],
|
|
20
|
+
['pipeline/pipeline.js', 'P1']
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
if (!existsSync(coveragePath)) {
|
|
24
|
+
throw new Error('coverage/coverage-final.json is missing; run npm run test:coverage first');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const raw = JSON.parse(readFileSync(coveragePath, 'utf8'));
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const [absolutePath, coverage] of Object.entries(raw)) {
|
|
30
|
+
const file = relative(repoRoot, absolutePath).replaceAll('\\', '/');
|
|
31
|
+
if (!productionRoots.some(root => root.endsWith('/') ? file.startsWith(root) : file === root)) continue;
|
|
32
|
+
const uncoveredFunctions = Object.entries(coverage.fnMap || {})
|
|
33
|
+
.filter(([id]) => (coverage.f?.[id] || 0) === 0)
|
|
34
|
+
.map(([, fn]) => ({ name: fn.name || '(anonymous)', line: fn.decl?.start?.line || fn.loc?.start?.line }));
|
|
35
|
+
const functionHits = Object.values(coverage.f || {});
|
|
36
|
+
const branchHits = Object.values(coverage.b || {}).flat();
|
|
37
|
+
files.push({
|
|
38
|
+
file,
|
|
39
|
+
priority: priorities.get(file) || 'P2',
|
|
40
|
+
functions: { covered: functionHits.filter(hits => hits > 0).length, total: functionHits.length },
|
|
41
|
+
branches: { covered: branchHits.filter(hits => hits > 0).length, total: branchHits.length },
|
|
42
|
+
uncoveredFunctions
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
files.sort((a, b) => a.priority.localeCompare(b.priority) || a.file.localeCompare(b.file));
|
|
46
|
+
|
|
47
|
+
const baseline = existsSync(baselinePath) ? JSON.parse(readFileSync(baselinePath, 'utf8')) : null;
|
|
48
|
+
const reviewedGaps = existsSync(reviewedGapsPath) ? JSON.parse(readFileSync(reviewedGapsPath, 'utf8')) : { items: [] };
|
|
49
|
+
const regressions = [];
|
|
50
|
+
if (baseline) {
|
|
51
|
+
const currentByFile = new Map(files.map(item => [item.file, item]));
|
|
52
|
+
for (const expected of baseline.targetFiles) {
|
|
53
|
+
const current = currentByFile.get(expected.file);
|
|
54
|
+
if (!current || current.functions.covered < expected.functionsCovered
|
|
55
|
+
|| current.branches.covered < expected.branchesCovered) {
|
|
56
|
+
regressions.push({ expected, current: current || null });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const actualPriorityGaps = new Set(files
|
|
62
|
+
.filter(item => item.priority === 'P0' || item.priority === 'P1')
|
|
63
|
+
.flatMap(item => item.uncoveredFunctions.map(fn => `${item.file}:${fn.line}`)));
|
|
64
|
+
const classifiedPriorityGaps = new Set((reviewedGaps.items || [])
|
|
65
|
+
.flatMap(item => (item.lines || []).map(line => `${item.file}:${line}`)));
|
|
66
|
+
const classificationErrors = [
|
|
67
|
+
...[...actualPriorityGaps]
|
|
68
|
+
.filter(key => !classifiedPriorityGaps.has(key))
|
|
69
|
+
.map(key => `Unclassified priority gap: ${key}`),
|
|
70
|
+
...[...classifiedPriorityGaps]
|
|
71
|
+
.filter(key => !actualPriorityGaps.has(key))
|
|
72
|
+
.map(key => `Stale priority-gap classification: ${key}`)
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const report = {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
production: {
|
|
78
|
+
functionsCovered: files.reduce((sum, item) => sum + item.functions.covered, 0),
|
|
79
|
+
functionsTotal: files.reduce((sum, item) => sum + item.functions.total, 0),
|
|
80
|
+
branchesCovered: files.reduce((sum, item) => sum + item.branches.covered, 0),
|
|
81
|
+
branchesTotal: files.reduce((sum, item) => sum + item.branches.total, 0)
|
|
82
|
+
},
|
|
83
|
+
files,
|
|
84
|
+
regressions,
|
|
85
|
+
classificationErrors
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (process.argv.includes('--json')) {
|
|
89
|
+
console.log(JSON.stringify(report, null, 2));
|
|
90
|
+
} else {
|
|
91
|
+
console.log(`Production functions: ${report.production.functionsCovered}/${report.production.functionsTotal}`);
|
|
92
|
+
console.log(`Production branches: ${report.production.branchesCovered}/${report.production.branchesTotal}`);
|
|
93
|
+
for (const item of files.filter(file => file.uncoveredFunctions.length > 0)) {
|
|
94
|
+
console.log(`\n${item.priority} ${item.file} (${item.functions.covered}/${item.functions.total} functions, ${item.branches.covered}/${item.branches.total} branches)`);
|
|
95
|
+
for (const fn of item.uncoveredFunctions) console.log(` ${fn.line}: ${fn.name}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (regressions.length > 0 || classificationErrors.length > 0) {
|
|
100
|
+
console.error(`Coverage regression in ${regressions.length} targeted file(s)`);
|
|
101
|
+
for (const issue of classificationErrors) console.error(issue);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COVERAGE_STRUCTURES,
|
|
3
|
+
COVERAGE_TASKS,
|
|
4
|
+
loadCoverageCatalogue,
|
|
5
|
+
validateCoveragePriorities
|
|
6
|
+
} from './lib/word-coverage-catalogue.mjs';
|
|
7
|
+
|
|
8
|
+
const { cases, priorities } = loadCoverageCatalogue();
|
|
9
|
+
const cells = validateCoveragePriorities(cases, priorities);
|
|
10
|
+
const dispositionByCell = new Map(
|
|
11
|
+
priorities.emptyCellDispositions.map(item => [`${item.task}/${item.structure}`, item])
|
|
12
|
+
);
|
|
13
|
+
const highPriority = new Set(
|
|
14
|
+
priorities.highPriorityCells.map(item => `${item.task}/${item.structure}`)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const report = {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
totals: {
|
|
20
|
+
cases: cases.length,
|
|
21
|
+
synthetic: cases.filter(item => item.lane === 'synthetic').length,
|
|
22
|
+
superdoc: cases.filter(item => item.lane === 'superdoc').length,
|
|
23
|
+
manualMissing: cases.filter(item => item.metadata.manualReview.status === 'missing').length,
|
|
24
|
+
manualStale: cases.filter(item => item.metadata.manualReview.status === 'stale').length,
|
|
25
|
+
aiPreflight: cases.filter(item => item.metadata.oracles.includes('ai-word-visual-preflight')).length,
|
|
26
|
+
syntheticWord: cases.filter(item => item.metadata.oracles.includes('synthetic-word')).length,
|
|
27
|
+
realDocumentWord: cases.filter(item => item.metadata.oracles.includes('real-document-word')).length
|
|
28
|
+
},
|
|
29
|
+
tasks: COVERAGE_TASKS.map(task => ({
|
|
30
|
+
task,
|
|
31
|
+
structures: COVERAGE_STRUCTURES.map(structure => {
|
|
32
|
+
const key = `${task}/${structure}`;
|
|
33
|
+
return {
|
|
34
|
+
structure,
|
|
35
|
+
count: cells.get(key).length,
|
|
36
|
+
highPriority: highPriority.has(key),
|
|
37
|
+
disposition: dispositionByCell.get(key) || null,
|
|
38
|
+
cases: cells.get(key)
|
|
39
|
+
};
|
|
40
|
+
})
|
|
41
|
+
})),
|
|
42
|
+
missingVisualReview: cases
|
|
43
|
+
.filter(item => item.metadata.manualReview.status !== 'current')
|
|
44
|
+
.map(item => ({
|
|
45
|
+
identity: item.identity,
|
|
46
|
+
status: item.metadata.manualReview.status,
|
|
47
|
+
aiPreflight: item.metadata.oracles.includes('ai-word-visual-preflight')
|
|
48
|
+
}))
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (process.argv.includes('--json')) {
|
|
52
|
+
console.log(JSON.stringify(report, null, 2));
|
|
53
|
+
} else {
|
|
54
|
+
console.log('# Word task/structure coverage matrix\n');
|
|
55
|
+
console.log(`Cases: ${report.totals.cases} (${report.totals.synthetic} synthetic, ${report.totals.superdoc} SuperDoc)`);
|
|
56
|
+
console.log(`Human review: ${report.totals.manualMissing} missing, ${report.totals.manualStale} stale\n`);
|
|
57
|
+
console.log(`Automated Word: ${report.totals.syntheticWord} synthetic, ${report.totals.realDocumentWord} real-document; AI visual preflight: ${report.totals.aiPreflight}\n`);
|
|
58
|
+
console.log(`| Task | ${COVERAGE_STRUCTURES.join(' | ')} |`);
|
|
59
|
+
console.log(`|---|${COVERAGE_STRUCTURES.map(() => '---:').join('|')}|`);
|
|
60
|
+
for (const row of report.tasks) {
|
|
61
|
+
console.log(`| ${row.task} | ${row.structures.map(cell => cell.count || '—').join(' | ')} |`);
|
|
62
|
+
}
|
|
63
|
+
console.log('\n## High-priority empty cells\n');
|
|
64
|
+
const empty = report.tasks.flatMap(row => row.structures
|
|
65
|
+
.filter(cell => cell.highPriority && cell.count === 0)
|
|
66
|
+
.map(cell => ({ task: row.task, ...cell })));
|
|
67
|
+
for (const cell of empty) {
|
|
68
|
+
console.log(`- ${cell.task} / ${cell.structure}: ${cell.disposition.status} — ${cell.disposition.reason} Dependency: ${cell.disposition.dependency}.`);
|
|
69
|
+
}
|
|
70
|
+
console.log('\nUse `--json` for stable machine-readable case identities and review status.');
|
|
71
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { generateContactSheetWithPyMuPdf, loadManifest } from './inspect-visual-evidence.mjs';
|
|
6
|
+
|
|
7
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const pythonVenvExe = join(repoRoot, 'tmp', 'visual-qa-venv', 'Scripts', 'python.exe');
|
|
9
|
+
|
|
10
|
+
export function renderPdfPageToImage(pdfPath, outputPngPath, pageIndex = 0, dpi = 150) {
|
|
11
|
+
if (!existsSync(pythonVenvExe) || !existsSync(pdfPath)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pythonScript = `
|
|
16
|
+
import sys
|
|
17
|
+
import fitz
|
|
18
|
+
|
|
19
|
+
pdf_path = sys.argv[1]
|
|
20
|
+
output_png = sys.argv[2]
|
|
21
|
+
page_idx = int(sys.argv[3]) if len(sys.argv) > 3 else 0
|
|
22
|
+
dpi = int(sys.argv[4]) if len(sys.argv) > 4 else 150
|
|
23
|
+
|
|
24
|
+
doc = fitz.open(pdf_path)
|
|
25
|
+
if page_idx < 0 or page_idx >= len(doc):
|
|
26
|
+
page_idx = 0
|
|
27
|
+
page = doc.load_page(page_idx)
|
|
28
|
+
pix = page.get_pixmap(dpi=dpi)
|
|
29
|
+
pix.save(output_png)
|
|
30
|
+
doc.close()
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
execFileSync(pythonVenvExe, ['-c', pythonScript, pdfPath, outputPngPath, String(pageIndex), String(dpi)], {
|
|
35
|
+
stdio: 'pipe',
|
|
36
|
+
encoding: 'utf8'
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sampleScenarios(scenarios, count = 3, seed = null) {
|
|
45
|
+
const list = [...scenarios];
|
|
46
|
+
if (seed !== null) {
|
|
47
|
+
// Simple seeded pseudo-random shuffle (mulberry32)
|
|
48
|
+
let s = seed;
|
|
49
|
+
const random = () => {
|
|
50
|
+
s |= 0;
|
|
51
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
52
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
53
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
54
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
55
|
+
};
|
|
56
|
+
for (let i = list.length - 1; i > 0; i--) {
|
|
57
|
+
const j = Math.floor(random() * (i + 1));
|
|
58
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// Standard shuffle
|
|
62
|
+
for (let i = list.length - 1; i > 0; i--) {
|
|
63
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
64
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return list.slice(0, count);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseCliArgs() {
|
|
71
|
+
const args = process.argv.slice(2);
|
|
72
|
+
const options = {
|
|
73
|
+
count: 3,
|
|
74
|
+
seed: null,
|
|
75
|
+
cases: null
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (const arg of args) {
|
|
79
|
+
if (arg.startsWith('--count=')) {
|
|
80
|
+
options.count = parseInt(arg.split('=')[1], 10) || 3;
|
|
81
|
+
} else if (arg.startsWith('--seed=')) {
|
|
82
|
+
options.seed = parseInt(arg.split('=')[1], 10) || 12345;
|
|
83
|
+
} else if (arg.startsWith('--cases=')) {
|
|
84
|
+
options.cases = arg.split('=')[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildMultimodalSpotCheckSamples(options = {}) {
|
|
92
|
+
const corpusDir = join(repoRoot, 'tmp', 'superdoc-word-visual-review', 'rendered');
|
|
93
|
+
const corpusManifestPath = join(corpusDir, 'manifest.json');
|
|
94
|
+
const manifest = loadManifest(corpusManifestPath);
|
|
95
|
+
|
|
96
|
+
if (!manifest || !Array.isArray(manifest.cases)) {
|
|
97
|
+
throw new Error(`Corpus visual manifest not found at ${corpusManifestPath}. Run: npm run test:corpus:word:visual`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scenariosPath = join(repoRoot, 'tests', 'corpus', 'superdoc-word-scenarios.json');
|
|
101
|
+
const scenariosData = JSON.parse(readFileSync(scenariosPath, 'utf8'));
|
|
102
|
+
const scenarioMap = new Map();
|
|
103
|
+
for (const sc of scenariosData.scenarios) {
|
|
104
|
+
if (sc.name) scenarioMap.set(sc.name, sc);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Filter or sample cases
|
|
108
|
+
let selectedCases = [];
|
|
109
|
+
if (options.cases && options.cases.length > 0) {
|
|
110
|
+
selectedCases = manifest.cases.filter(c =>
|
|
111
|
+
options.cases.some(target => c.identity?.includes(target) || c.name?.includes(target) || c.scenarioKey?.includes(target))
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
selectedCases = sampleScenarios(manifest.cases, options.count || 3, options.seed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (selectedCases.length === 0) {
|
|
118
|
+
throw new Error('No matching corpus visual cases found to sample.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const outputDir = join(repoRoot, 'tmp', 'multimodal-visual-spot-checks');
|
|
122
|
+
const imagesDir = join(outputDir, 'images');
|
|
123
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const sampleManifest = {
|
|
126
|
+
createdAt: new Date().toISOString(),
|
|
127
|
+
sampleCount: selectedCases.length,
|
|
128
|
+
cases: []
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
let promptMarkdown = '# Multimodal LLM Visual Inspection Prompt Bundle\n\n';
|
|
132
|
+
promptMarkdown += '> **Role:** You are an expert document layout quality assessor evaluating Microsoft Word tracked change renderings.\n';
|
|
133
|
+
promptMarkdown += '> **Task:** Visually inspect the provided side-by-side renders across three views (`allMarkup`, `acceptAll`, `rejectAll`) to detect visual layout, table, list, or typographic defects.\n\n';
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < selectedCases.length; i++) {
|
|
136
|
+
const testCase = selectedCases[i];
|
|
137
|
+
const scenarioKey = testCase.scenarioKey || testCase.name;
|
|
138
|
+
const caseRecord = {
|
|
139
|
+
index: i + 1,
|
|
140
|
+
scenarioKey,
|
|
141
|
+
category: testCase.category,
|
|
142
|
+
shape: testCase.shape,
|
|
143
|
+
pages: testCase.views?.allMarkup?.pages || 1,
|
|
144
|
+
images: {}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
promptMarkdown += `## Sample ${i + 1}: \`${scenarioKey}\`\n`;
|
|
148
|
+
promptMarkdown += `- **Category:** ${testCase.category} | **Shape:** ${testCase.shape}\n`;
|
|
149
|
+
promptMarkdown += `- **Document Length:** ${caseRecord.pages} page(s)\n\n`;
|
|
150
|
+
|
|
151
|
+
for (const viewName of ['allMarkup', 'acceptAll', 'rejectAll']) {
|
|
152
|
+
const viewInfo = testCase.views?.[viewName];
|
|
153
|
+
if (!viewInfo || !viewInfo.pdf) continue;
|
|
154
|
+
|
|
155
|
+
const pdfPath = join(corpusDir, viewInfo.pdf);
|
|
156
|
+
const highResImageName = `${scenarioKey}--${viewName}--page1.png`;
|
|
157
|
+
const highResImagePath = join(imagesDir, highResImageName);
|
|
158
|
+
const sheetImageName = `${scenarioKey}--${viewName}--sheet.png`;
|
|
159
|
+
const sheetImagePath = join(imagesDir, sheetImageName);
|
|
160
|
+
|
|
161
|
+
// Render high-res page 1
|
|
162
|
+
renderPdfPageToImage(pdfPath, highResImagePath, 0, 150);
|
|
163
|
+
// Render contact sheet (up to 6 pages)
|
|
164
|
+
generateContactSheetWithPyMuPdf(pdfPath, sheetImagePath, 6);
|
|
165
|
+
|
|
166
|
+
caseRecord.images[viewName] = {
|
|
167
|
+
highRes: highResImagePath,
|
|
168
|
+
sheet: sheetImagePath
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
promptMarkdown += `### View: \`${viewName}\`\n`;
|
|
172
|
+
promptMarkdown += `- High-Res Page 1: \`${highResImagePath}\`\n`;
|
|
173
|
+
promptMarkdown += `- Contact Sheet: \`${sheetImagePath}\`\n\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
promptMarkdown += '### Visual Inspection Questions:\n';
|
|
177
|
+
promptMarkdown += '1. **Markup Visibility & Isolation (`allMarkup`):** Are tracked insertions (underlined/colored) and deletions (strikethrough) clearly visible and cleanly localized, without wrapping or clipping adjacent text?\n';
|
|
178
|
+
promptMarkdown += '2. **Accepted State Correctness (`acceptAll`):** Does the document render cleanly without any leftover deletion markers or awkward spacing?\n';
|
|
179
|
+
promptMarkdown += '3. **Rejected State Fidelity (`rejectAll`):** Does the page restore the exact original layout, fonts, and numbering without ghost bullets or shifted margins?\n';
|
|
180
|
+
promptMarkdown += '4. **Structural & Table Integrity:** Did table column widths, grid lines, background fills, or list indents remain stable across all three views?\n\n';
|
|
181
|
+
promptMarkdown += '---\n\n';
|
|
182
|
+
|
|
183
|
+
sampleManifest.cases.push(caseRecord);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const manifestOutPath = join(outputDir, 'sample-manifest.json');
|
|
187
|
+
const promptOutPath = join(outputDir, 'multimodal-prompt.md');
|
|
188
|
+
|
|
189
|
+
writeFileSync(manifestOutPath, JSON.stringify(sampleManifest, null, 2), 'utf8');
|
|
190
|
+
writeFileSync(promptOutPath, promptMarkdown, 'utf8');
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
sampleManifest,
|
|
194
|
+
outputDir,
|
|
195
|
+
manifestOutPath,
|
|
196
|
+
promptOutPath
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runCli() {
|
|
201
|
+
console.log('=== Multimodal Visual Spot Check Sample Generator ===\n');
|
|
202
|
+
const options = parseCliArgs();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const result = buildMultimodalSpotCheckSamples(options);
|
|
206
|
+
console.log(`Generated ${result.sampleManifest.sampleCount} multimodal inspection samples:`);
|
|
207
|
+
for (const c of result.sampleManifest.cases) {
|
|
208
|
+
console.log(` - [Sample ${c.index}] ${c.scenarioKey} (${c.category}, ${c.shape}, ${c.pages} pages)`);
|
|
209
|
+
}
|
|
210
|
+
console.log(`\nManifest: ${result.manifestOutPath}`);
|
|
211
|
+
console.log(`Prompt bundle: ${result.promptOutPath}`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error('Error generating multimodal samples:', err.message);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
219
|
+
if (isCli) {
|
|
220
|
+
runCli();
|
|
221
|
+
}
|