@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,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-
|
|
19
|
+
function Get-ComparableText([string]$text, [string]$fidelity = 'exact') {
|
|
19
20
|
if ($null -eq $text) { return '' }
|
|
20
|
-
$text = $text -replace [string][char]7, '
|
|
21
|
-
|
|
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
|
-
$
|
|
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
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# writable
|
|
44
|
-
return $word.Documents.
|
|
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
|
-
$
|
|
64
|
-
$
|
|
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
|
|
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-
|
|
79
|
-
if ($
|
|
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-
|
|
101
|
-
if ($
|
|
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
|
+
}
|