@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,187 @@
1
+ import '../tests/setup-xml-provider.mjs';
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { spawnSync } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import { buildMinimalDocx } from './lib/minimal-zip.mjs';
8
+ import { applyOperationsToDocumentXml } from '../services/standalone-operation-runner.js';
9
+ import {
10
+ acceptTrackedChangesInOoxml,
11
+ rejectTrackedChangesInOoxml
12
+ } from '../index.js';
13
+ import { elementsByLocalName, parseXml } from '../tests/helpers/ooxml-assertions.mjs';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const rootDir = path.resolve(__dirname, '..');
17
+ const outputDir = path.join(rootDir, 'tmp', 'multilevel-bullet-visual');
18
+ const pythonExe = path.join(rootDir, 'tmp', 'visual-qa-venv', 'Scripts', 'python.exe');
19
+
20
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
21
+
22
+ const NUMBERING_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
23
+ <w:numbering xmlns:w="${NS_W}" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">
24
+ <w:abstractNum w:abstractNumId="0" w15:restartNumberingAfterBreak="0">
25
+ <w:nsid w:val="71A1B101"/><w:multiLevelType w:val="hybridMultilevel"/><w:tmpl w:val="71A1B101"/>
26
+ <w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1."/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="720"/></w:tabs><w:ind w:left="720" w:hanging="360"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/></w:rPr></w:lvl>
27
+ <w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="(%2)"/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="1440"/></w:tabs><w:ind w:left="1440" w:hanging="360"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/></w:rPr></w:lvl>
28
+ <w:lvl w:ilvl="2"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:tabs><w:tab w:val="num" w:pos="2160"/></w:tabs><w:ind w:left="2160" w:hanging="360"/></w:pPr><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/></w:rPr></w:lvl>
29
+ </w:abstractNum>
30
+ <w:num w:numId="10"><w:abstractNumId w:val="0"/></w:num>
31
+ </w:numbering>`;
32
+
33
+ const SOURCE_DOCUMENT_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
34
+ <w:document xmlns:w="${NS_W}">
35
+ <w:body>
36
+ <w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="32"/></w:rPr><w:t>Corporate Governance Charter</w:t></w:r></w:p>
37
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Corporate Governance Framework.</w:t></w:r></w:p>
38
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Audit and Risk Oversight.</w:t></w:r></w:p>
39
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="2"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Review internal accounting controls.</w:t></w:r></w:p>
40
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="2"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Deprecated manual transaction logging.</w:t></w:r></w:p>
41
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Executive Compensation.</w:t></w:r></w:p>
42
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="2"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Review executive incentive structures.</w:t></w:r></w:p>
43
+ <w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="10"/></w:numPr></w:pPr><w:r><w:t>Regulatory Compliance Protocol.</w:t></w:r></w:p>
44
+ <w:sectPr/>
45
+ </w:body>
46
+ </w:document>`;
47
+
48
+ async function main() {
49
+ fs.mkdirSync(outputDir, { recursive: true });
50
+
51
+ console.log('=== Multi-Level Bullet Visual Test ===');
52
+ console.log('Building 3-level list document and executing concurrent multi-level operations...');
53
+
54
+ // Multi-level operations:
55
+ // 1. Level 0 edit: Corporate Governance Framework -> Corporate Governance and Enterprise Risk Framework
56
+ // 2. Level 2 insert: under Audit and Risk Oversight, insert "Conduct annual cybersecurity vulnerability audit."
57
+ // 3. Level 2 edit: Review internal accounting controls -> Review and audit internal accounting controls
58
+ // 4. Level 2 consolidation/edit: Deprecated manual transaction logging -> Maintain automated electronic audit trails
59
+ // 5. Level 1 edit: Executive Compensation -> Executive Compensation and Performance Oversight
60
+ const operations = [
61
+ {
62
+ type: 'redline',
63
+ target: 'Corporate Governance Framework.',
64
+ modified: 'Corporate Governance and Enterprise Risk Framework.'
65
+ },
66
+ {
67
+ type: 'list-change',
68
+ target: 'Audit and Risk Oversight.',
69
+ modified: '1. Audit and Risk Oversight.\n - Conduct annual cybersecurity vulnerability audit.'
70
+ },
71
+ {
72
+ type: 'redline',
73
+ target: 'Review internal accounting controls.',
74
+ modified: 'Review and audit internal accounting controls.'
75
+ },
76
+ {
77
+ type: 'redline',
78
+ target: 'Deprecated manual transaction logging.',
79
+ modified: 'Maintain automated electronic audit trails.'
80
+ },
81
+ {
82
+ type: 'redline',
83
+ target: 'Executive Compensation.',
84
+ modified: 'Executive Compensation and Performance Oversight.'
85
+ }
86
+ ];
87
+
88
+ const result = await applyOperationsToDocumentXml(SOURCE_DOCUMENT_XML, operations, 'VisualTester');
89
+ if (!result.hasChanges) {
90
+ throw new Error('Operations did not report changes');
91
+ }
92
+
93
+ // Generate the 3 OOXML states
94
+ const allMarkupXml = result.documentXml;
95
+ const acceptAllXml = acceptTrackedChangesInOoxml(allMarkupXml, { author: 'VisualTester' }).oxml;
96
+ const _rejectAllXml = rejectTrackedChangesInOoxml(allMarkupXml, { author: 'VisualTester' }).oxml;
97
+
98
+ // Verify structural invariants in OOXML
99
+ const acceptedDoc = parseXml(acceptAllXml);
100
+ const acceptedParagraphs = elementsByLocalName(acceptedDoc, 'p');
101
+ // Heading + 8 list items = 9 paragraphs
102
+ console.log(`Accepted paragraphs: ${acceptedParagraphs.length}`);
103
+
104
+ const parts = { numberingXml: NUMBERING_XML };
105
+ const docxPath = path.join(outputDir, 'multilevel-bullet-edits.docx');
106
+ fs.writeFileSync(docxPath, buildMinimalDocx(allMarkupXml, parts));
107
+ console.log(`Generated DOCX fixture: ${docxPath}`);
108
+
109
+ // Render 3 views using Word COM via PowerShell
110
+ console.log('Rendering 3 views in Microsoft Word COM (ExportAsFixedFormat)...');
111
+ const psScriptPath = path.join(__dirname, 'render-multilevel-bullet-visual.ps1');
112
+ const psResult = spawnSync('powershell', [
113
+ '-NoProfile',
114
+ '-ExecutionPolicy',
115
+ 'Bypass',
116
+ '-File',
117
+ psScriptPath,
118
+ '-DocxPath',
119
+ docxPath,
120
+ '-OutputDir',
121
+ outputDir
122
+ ], {
123
+ encoding: 'utf8',
124
+ cwd: rootDir
125
+ });
126
+
127
+ if (psResult.status !== 0) {
128
+ console.error('Word COM rendering failed:', psResult.stderr || psResult.stdout);
129
+ throw new Error('Word COM rendering failed');
130
+ }
131
+ console.log(psResult.stdout.trim());
132
+
133
+ // Render high-res PNGs and contact sheet using PyMuPDF
134
+ console.log('Converting PDFs to 150 DPI page images using PyMuPDF...');
135
+ const pyScript = `
136
+ import os
137
+ import fitz
138
+ from PIL import Image
139
+
140
+ output_dir = r"${outputDir}"
141
+ views = ["allMarkup", "acceptAll", "rejectAll"]
142
+ rendered_images = []
143
+
144
+ for view in views:
145
+ pdf_path = os.path.join(output_dir, f"multilevel-bullet--{view}.pdf")
146
+ doc = fitz.open(pdf_path)
147
+ page = doc.load_page(0)
148
+ pix = page.get_pixmap(dpi=150)
149
+ img_path = os.path.join(output_dir, f"multilevel-bullet--{view}--page1.png")
150
+ pix.save(img_path)
151
+ rendered_images.append(img_path)
152
+ print(f"Rendered {img_path} ({pix.width}x{pix.height})")
153
+ doc.close()
154
+
155
+ # Create side-by-side contact sheet
156
+ images = [Image.open(p) for p in rendered_images]
157
+ total_w = sum(img.width for img in images) + 40
158
+ max_h = max(img.height for img in images) + 20
159
+ sheet = Image.new("RGB", (total_w, max_h), (240, 240, 240))
160
+ x = 10
161
+ for img in images:
162
+ sheet.paste(img, (x, 10))
163
+ x += img.width + 10
164
+
165
+ sheet_path = os.path.join(output_dir, "multilevel-bullet--contact-sheet.png")
166
+ sheet.save(sheet_path)
167
+ print(f"Created contact sheet: {sheet_path} ({sheet.width}x{sheet.height})")
168
+ `;
169
+
170
+ const pyResult = spawnSync(pythonExe, ['-c', pyScript], {
171
+ encoding: 'utf8',
172
+ cwd: rootDir
173
+ });
174
+
175
+ if (pyResult.status !== 0) {
176
+ console.error('PyMuPDF conversion failed:', pyResult.stderr || pyResult.stdout);
177
+ throw new Error('PyMuPDF conversion failed');
178
+ }
179
+ console.log(pyResult.stdout.trim());
180
+
181
+ console.log('\n✅ Visual test execution complete! Images ready for multimodal inspection in:', outputDir);
182
+ }
183
+
184
+ main().catch(err => {
185
+ console.error('Fatal error:', err);
186
+ process.exit(1);
187
+ });
@@ -0,0 +1,43 @@
1
+ param(
2
+ [string]$FixturesDir = "tmp/superdoc-word-fixtures",
3
+ [string]$SourcesDir = "tmp/superdoc-corpus"
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $repoRoot = Split-Path -Parent $PSScriptRoot
9
+ $fixturesPath = if ([System.IO.Path]::IsPathRooted($FixturesDir)) {
10
+ [System.IO.Path]::GetFullPath($FixturesDir)
11
+ } else {
12
+ [System.IO.Path]::GetFullPath((Join-Path $repoRoot $FixturesDir))
13
+ }
14
+ $sourcesPath = if ([System.IO.Path]::IsPathRooted($SourcesDir)) {
15
+ [System.IO.Path]::GetFullPath($SourcesDir)
16
+ } else {
17
+ [System.IO.Path]::GetFullPath((Join-Path $repoRoot $SourcesDir))
18
+ }
19
+ $manifestPath = Join-Path $repoRoot 'tests\corpus\superdoc-english-legal-administrative.json'
20
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
21
+ $fetchArgs = @((Join-Path $repoRoot 'scripts\fetch-superdoc-corpus.mjs'))
22
+ foreach ($document in $manifest.documents) {
23
+ $fetchArgs += '--id'
24
+ $fetchArgs += [string]$document.id
25
+ }
26
+
27
+ & node @fetchArgs
28
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
29
+
30
+ & node (Join-Path $repoRoot 'scripts\prepare-superdoc-word-corpus.mjs') `
31
+ --input-dir $sourcesPath `
32
+ --output-dir $fixturesPath
33
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
34
+
35
+ & (Join-Path $repoRoot 'scripts\package-superdoc-word-fixtures.ps1') `
36
+ -FixturesDir $fixturesPath `
37
+ -SourcesDir $sourcesPath
38
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
39
+
40
+ & (Join-Path $repoRoot 'scripts\word-com-differential.ps1') `
41
+ -FixturesDir $fixturesPath `
42
+ -SourcesDir $sourcesPath
43
+ exit $LASTEXITCODE
@@ -0,0 +1,116 @@
1
+ param(
2
+ [string]$FixturesDir = "",
3
+ [string]$OutputDir = "tmp/superdoc-word-visual-review/rendered",
4
+ [string[]]$Case = @()
5
+ )
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+ $repoRoot = Split-Path -Parent $PSScriptRoot
9
+ if (-not $FixturesDir) { $FixturesDir = "tmp/superdoc-word-visual-review/fixtures-$PID" }
10
+ $fixturesPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $FixturesDir))
11
+ $outputPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDir))
12
+ $manifestPath = Join-Path $outputPath 'manifest.json'
13
+ New-Item -ItemType Directory -Force -Path $fixturesPath, $outputPath | Out-Null
14
+
15
+ & (Join-Path $PSScriptRoot 'word-com-corpus-suite.ps1') -FixturesDir $fixturesPath
16
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
17
+
18
+ $prepareArgs = @((Join-Path $PSScriptRoot 'prepare-corpus-word-visual-review.mjs'), "--output=$manifestPath")
19
+ foreach ($caseName in $Case) { $prepareArgs += "--case=$caseName" }
20
+ & node @prepareArgs
21
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
22
+
23
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
24
+ $word = $null
25
+ $rpcDisconnected = -2147417848 # 0x80010108 RPC_E_DISCONNECTED
26
+
27
+ function Test-RpcDisconnected($exception) {
28
+ return $exception -is [Runtime.InteropServices.COMException] -and $exception.HResult -eq $rpcDisconnected
29
+ }
30
+
31
+ function Start-WordApplication {
32
+ $application = New-Object -ComObject Word.Application
33
+ $application.Visible = $false
34
+ $application.DisplayAlerts = 0
35
+ return $application
36
+ }
37
+
38
+ function Stop-WordApplicationSafely($application) {
39
+ if ($null -eq $application) { return }
40
+ try { $application.Quit() | Out-Null }
41
+ catch {
42
+ if (-not (Test-RpcDisconnected $_.Exception)) { throw }
43
+ Write-Warning 'Word disconnected while quitting; the COM process was already gone.'
44
+ }
45
+ }
46
+
47
+ function Close-WordDocumentSafely($document) {
48
+ if ($null -eq $document) { return }
49
+ try { $document.Close(0) | Out-Null }
50
+ catch {
51
+ if (-not (Test-RpcDisconnected $_.Exception)) { throw }
52
+ # ExportAsFixedFormat occasionally tears down Word's document proxy
53
+ # after successfully writing a PDF. Do not let cleanup hide the valid
54
+ # render; the next operation will restart Word if needed.
55
+ Write-Warning 'Word disconnected after export while closing the document.'
56
+ }
57
+ }
58
+
59
+ function Export-View($word, [string]$docxPath, [string]$pdfPath, [string]$viewName) {
60
+ $document = $null
61
+ try {
62
+ $document = $word.Documents.OpenNoRepairDialog($docxPath)
63
+ if ($viewName -eq 'acceptAll') { $document.AcceptAllRevisions(); $exportItem = 0 }
64
+ elseif ($viewName -eq 'rejectAll') { $document.RejectAllRevisions(); $exportItem = 0 }
65
+ else { $document.ShowRevisions = $true; $document.PrintRevisions = $true; $exportItem = 7 }
66
+ $pages = $document.ComputeStatistics(2)
67
+ $document.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)
68
+ $pdf = Get-Item -LiteralPath $pdfPath -ErrorAction Stop
69
+ if ($pdf.Length -lt 1000 -or $pages -lt 1) { throw "Word produced invalid visual evidence" }
70
+ return @{ pages = [int]$pages; bytes = [long]$pdf.Length }
71
+ }
72
+ finally { Close-WordDocumentSafely $document }
73
+ }
74
+
75
+ try {
76
+ $word = Start-WordApplication
77
+ $manifest.word.version = [string]$word.Version
78
+ try { $manifest.word.build = [string]$word.Build } catch { $manifest.word.build = $null }
79
+ foreach ($entry in $manifest.cases) {
80
+ $docxPath = Join-Path $fixturesPath "$($entry.name).docx"
81
+ foreach ($viewProperty in $entry.views.PSObject.Properties) {
82
+ $pdfPath = Join-Path $outputPath ([string]$viewProperty.Value.pdf)
83
+ $render = $null
84
+ for ($attempt = 1; $attempt -le 2; $attempt++) {
85
+ try {
86
+ $render = Export-View $word $docxPath $pdfPath ([string]$viewProperty.Name)
87
+ break
88
+ }
89
+ catch {
90
+ if (-not (Test-RpcDisconnected $_.Exception) -or $attempt -eq 2) { throw }
91
+ Write-Warning "Word disconnected while rendering $($entry.identity) $($viewProperty.Name); restarting and retrying once."
92
+ Stop-WordApplicationSafely $word
93
+ $word = Start-WordApplication
94
+ }
95
+ }
96
+ $viewProperty.Value.status = 'rendered'
97
+ $viewProperty.Value.pages = $render.pages
98
+ $viewProperty.Value.bytes = $render.bytes
99
+ }
100
+ $entry.renderStatus = 'rendered'
101
+ Write-Output "RENDERED $($entry.identity) (markup, accept-all, reject-all)"
102
+ }
103
+ }
104
+ finally {
105
+ Stop-WordApplicationSafely $word
106
+ $manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
107
+ }
108
+
109
+ Write-Output "Corpus visual evidence: $($manifest.cases.Count) cases, $($manifest.cases.Count * 3) PDFs."
110
+ Write-Output "Manifest: $manifestPath"
111
+
112
+ # Keep the self-contained comparison dashboard synchronized with the exact
113
+ # process-isolated DOCX fixtures that Word just validated and rendered.
114
+ & node (Join-Path $PSScriptRoot 'build-test-dashboard.mjs') --corpus-fixtures-dir $fixturesPath
115
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
116
+ Write-Output "Dashboard refreshed from: $fixturesPath"
@@ -1,5 +1,6 @@
1
1
  param(
2
- [string]$FixturesDir = "tmp/validation-docx"
2
+ [string]$FixturesDir = "tmp/validation-docx",
3
+ [string]$SourcesDir = ""
3
4
  )
