@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
@@ -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
+ }