@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,284 @@
|
|
|
1
|
+
import '../tests/setup-xml-provider.mjs';
|
|
2
|
+
|
|
3
|
+
import assert from 'assert';
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { dirname, join, resolve } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
import { applyOperationsToDocumentXml } from '../services/standalone-operation-runner.js';
|
|
9
|
+
import { applyRedlineToOxml } from '../index.js';
|
|
10
|
+
import {
|
|
11
|
+
acceptTrackedChangesInOoxml,
|
|
12
|
+
rejectTrackedChangesInOoxml
|
|
13
|
+
} from '../services/revision-comment-management.js';
|
|
14
|
+
import { buildZip } from './lib/minimal-zip.mjs';
|
|
15
|
+
import { unzipEntries } from './lib/zip-reader.mjs';
|
|
16
|
+
import { loadCoverageCatalogue } from './lib/word-coverage-catalogue.mjs';
|
|
17
|
+
|
|
18
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const repoRoot = dirname(scriptDir);
|
|
20
|
+
const manifest = JSON.parse(readFileSync(join(repoRoot, 'tests', 'corpus', 'superdoc-english-legal-administrative.json'), 'utf8'));
|
|
21
|
+
const catalogue = JSON.parse(readFileSync(join(repoRoot, 'tests', 'corpus', 'superdoc-word-scenarios.json'), 'utf8'));
|
|
22
|
+
function argumentPath(name, fallback) {
|
|
23
|
+
const index = process.argv.indexOf(name);
|
|
24
|
+
if (index < 0) return fallback;
|
|
25
|
+
if (!process.argv[index + 1]) throw new Error(`${name} requires a path`);
|
|
26
|
+
return resolve(process.cwd(), process.argv[index + 1]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const inputDir = argumentPath('--input-dir', join(repoRoot, 'tmp', 'superdoc-corpus'));
|
|
30
|
+
const outputDir = argumentPath('--output-dir', join(repoRoot, 'tmp', 'superdoc-word-fixtures'));
|
|
31
|
+
const manifestById = new Map(manifest.documents.map(document => [document.id, document]));
|
|
32
|
+
const normalizedCoverageById = new Map(loadCoverageCatalogue().cases
|
|
33
|
+
.filter(item => item.lane === 'superdoc')
|
|
34
|
+
.map(item => [item.identity.slice('superdoc:'.length), item.metadata]));
|
|
35
|
+
|
|
36
|
+
mkdirSync(outputDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
function packageWithPartXml(sourceEntries, partName, partXml) {
|
|
39
|
+
return buildZip(Array.from(sourceEntries, ([name, data]) => ({
|
|
40
|
+
name,
|
|
41
|
+
data: name === partName ? partXml : data
|
|
42
|
+
})));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertInsertionFormatting(scenario, trackedXml) {
|
|
46
|
+
const forbidden = scenario.formatExpectations?.insertionsForbidRunProperties || [];
|
|
47
|
+
const requiredValues = scenario.formatExpectations?.insertionsRequireRunPropertyValues || {};
|
|
48
|
+
if (forbidden.length === 0 && Object.keys(requiredValues).length === 0) return;
|
|
49
|
+
|
|
50
|
+
const xmlDoc = new DOMParser().parseFromString(trackedXml, 'application/xml');
|
|
51
|
+
const insertions = Array.from(xmlDoc.getElementsByTagName('*'))
|
|
52
|
+
.filter(node => node.localName === 'ins');
|
|
53
|
+
assert.ok(insertions.length > 0, `${scenario.key}: expected tracked insertions for format validation`);
|
|
54
|
+
|
|
55
|
+
for (const insertion of insertions) {
|
|
56
|
+
const runs = Array.from(insertion.getElementsByTagName('*'))
|
|
57
|
+
.filter(node => node.localName === 'r' && Array.from(node.childNodes)
|
|
58
|
+
.some(child => child.localName === 't'));
|
|
59
|
+
const runProperties = runs.flatMap(run => Array.from(run.childNodes)
|
|
60
|
+
.filter(node => node.localName === 'rPr'));
|
|
61
|
+
for (const propertyName of forbidden) {
|
|
62
|
+
const inherited = runProperties.some(rPr => Array.from(rPr.getElementsByTagName('*'))
|
|
63
|
+
.some(node => node.localName === propertyName));
|
|
64
|
+
assert.equal(inherited, false,
|
|
65
|
+
`${scenario.key}: inserted text unexpectedly inherited w:${propertyName}`);
|
|
66
|
+
}
|
|
67
|
+
for (const [propertyName, expectedValue] of Object.entries(requiredValues)) {
|
|
68
|
+
assert.ok(runs.length > 0, `${scenario.key}: expected inserted text runs for format validation`);
|
|
69
|
+
for (const run of runs) {
|
|
70
|
+
const rPr = Array.from(run.childNodes).find(node => node.localName === 'rPr');
|
|
71
|
+
const property = rPr && Array.from(rPr.childNodes)
|
|
72
|
+
.find(node => node.localName === propertyName);
|
|
73
|
+
const actualValue = property?.getAttribute('w:val') || property?.getAttribute('val');
|
|
74
|
+
assert.equal(actualValue, String(expectedValue),
|
|
75
|
+
`${scenario.key}: inserted text must retain w:${propertyName}=${expectedValue}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function countMatches(text, pattern) {
|
|
82
|
+
return Array.from(text.matchAll(pattern)).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function directWordChild(node, localName) {
|
|
86
|
+
return Array.from(node?.childNodes || []).find(child =>
|
|
87
|
+
child.nodeType === 1 && child.localName === localName
|
|
88
|
+
) || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function wordAttribute(node, localName) {
|
|
92
|
+
return node?.getAttribute(`w:${localName}`) || node?.getAttribute(localName) || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizedNodeText(node) {
|
|
96
|
+
return Array.from(node?.getElementsByTagName('*') || [])
|
|
97
|
+
.filter(child => child.localName === 't')
|
|
98
|
+
.map(child => child.textContent || '')
|
|
99
|
+
.join('')
|
|
100
|
+
.replace(/\s+/g, ' ')
|
|
101
|
+
.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertSourceListStyle(scenario, sourceEntries, documentXml) {
|
|
105
|
+
const expected = scenario.listStyleExpectation;
|
|
106
|
+
if (!expected) return;
|
|
107
|
+
|
|
108
|
+
const numberingXml = sourceEntries.get('word/numbering.xml')?.toString('utf8');
|
|
109
|
+
assert.ok(numberingXml, `${scenario.key}: source has no word/numbering.xml`);
|
|
110
|
+
|
|
111
|
+
const target = String((scenario.operations || [scenario.operation])[0].target)
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
const document = new DOMParser().parseFromString(documentXml, 'application/xml');
|
|
115
|
+
const matchingParagraphs = Array.from(document.getElementsByTagName('*'))
|
|
116
|
+
.filter(node => node.localName === 'p' && normalizedNodeText(node) === target);
|
|
117
|
+
assert.equal(matchingParagraphs.length, 1,
|
|
118
|
+
`${scenario.key}: expected one exact source list paragraph, found ${matchingParagraphs.length}`);
|
|
119
|
+
|
|
120
|
+
const pPr = directWordChild(matchingParagraphs[0], 'pPr');
|
|
121
|
+
const numPr = directWordChild(pPr, 'numPr');
|
|
122
|
+
const numId = wordAttribute(directWordChild(numPr, 'numId'), 'val');
|
|
123
|
+
const level = Number.parseInt(wordAttribute(directWordChild(numPr, 'ilvl'), 'val') || '0', 10);
|
|
124
|
+
assert.ok(numId, `${scenario.key}: target paragraph has no direct numbering binding`);
|
|
125
|
+
assert.equal(level, expected.level, `${scenario.key}: unexpected target list level`);
|
|
126
|
+
|
|
127
|
+
const numbering = new DOMParser().parseFromString(numberingXml, 'application/xml');
|
|
128
|
+
const num = Array.from(numbering.getElementsByTagName('*'))
|
|
129
|
+
.find(node => node.localName === 'num' && wordAttribute(node, 'numId') === numId);
|
|
130
|
+
const abstractNumId = wordAttribute(directWordChild(num, 'abstractNumId'), 'val');
|
|
131
|
+
const abstractNum = Array.from(numbering.getElementsByTagName('*'))
|
|
132
|
+
.find(node => node.localName === 'abstractNum'
|
|
133
|
+
&& wordAttribute(node, 'abstractNumId') === abstractNumId);
|
|
134
|
+
const levelNode = Array.from(abstractNum?.childNodes || [])
|
|
135
|
+
.find(node => node.nodeType === 1 && node.localName === 'lvl'
|
|
136
|
+
&& Number.parseInt(wordAttribute(node, 'ilvl') || '0', 10) === level);
|
|
137
|
+
const format = wordAttribute(directWordChild(levelNode, 'numFmt'), 'val');
|
|
138
|
+
const levelText = wordAttribute(directWordChild(levelNode, 'lvlText'), 'val');
|
|
139
|
+
assert.equal(format, expected.format, `${scenario.key}: unexpected source numbering format`);
|
|
140
|
+
if (expected.levelText !== undefined) {
|
|
141
|
+
assert.equal(levelText, expected.levelText, `${scenario.key}: unexpected source list marker text`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertSourceComplexity(scenario, source, sourceEntries, documentXml) {
|
|
146
|
+
const expected = scenario.structuralExpectations;
|
|
147
|
+
if (!expected) return;
|
|
148
|
+
|
|
149
|
+
const observed = {
|
|
150
|
+
words: source.wordCount,
|
|
151
|
+
tables: countMatches(documentXml, /<w:tbl(?:\s|>)/g),
|
|
152
|
+
listParagraphs: countMatches(documentXml, /<w:numPr(?:\s|>)/g),
|
|
153
|
+
sections: countMatches(documentXml, /<w:sectPr(?:\s|>)/g),
|
|
154
|
+
headers: Array.from(sourceEntries.keys()).filter(name => /^word\/header\d+\.xml$/.test(name)).length,
|
|
155
|
+
footers: Array.from(sourceEntries.keys()).filter(name => /^word\/footer\d+\.xml$/.test(name)).length
|
|
156
|
+
};
|
|
157
|
+
for (const [label, minimum] of Object.entries({
|
|
158
|
+
words: expected.minWords,
|
|
159
|
+
tables: expected.minTables,
|
|
160
|
+
listParagraphs: expected.minListParagraphs,
|
|
161
|
+
sections: expected.minSections,
|
|
162
|
+
headers: expected.minHeaders,
|
|
163
|
+
footers: expected.minFooters
|
|
164
|
+
})) {
|
|
165
|
+
if (minimum === undefined) continue;
|
|
166
|
+
assert.ok(observed[label] >= minimum,
|
|
167
|
+
`${scenario.key}: expected at least ${minimum} ${label}, observed ${observed[label]}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const suite = {
|
|
172
|
+
generatedAt: new Date().toISOString(),
|
|
173
|
+
source: manifest.source,
|
|
174
|
+
attribution: manifest.attribution,
|
|
175
|
+
datasetLicense: manifest.datasetLicense,
|
|
176
|
+
cases: []
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (const [index, scenario] of catalogue.scenarios.entries()) {
|
|
180
|
+
const scenarioKey = scenario.key || scenario.id;
|
|
181
|
+
const sourceId = scenario.sourceId || scenario.id;
|
|
182
|
+
const operations = scenario.operations || [scenario.operation];
|
|
183
|
+
const revisionPart = scenario.part || 'word/document.xml';
|
|
184
|
+
const source = manifestById.get(sourceId);
|
|
185
|
+
assert.ok(source, `Scenario ${scenarioKey} is absent from the pinned manifest`);
|
|
186
|
+
|
|
187
|
+
const sourcePath = join(inputDir, `${sourceId}.docx`);
|
|
188
|
+
const sourceBytes = readFileSync(sourcePath);
|
|
189
|
+
const sourceEntries = unzipEntries(sourceBytes);
|
|
190
|
+
const sourcePartXml = sourceEntries.get(revisionPart)?.toString('utf8');
|
|
191
|
+
assert.ok(sourcePartXml, `${scenarioKey} has no ${revisionPart}`);
|
|
192
|
+
const documentXml = sourceEntries.get('word/document.xml')?.toString('utf8');
|
|
193
|
+
assert.ok(documentXml, `${scenarioKey} has no word/document.xml`);
|
|
194
|
+
assertSourceComplexity(scenario, source, sourceEntries, documentXml);
|
|
195
|
+
assertSourceListStyle(scenario, sourceEntries, documentXml);
|
|
196
|
+
|
|
197
|
+
let trackedXml;
|
|
198
|
+
if (revisionPart === 'word/document.xml') {
|
|
199
|
+
const result = await applyOperationsToDocumentXml(
|
|
200
|
+
sourcePartXml,
|
|
201
|
+
operations,
|
|
202
|
+
catalogue.author,
|
|
203
|
+
null,
|
|
204
|
+
{ generateRedlines: true, existingRevisions: 'reject-input' }
|
|
205
|
+
);
|
|
206
|
+
assert.notEqual(result.status, 'error', `${scenarioKey}: ${result.error?.message || result.status}`);
|
|
207
|
+
assert.equal(result.hasChanges, true, `${scenarioKey}: operation was a no-op`);
|
|
208
|
+
trackedXml = result.documentXml;
|
|
209
|
+
} else {
|
|
210
|
+
assert.equal(operations.length, 1, `${scenarioKey}: related-part scenarios support one operation`);
|
|
211
|
+
const operation = operations[0];
|
|
212
|
+
const result = await applyRedlineToOxml(sourcePartXml, operation.target, operation.modified, {
|
|
213
|
+
generateRedlines: true,
|
|
214
|
+
author: catalogue.author,
|
|
215
|
+
existingRevisions: 'reject-input'
|
|
216
|
+
});
|
|
217
|
+
assert.equal(result.status, 'ok', `${scenarioKey}: ${result.error?.message || result.status}`);
|
|
218
|
+
assert.equal(result.hasChanges, true, `${scenarioKey}: operation was a no-op`);
|
|
219
|
+
trackedXml = result.oxml;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
assert.ok(
|
|
223
|
+
trackedXml.includes('<w:ins') || trackedXml.includes('<w:del')
|
|
224
|
+
|| trackedXml.includes('<w:rPrChange') || trackedXml.includes('<w:pPrChange'),
|
|
225
|
+
`${scenarioKey}: tracked revision markup missing`
|
|
226
|
+
);
|
|
227
|
+
assertInsertionFormatting(scenario, trackedXml);
|
|
228
|
+
|
|
229
|
+
const caseToken = scenario.key || sourceId.slice(0, 12);
|
|
230
|
+
const caseName = `${String(index + 1).padStart(2, '0')}-${source.type}-${caseToken}`;
|
|
231
|
+
const accepted = acceptTrackedChangesInOoxml(trackedXml, { allAuthors: true });
|
|
232
|
+
const rejected = rejectTrackedChangesInOoxml(trackedXml, { allAuthors: true });
|
|
233
|
+
assert.notEqual(accepted.status, 'error', `${scenarioKey}: accepting revisions failed`);
|
|
234
|
+
assert.notEqual(rejected.status, 'error', `${scenarioKey}: rejecting revisions failed`);
|
|
235
|
+
|
|
236
|
+
// Emit complete comparison packages for the dashboard. The Windows suite
|
|
237
|
+
// may independently repackage the tracked state before its hash checks.
|
|
238
|
+
writeFileSync(join(outputDir, `${caseName}.source.docx`), sourceBytes);
|
|
239
|
+
writeFileSync(join(outputDir, `${caseName}.docx`), packageWithPartXml(sourceEntries, revisionPart, trackedXml));
|
|
240
|
+
writeFileSync(join(outputDir, `${caseName}.accepted.docx`), packageWithPartXml(sourceEntries, revisionPart, accepted.oxml));
|
|
241
|
+
writeFileSync(join(outputDir, `${caseName}.rejected.docx`), packageWithPartXml(sourceEntries, revisionPart, rejected.oxml));
|
|
242
|
+
writeFileSync(join(outputDir, `${caseName}.document.xml`), trackedXml);
|
|
243
|
+
writeFileSync(join(outputDir, `${caseName}.expected.json`), `${JSON.stringify({
|
|
244
|
+
name: caseName,
|
|
245
|
+
scenarioKey,
|
|
246
|
+
sourceId,
|
|
247
|
+
revisionPart,
|
|
248
|
+
assertionScope: revisionPart.startsWith('word/header') ? 'headers' : 'document',
|
|
249
|
+
assertionMode: scenario.assertions ? 'contains' : 'word-source-exact',
|
|
250
|
+
...(scenario.assertions ? {
|
|
251
|
+
expectedAcceptedContains: scenario.assertions.acceptedContains,
|
|
252
|
+
expectedAcceptedAbsent: scenario.assertions.acceptedAbsent,
|
|
253
|
+
expectedRejectedContains: scenario.assertions.rejectedContains,
|
|
254
|
+
expectedRejectedAbsent: scenario.assertions.rejectedAbsent
|
|
255
|
+
} : {}),
|
|
256
|
+
replacements: operations.map(operation => ({
|
|
257
|
+
originalTarget: operation.target,
|
|
258
|
+
modifiedTarget: operation.type === 'format' ? operation.target : operation.modified
|
|
259
|
+
})),
|
|
260
|
+
originalTarget: operations[0].target,
|
|
261
|
+
modifiedTarget: operations[0].type === 'format' ? operations[0].target : operations[0].modified,
|
|
262
|
+
sourceText: operations.map(operation => operation.target).join('\n'),
|
|
263
|
+
expectedAcceptedText: operations.map(operation =>
|
|
264
|
+
operation.type === 'format' ? operation.target : operation.modified
|
|
265
|
+
).join('\n'),
|
|
266
|
+
expectedRejectedText: operations.map(operation => operation.target).join('\n'),
|
|
267
|
+
shape: scenario.shape,
|
|
268
|
+
coverage: scenario.coverage,
|
|
269
|
+
coverageMetadata: normalizedCoverageById.get(scenarioKey)
|
|
270
|
+
}, null, 2)}\n`);
|
|
271
|
+
suite.cases.push({
|
|
272
|
+
name: caseName,
|
|
273
|
+
scenarioKey,
|
|
274
|
+
sourceId,
|
|
275
|
+
revisionPart,
|
|
276
|
+
type: source.type,
|
|
277
|
+
shape: scenario.shape,
|
|
278
|
+
coverageMetadata: normalizedCoverageById.get(scenarioKey)
|
|
279
|
+
});
|
|
280
|
+
console.log(`Prepared ${caseName}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
writeFileSync(join(outputDir, 'suite.json'), `${JSON.stringify(suite, null, 2)}\n`);
|
|
284
|
+
console.log(`Prepared ${suite.cases.length} reviewed SuperDoc Word fixtures in ${outputDir}`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
import { loadCoverageCatalogue } from './lib/word-coverage-catalogue.mjs';
|
|
7
|
+
|
|
8
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
const cycleArg = process.argv.find(argument => argument.startsWith('--cycle='));
|
|
10
|
+
const cycle = Number(cycleArg?.split('=')[1] ?? 0);
|
|
11
|
+
if (!Number.isInteger(cycle) || cycle < 0) throw new Error('--cycle must be a non-negative integer');
|
|
12
|
+
|
|
13
|
+
function changedFiles() {
|
|
14
|
+
try {
|
|
15
|
+
return execFileSync('git', [
|
|
16
|
+
'status', '--short', '--untracked-files=all', '--',
|
|
17
|
+
'tests/fixtures/word-task-cases.mjs',
|
|
18
|
+
'tests/fixtures/word-task-coverage.mjs',
|
|
19
|
+
'tests/corpus/superdoc-word-scenarios.json',
|
|
20
|
+
'tests/corpus/superdoc-word-coverage.json'
|
|
21
|
+
], { cwd: repoRoot, encoding: 'utf8' });
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rotate(items, count, offset) {
|
|
28
|
+
if (items.length === 0) return [];
|
|
29
|
+
return Array.from({ length: Math.min(count, items.length) }, (_, index) => items[(offset + index) % items.length]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { cases } = loadCoverageCatalogue();
|
|
33
|
+
const synthetic = cases.filter(item => item.lane === 'synthetic');
|
|
34
|
+
const corpus = cases.filter(item => item.lane === 'superdoc');
|
|
35
|
+
const changes = changedFiles();
|
|
36
|
+
const selected = new Map();
|
|
37
|
+
const add = (item, reason) => {
|
|
38
|
+
const existing = selected.get(item.identity);
|
|
39
|
+
selected.set(item.identity, { ...item, reasons: [...new Set([...(existing?.reasons || []), reason])] });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (/word-task-(?:cases|coverage)\.mjs/.test(changes)) {
|
|
43
|
+
for (const item of synthetic) add(item, 'Synthetic catalogue metadata or cases changed');
|
|
44
|
+
}
|
|
45
|
+
if (/superdoc-word-(?:scenarios|coverage)\.json/.test(changes)) {
|
|
46
|
+
for (const item of corpus) add(item, 'SuperDoc scenario catalogue changed');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sampleSize = Math.ceil(synthetic.length * 0.2);
|
|
50
|
+
for (const item of rotate(synthetic, sampleSize, (cycle * sampleSize) % synthetic.length)) {
|
|
51
|
+
add(item, `Rotating 20% synthetic sample, cycle ${cycle}`);
|
|
52
|
+
}
|
|
53
|
+
for (const category of ['legal', 'administrative']) {
|
|
54
|
+
const categoryCases = corpus.filter(item => item.category === category);
|
|
55
|
+
add(categoryCases[cycle % categoryCases.length], `Required ${category} SuperDoc sample`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manifest = {
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
cycle,
|
|
61
|
+
humanSignOff: { status: 'pending', reviewer: null, wordBuild: null },
|
|
62
|
+
instructions: 'Inspect All Markup, Accept All, and Reject All. This manifest does not record a pass.',
|
|
63
|
+
cases: [...selected.values()].sort((a, b) => a.identity.localeCompare(b.identity)).map(item => ({
|
|
64
|
+
identity: item.identity,
|
|
65
|
+
lane: item.lane,
|
|
66
|
+
category: item.category,
|
|
67
|
+
task: item.metadata.task,
|
|
68
|
+
structures: item.metadata.structures,
|
|
69
|
+
reasons: item.reasons,
|
|
70
|
+
views: { allMarkup: 'pending', acceptAll: 'pending', rejectAll: 'pending' }
|
|
71
|
+
}))
|
|
72
|
+
};
|
|
73
|
+
const outputDir = join(repoRoot, 'tmp', 'word-manual-review');
|
|
74
|
+
mkdirSync(outputDir, { recursive: true });
|
|
75
|
+
const outputPath = join(outputDir, `review-manifest-cycle-${cycle}.json`);
|
|
76
|
+
writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
77
|
+
console.log(`Prepared ${manifest.cases.length} cases for human review: ${outputPath}`);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
import { WORD_TASK_CASES } from '../tests/fixtures/word-task-cases.mjs';
|
|
6
|
+
|
|
7
|
+
export const VISUAL_STRUCTURES = Object.freeze([
|
|
8
|
+
'formatted-runs',
|
|
9
|
+
'list',
|
|
10
|
+
'table',
|
|
11
|
+
'bookmark',
|
|
12
|
+
'hyperlink',
|
|
13
|
+
'content-control',
|
|
14
|
+
'tab-break',
|
|
15
|
+
'field',
|
|
16
|
+
'comment',
|
|
17
|
+
'note',
|
|
18
|
+
'header-footer',
|
|
19
|
+
'section-boundary'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const VISUAL_STRUCTURE_SET = new Set(VISUAL_STRUCTURES);
|
|
23
|
+
const VIEW_NAMES = Object.freeze(['allMarkup', 'acceptAll', 'rejectAll']);
|
|
24
|
+
|
|
25
|
+
export function selectVisualReviewCases(cases = WORD_TASK_CASES, requestedNames = []) {
|
|
26
|
+
const requested = new Set(requestedNames);
|
|
27
|
+
if (requested.size > 0) {
|
|
28
|
+
const knownNames = new Set(cases.map(testCase => testCase.name));
|
|
29
|
+
const unknown = [...requested].filter(name => !knownNames.has(name));
|
|
30
|
+
if (unknown.length > 0) throw new Error(`Unknown visual-review case(s): ${unknown.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return cases
|
|
34
|
+
.filter(testCase => requested.size > 0
|
|
35
|
+
? requested.has(testCase.name)
|
|
36
|
+
: testCase.coverageMetadata.structures.some(structure => VISUAL_STRUCTURE_SET.has(structure)))
|
|
37
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildVisualReviewManifest(cases = WORD_TASK_CASES, requestedNames = []) {
|
|
41
|
+
const selected = selectVisualReviewCases(cases, requestedNames);
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
reviewType: 'render-only-visual-preflight',
|
|
45
|
+
certification: {
|
|
46
|
+
status: 'pending',
|
|
47
|
+
reviewer: null,
|
|
48
|
+
reviewedAt: null,
|
|
49
|
+
note: 'Rendering proves that Word produced inspectable evidence; it does not certify visual correctness.'
|
|
50
|
+
},
|
|
51
|
+
word: { version: null, build: null },
|
|
52
|
+
cases: selected.map(testCase => ({
|
|
53
|
+
identity: `synthetic:${testCase.name}`,
|
|
54
|
+
name: testCase.name,
|
|
55
|
+
category: testCase.category,
|
|
56
|
+
task: testCase.coverageMetadata.task,
|
|
57
|
+
structures: [...testCase.coverageMetadata.structures],
|
|
58
|
+
renderStatus: 'pending',
|
|
59
|
+
views: Object.fromEntries(VIEW_NAMES.map(view => [view, {
|
|
60
|
+
status: 'pending',
|
|
61
|
+
pdf: `${testCase.name}-${view}.pdf`,
|
|
62
|
+
pages: null,
|
|
63
|
+
bytes: null
|
|
64
|
+
}]))
|
|
65
|
+
}))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const requestedNames = argv
|
|
71
|
+
.filter(argument => argument.startsWith('--case='))
|
|
72
|
+
.map(argument => argument.slice('--case='.length))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
const outputArgument = argv.find(argument => argument.startsWith('--output='));
|
|
75
|
+
return {
|
|
76
|
+
requestedNames,
|
|
77
|
+
outputPath: outputArgument
|
|
78
|
+
? resolve(process.cwd(), outputArgument.slice('--output='.length))
|
|
79
|
+
: join(process.cwd(), 'tmp', 'word-visual-review', 'manifest.json')
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
84
|
+
if (isCli) {
|
|
85
|
+
const { requestedNames, outputPath } = parseArgs(process.argv.slice(2));
|
|
86
|
+
const manifest = buildVisualReviewManifest(WORD_TASK_CASES, requestedNames);
|
|
87
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
88
|
+
writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
89
|
+
console.log(`Prepared ${manifest.cases.length} layout-sensitive cases for Word visual rendering: ${outputPath}`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const baseName = '48-administrative-administrative-list-change-dash-agenda-child';
|
|
5
|
+
const fixtureDir = 'tmp/superdoc-word-fixtures';
|
|
6
|
+
const visualDir = 'tmp/multilevel-bullet-visual';
|
|
7
|
+
|
|
8
|
+
const docxPath = `${fixtureDir}/${baseName}.docx`;
|
|
9
|
+
const acceptedDocx = `${fixtureDir}/${baseName}.accepted.docx`;
|
|
10
|
+
const rejectedDocx = `${fixtureDir}/${baseName}.rejected.docx`;
|
|
11
|
+
|
|
12
|
+
const allMarkupPdf = `${visualDir}/${baseName}--allMarkup.pdf`;
|
|
13
|
+
const acceptAllPdf = `${visualDir}/${baseName}--acceptAll.pdf`;
|
|
14
|
+
const rejectAllPdf = `${visualDir}/${baseName}--rejectAll.pdf`;
|
|
15
|
+
|
|
16
|
+
console.log('Rendering Word COM for', baseName);
|
|
17
|
+
|
|
18
|
+
const psScript = `
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
$word = New-Object -ComObject Word.Application
|
|
21
|
+
$word.Visible = $false
|
|
22
|
+
$word.DisplayAlerts = 0
|
|
23
|
+
try {
|
|
24
|
+
Write-Output 'Rendering allMarkup...'
|
|
25
|
+
$doc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${docxPath}').Path)
|
|
26
|
+
$doc.ShowRevisions = $true
|
|
27
|
+
$doc.PrintRevisions = $true
|
|
28
|
+
$doc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--allMarkup.pdf', 17, $false, 0, 0, 1, 1, 7, $true, $true, 0, $true, $true, $false)
|
|
29
|
+
$doc.Close(0)
|
|
30
|
+
|
|
31
|
+
Write-Output 'Rendering acceptAll...'
|
|
32
|
+
$docAcc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${acceptedDocx}').Path)
|
|
33
|
+
$docAcc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--acceptAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
34
|
+
$docAcc.Close(0)
|
|
35
|
+
|
|
36
|
+
Write-Output 'Rendering rejectAll...'
|
|
37
|
+
$docRej = $word.Documents.OpenNoRepairDialog((Resolve-Path '${rejectedDocx}').Path)
|
|
38
|
+
$docRej.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--rejectAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
39
|
+
$docRej.Close(0)
|
|
40
|
+
Write-Output 'SUCCESS: Rendered all 3 views.'
|
|
41
|
+
} finally {
|
|
42
|
+
$word.Quit()
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const psRes = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], { encoding: 'utf8' });
|
|
47
|
+
console.log(psRes.stdout);
|
|
48
|
+
if (psRes.stderr) console.error(psRes.stderr);
|
|
49
|
+
|
|
50
|
+
// Render PDF pages to PNG using python pymupdf
|
|
51
|
+
const pyScript = `
|
|
52
|
+
import pymupdf
|
|
53
|
+
views = ['allMarkup', 'acceptAll', 'rejectAll']
|
|
54
|
+
base = '${baseName}'
|
|
55
|
+
vis_dir = '${visualDir}'
|
|
56
|
+
|
|
57
|
+
for v in views:
|
|
58
|
+
pdf_file = f"{vis_dir}/{base}--{v}.pdf"
|
|
59
|
+
doc = pymupdf.open(pdf_file)
|
|
60
|
+
print(f"{v} page count: {len(doc)}")
|
|
61
|
+
for i, page in enumerate(doc):
|
|
62
|
+
pix = page.get_pixmap(dpi=150)
|
|
63
|
+
out_png = f"{vis_dir}/{base}--{v}-p{i+1}.png"
|
|
64
|
+
pix.save(out_png)
|
|
65
|
+
print(f" Rendered {out_png} ({pix.width}x{pix.height})")
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const pyRes = spawnSync('tmp/visual-qa-venv/Scripts/python.exe', ['-c', pyScript], { encoding: 'utf8' });
|
|
69
|
+
console.log(pyRes.stdout);
|
|
70
|
+
if (pyRes.stderr) console.error(pyRes.stderr);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const baseName = '22-administrative-administrative-multi-bullet-board-agenda';
|
|
5
|
+
const fixtureDir = 'tmp/superdoc-word-fixtures';
|
|
6
|
+
const visualDir = 'tmp/multilevel-bullet-visual';
|
|
7
|
+
const brainArtifactDir = 'C:/Users/Phara/.gemini/antigravity-ide/brain/ee12439b-cade-4f45-ab6e-a2a7b1bf1610';
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(visualDir)) {
|
|
10
|
+
fs.mkdirSync(visualDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
console.log('\n=== Rendering Word COM for', baseName, '===');
|
|
14
|
+
const docxPath = `${fixtureDir}/${baseName}.docx`;
|
|
15
|
+
const acceptedDocx = `${fixtureDir}/${baseName}.accepted.docx`;
|
|
16
|
+
const rejectedDocx = `${fixtureDir}/${baseName}.rejected.docx`;
|
|
17
|
+
|
|
18
|
+
const psScript = `
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
$word = New-Object -ComObject Word.Application
|
|
21
|
+
$word.Visible = $false
|
|
22
|
+
$word.DisplayAlerts = 0
|
|
23
|
+
try {
|
|
24
|
+
Write-Output 'Rendering allMarkup...'
|
|
25
|
+
$doc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${docxPath}').Path)
|
|
26
|
+
$doc.ShowRevisions = $true
|
|
27
|
+
$doc.PrintRevisions = $true
|
|
28
|
+
$doc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--allMarkup.pdf', 17, $false, 0, 0, 1, 1, 7, $true, $true, 0, $true, $true, $false)
|
|
29
|
+
$doc.Close(0)
|
|
30
|
+
|
|
31
|
+
Write-Output 'Rendering acceptAll...'
|
|
32
|
+
$docAcc = $word.Documents.OpenNoRepairDialog((Resolve-Path '${acceptedDocx}').Path)
|
|
33
|
+
$docAcc.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--acceptAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
34
|
+
$docAcc.Close(0)
|
|
35
|
+
|
|
36
|
+
Write-Output 'Rendering rejectAll...'
|
|
37
|
+
$docRej = $word.Documents.OpenNoRepairDialog((Resolve-Path '${rejectedDocx}').Path)
|
|
38
|
+
$docRej.ExportAsFixedFormat((Resolve-Path '${visualDir}').Path + '/${baseName}--rejectAll.pdf', 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
39
|
+
$docRej.Close(0)
|
|
40
|
+
Write-Output 'SUCCESS: Rendered all 3 views for ${baseName}.'
|
|
41
|
+
} finally {
|
|
42
|
+
$word.Quit()
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const psRes = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], { encoding: 'utf8' });
|
|
47
|
+
console.log(psRes.stdout);
|
|
48
|
+
if (psRes.stderr) console.error(psRes.stderr);
|
|
49
|
+
|
|
50
|
+
const pyScript = `
|
|
51
|
+
import pymupdf
|
|
52
|
+
import shutil
|
|
53
|
+
views = ['allMarkup', 'acceptAll', 'rejectAll']
|
|
54
|
+
base = '${baseName}'
|
|
55
|
+
vis_dir = '${visualDir}'
|
|
56
|
+
art_dir = '${brainArtifactDir}'
|
|
57
|
+
|
|
58
|
+
for v in views:
|
|
59
|
+
pdf_file = f"{vis_dir}/{base}--{v}.pdf"
|
|
60
|
+
doc = pymupdf.open(pdf_file)
|
|
61
|
+
print(f"{base} {v} page count: {len(doc)}")
|
|
62
|
+
for i, page in enumerate(doc):
|
|
63
|
+
pix = page.get_pixmap(dpi=150)
|
|
64
|
+
out_png = f"{vis_dir}/{base}--{v}-p{i+1}.png"
|
|
65
|
+
pix.save(out_png)
|
|
66
|
+
art_png = f"{art_dir}/{base}--{v}-p{i+1}.png"
|
|
67
|
+
shutil.copyfile(out_png, art_png)
|
|
68
|
+
print(f" Rendered {out_png} -> {art_png}")
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const pyRes = spawnSync('tmp/visual-qa-venv/Scripts/python.exe', ['-c', pyScript], { encoding: 'utf8' });
|
|
72
|
+
console.log(pyRes.stdout);
|
|
73
|
+
if (pyRes.stderr) console.error(pyRes.stderr);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
$fixtureDir = (Resolve-Path 'tmp/superdoc-word-fixtures').Path
|
|
3
|
+
$visualDir = (Resolve-Path 'tmp/multilevel-bullet-visual').Path
|
|
4
|
+
$baseName = '40-administrative-administrative-list-change-ppg-agenda-addition'
|
|
5
|
+
|
|
6
|
+
function Export-Docx([string]$docxName, [string]$pdfName, [bool]$isMarkup) {
|
|
7
|
+
Write-Output "Exporting $docxName to $pdfName..."
|
|
8
|
+
$word = New-Object -ComObject Word.Application
|
|
9
|
+
$word.Visible = $false
|
|
10
|
+
$word.DisplayAlerts = 0
|
|
11
|
+
try {
|
|
12
|
+
$doc = $word.Documents.Open("$fixtureDir/$docxName")
|
|
13
|
+
if ($isMarkup) {
|
|
14
|
+
$doc.ShowRevisions = $true
|
|
15
|
+
$doc.PrintRevisions = $true
|
|
16
|
+
$doc.ExportAsFixedFormat("$visualDir/$pdfName", 17, $false, 0, 0, 1, 1, 7, $true, $true, 0, $true, $true, $false)
|
|
17
|
+
} else {
|
|
18
|
+
$doc.ExportAsFixedFormat("$visualDir/$pdfName", 17, $false, 0, 0, 1, 1, 0, $true, $true, 0, $true, $true, $false)
|
|
19
|
+
}
|
|
20
|
+
Start-Sleep -Milliseconds 800
|
|
21
|
+
try { $doc.Close(0) } catch {}
|
|
22
|
+
} finally {
|
|
23
|
+
try { $word.Quit(0) } catch {}
|
|
24
|
+
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!(Test-Path "$visualDir/$baseName--allMarkup.pdf")) {
|
|
29
|
+
Export-Docx "$baseName.docx" "$baseName--allMarkup.pdf" $true
|
|
30
|
+
Start-Sleep -Seconds 1
|
|
31
|
+
}
|
|
32
|
+
Export-Docx "$baseName.accepted.docx" "$baseName--acceptAll.pdf" $false
|
|
33
|
+
Start-Sleep -Seconds 1
|
|
34
|
+
Export-Docx "$baseName.rejected.docx" "$baseName--rejectAll.pdf" $false
|
|
35
|
+
Write-Output 'All views exported successfully!'
|