4
5
 
5
6
  # Differential validation against desktop Microsoft Word (the authoritative
@@ -15,10 +16,68 @@ param(
15
16
 
16
17
  $ErrorActionPreference = 'Stop'
17
18
 
18
- function Get-NormalizedText([string]$text) {
19
+ function Get-ComparableText([string]$text, [string]$fidelity = 'exact') {
19
20
  if ($null -eq $text) { return '' }
20
- $text = $text -replace [string][char]7, ' ' # table cell markers
21
- return ($text -replace '\s+', ' ').Trim()
21
+ $text = $text -replace [string][char]7, '' # table cell markers
22
+ $text = $text -replace [string][char]2, '' # footnote/endnote reference markers
23
+ $text = $text -replace "`r`n", "`n"
24
+ $text = $text -replace "`r", "`n"
25
+ $text = $text -replace "`n+$", '' # Word's terminal paragraph mark
26
+ if ($fidelity -eq 'normalized') {
27
+ return ($text -replace '\s+', ' ').Trim()
28
+ }
29
+ return $text
30
+ }
31
+
32
+ function Test-ContainsAssertions([string]$text, $required, $absent, [string]$phase, [string]$name) {
33
+ $passed = $true
34
+ foreach ($value in @($required)) {
35
+ if (-not $text.Contains([string]$value)) {
36
+ Write-Output "FAIL ${name}: ${phase} is missing expected text: $value"
37
+ $passed = $false
38
+ }
39
+ }
40
+ foreach ($value in @($absent)) {
41
+ if ($text.Contains([string]$value)) {
42
+ Write-Output "FAIL ${name}: ${phase} still contains superseded text: $value"
43
+ $passed = $false
44
+ }
45
+ }
46
+ return $passed
47
+ }
48
+
49
+ function Test-UntouchedPartHashes([string]$docxPath, $expectedHashes, [string]$name) {
50
+ $properties = @($expectedHashes.PSObject.Properties)
51
+ if ($properties.Count -eq 0) { return $true }
52
+
53
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
54
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($docxPath)
55
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
56
+ try {
57
+ foreach ($property in $properties) {
58
+ $entry = $archive.GetEntry([string]$property.Name)
59
+ if ($null -eq $entry) {
60
+ Write-Output "FAIL ${name}: package is missing untouched part $($property.Name)"
61
+ return $false
62
+ }
63
+ $stream = $entry.Open()
64
+ try {
65
+ $actual = ([System.BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant()
66
+ }
67
+ finally {
68
+ $stream.Dispose()
69
+ }
70
+ if ($actual -ne [string]$property.Value) {
71
+ Write-Output "FAIL ${name}: untouched part hash changed for $($property.Name)"
72
+ return $false
73
+ }
74
+ }
75
+ }
76
+ finally {
77
+ $sha256.Dispose()
78
+ $archive.Dispose()
79
+ }
80
+ return $true
22
81
  }
23
82
 
24
83
  $resolvedDir = Resolve-Path -LiteralPath $FixturesDir -ErrorAction SilentlyContinue
@@ -27,7 +86,17 @@ if (-not $resolvedDir) {
27
86
  exit 1
28
87
  }
29
88
 
30
- $expectations = Get-ChildItem -LiteralPath $resolvedDir -Filter '*.expected.json' | Sort-Object Name
89
+ $suitePath = Join-Path $resolvedDir 'suite.json'
90
+ if (Test-Path -LiteralPath $suitePath) {
91
+ $suite = Get-Content -LiteralPath $suitePath -Raw -Encoding UTF8 | ConvertFrom-Json
92
+ $expectations = @($suite.cases | ForEach-Object {
93
+ $caseName = if ($_ -is [string]) { [string]$_ } else { [string]$_.name }
94
+ Get-Item -LiteralPath (Join-Path $resolvedDir "$caseName.expected.json") -ErrorAction Stop
95
+ })
96
+ }
97
+ else {
98
+ $expectations = @(Get-ChildItem -LiteralPath $resolvedDir -Filter '*.expected.json' | Sort-Object Name)
99
+ }
31
100
  if ($expectations.Count -eq 0) {
32
101
  Write-Error "No *.expected.json fixtures in '$resolvedDir'. Run: node scripts/export-validation-fixtures.mjs"
33
102
  exit 1
@@ -38,10 +107,38 @@ $failures = 0
38
107
  $results = @()
39
108
 
40
109
  function Open-FixtureDocument($word, [string]$path) {
41
- # Single-argument Open: Windows PowerShell 5.1 COM binding rejects the
42
- # long optional-parameter signature. Defaults leave the document
43
- # writable, which accept/reject requires; nothing is ever saved.
44
- return $word.Documents.Open($path)
110
+ # OpenNoRepairDialog prevents malformed real-world packages from blocking
111
+ # a headless run behind an invisible repair prompt. It still leaves the
112
+ # document writable for accept/reject; nothing is ever saved.
113
+ return $word.Documents.OpenNoRepairDialog($path)
114
+ }
115
+
116
+ function Get-ScopeText($document, [string]$scope, [string]$fidelity) {
117
+ if ($scope -ne 'headers') {
118
+ return Get-ComparableText $document.Content.Text $fidelity
119
+ }
120
+ $parts = @()
121
+ foreach ($section in @($document.Sections)) {
122
+ foreach ($headerType in 1..3) {
123
+ $header = $section.Headers.Item($headerType)
124
+ if ($header.Exists) {
125
+ $parts += Get-ComparableText $header.Range.Text $fidelity
126
+ }
127
+ }
128
+ }
129
+ return ($parts -join "`n")
130
+ }
131
+
132
+ function Get-ScopeRevisionCount($document, [string]$scope) {
133
+ if ($scope -ne 'headers') { return $document.Revisions.Count }
134
+ $count = 0
135
+ foreach ($section in @($document.Sections)) {
136
+ foreach ($headerType in 1..3) {
137
+ $header = $section.Headers.Item($headerType)
138
+ if ($header.Exists) { $count += $header.Range.Revisions.Count }
139
+ }
140
+ }
141
+ return $count
45
142
  }
46
143
 
47
144
  try {
@@ -60,23 +157,63 @@ try {
60
157
  # -Encoding UTF8 is required: Windows PowerShell 5.1 otherwise reads
61
158
  # BOM-less UTF-8 sidecars as ANSI and garbles non-ASCII expectations.
62
159
  $expected = Get-Content -LiteralPath $expectationFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
63
- $expectedAccepted = Get-NormalizedText $expected.expectedAcceptedText
64
- $expectedRejected = Get-NormalizedText $expected.expectedRejectedText
160
+ $fidelity = if ($expected.textFidelity) { [string]$expected.textFidelity } else { 'exact' }
161
+ $assertionMode = if ($expected.assertionMode) { [string]$expected.assertionMode } else { 'exact' }
162
+ $assertionScope = if ($expected.assertionScope) { [string]$expected.assertionScope } else { 'document' }
163
+ if ($assertionMode -eq 'exact') {
164
+ $expectedAccepted = Get-ComparableText $expected.expectedAcceptedText $fidelity
165
+ $expectedRejected = Get-ComparableText $expected.expectedRejectedText $fidelity
166
+ }
167
+ elseif ($assertionMode -eq 'word-source-exact') {
168
+ if (-not $SourcesDir) { throw "${name}: word-source-exact requires -SourcesDir" }
169
+ $sourcePath = Join-Path (Resolve-Path -LiteralPath $SourcesDir) "$($expected.sourceId).docx"
170
+ $sourceDocument = $null
171
+ try {
172
+ $sourceDocument = Open-FixtureDocument $word ([string]$sourcePath)
173
+ $expectedRejected = Get-ScopeText $sourceDocument $assertionScope $fidelity
174
+ }
175
+ finally {
176
+ if ($null -ne $sourceDocument) { $sourceDocument.Close(0) | Out-Null }
177
+ }
178
+ $replacements = if ($expected.replacements) { @($expected.replacements) } else { @(@{
179
+ originalTarget = $expected.originalTarget
180
+ modifiedTarget = $expected.modifiedTarget
181
+ }) }
182
+ $expectedAccepted = $expectedRejected
183
+ foreach ($replacement in $replacements) {
184
+ $targetCount = [regex]::Matches($expectedAccepted, [regex]::Escape([string]$replacement.originalTarget)).Count
185
+ if ($targetCount -ne 1) {
186
+ throw "${name}: expected target '$($replacement.originalTarget)' occurs $targetCount times in Word's source $assertionScope text"
187
+ }
188
+ $expectedAccepted = $expectedAccepted.Replace([string]$replacement.originalTarget, [string]$replacement.modifiedTarget)
189
+ }
190
+ }
65
191
  $caseFailed = $false
66
192
  $document = $null
67
193
 
194
+ if ($expected.untouchedPartSha256 -and -not (Test-UntouchedPartHashes $docxPath $expected.untouchedPartSha256 $name)) {
195
+ $failures++
196
+ $results += "FAIL $name"
197
+ continue
198
+ }
199
+
68
200
  # Phase 1: open cleanly, revisions present, accept-all matches intent.
69
201
  try {
70
202
  $document = Open-FixtureDocument $word ([string]$docxPath)
71
- $revisionCount = $document.Revisions.Count
203
+ $revisionCount = Get-ScopeRevisionCount $document $assertionScope
72
204
  if ($revisionCount -lt 1) {
73
205
  Write-Output "FAIL ${name}: Word sees no tracked revisions"
74
206
  $caseFailed = $true
75
207
  }
76
208
  else {
77
209
  $document.AcceptAllRevisions()
78
- $acceptedText = Get-NormalizedText $document.Content.Text
79
- if ($acceptedText -ne $expectedAccepted) {
210
+ $acceptedText = Get-ScopeText $document $assertionScope $fidelity
211
+ if ($assertionMode -eq 'contains') {
212
+ if (-not (Test-ContainsAssertions $acceptedText $expected.expectedAcceptedContains $expected.expectedAcceptedAbsent 'accept-all' $name)) {
213
+ $caseFailed = $true
214
+ }
215
+ }
216
+ elseif ($acceptedText -ne $expectedAccepted) {
80
217
  Write-Output "FAIL ${name}: accept-all mismatch"
81
218
  Write-Output " expected: $expectedAccepted"
82
219
  Write-Output " actual: $acceptedText"
@@ -97,8 +234,13 @@ try {
97
234
  try {
98
235
  $document = Open-FixtureDocument $word ([string]$docxPath)
99
236
  $document.RejectAllRevisions()
100
- $rejectedText = Get-NormalizedText $document.Content.Text
101
- if ($rejectedText -ne $expectedRejected) {
237
+ $rejectedText = Get-ScopeText $document $assertionScope $fidelity
238
+ if ($assertionMode -eq 'contains') {
239
+ if (-not (Test-ContainsAssertions $rejectedText $expected.expectedRejectedContains $expected.expectedRejectedAbsent 'reject-all' $name)) {
240
+ $caseFailed = $true
241
+ }
242
+ }
243
+ elseif ($rejectedText -ne $expectedRejected) {
102
244
  Write-Output "FAIL ${name}: reject-all mismatch"
103
245
  Write-Output " expected: $expectedRejected"
104
246
  Write-Output " actual: $rejectedText"
@@ -0,0 +1,19 @@
1
+ param(
2
+ [string]$OutputDir = "tmp/word-validation"
3
+ )
4
+
5
+ $ErrorActionPreference = 'Stop'
6
+ $repoRoot = Split-Path -Parent $PSScriptRoot
7
+ $exportScript = Join-Path $PSScriptRoot 'export-validation-fixtures.mjs'
8
+ $differentialScript = Join-Path $PSScriptRoot 'word-com-differential.ps1'
9
+ Push-Location $repoRoot
10
+ try {
11
+ & node $exportScript --output-dir $OutputDir
12
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
13
+
14
+ & powershell -NoProfile -ExecutionPolicy Bypass -File $differentialScript -FixturesDir $OutputDir
15
+ exit $LASTEXITCODE
16
+ }
17
+ finally {
18
+ Pop-Location
19
+ }