@ansonlai/docx-redline-js 0.8.3 → 0.9.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/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +803 -586
- package/README.md +1018 -901
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4551 -967
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +112 -106
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4690 -1106
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4690 -1106
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +221 -168
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +567 -560
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-mode.js +183 -183
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +51 -1
- package/index.js +47 -3
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -974
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/pipeline/serialization.js +111 -94
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +26 -0
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +215 -0
- package/services/document-operation-contract.js +628 -537
- package/services/document-operation-mutations.js +789 -46
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +612 -436
- package/services/operation-heuristics.js +197 -173
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +634 -594
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +606 -427
- package/services/table-reconciliation.js +103 -49
|
@@ -1,335 +1,443 @@
|
|
|
1
|
-
param(
|
|
2
|
-
[string]$FixturesDir = "tmp/validation-docx",
|
|
3
|
-
[string]$SourcesDir = ""
|
|
4
|
-
)
|
|
5
|
-
|
|
6
|
-
# Differential validation against desktop Microsoft Word (the authoritative
|
|
7
|
-
# OOXML consumer). For each generated fixture, Word itself accepts all
|
|
8
|
-
# revisions and then rejects all revisions, and the resulting document text
|
|
9
|
-
# is compared to the expected outcomes recorded at generation time. This
|
|
10
|
-
# makes Word an independent oracle for the redline engine instead of
|
|
11
|
-
# verifying the library against its own accept/reject transforms.
|
|
12
|
-
#
|
|
13
|
-
# Usage:
|
|
14
|
-
# node scripts/export-validation-fixtures.mjs
|
|
15
|
-
# npm run smoke:word:diff
|
|
16
|
-
|
|
17
|
-
$ErrorActionPreference = 'Stop'
|
|
18
|
-
|
|
19
|
-
function Get-ComparableText([string]$text, [string]$fidelity = 'exact') {
|
|
20
|
-
if ($null -eq $text) { return '' }
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
$
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
$
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
$
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if ($
|
|
1
|
+
param(
|
|
2
|
+
[string]$FixturesDir = "tmp/validation-docx",
|
|
3
|
+
[string]$SourcesDir = ""
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
# Differential validation against desktop Microsoft Word (the authoritative
|
|
7
|
+
# OOXML consumer). For each generated fixture, Word itself accepts all
|
|
8
|
+
# revisions and then rejects all revisions, and the resulting document text
|
|
9
|
+
# is compared to the expected outcomes recorded at generation time. This
|
|
10
|
+
# makes Word an independent oracle for the redline engine instead of
|
|
11
|
+
# verifying the library against its own accept/reject transforms.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# node scripts/export-validation-fixtures.mjs
|
|
15
|
+
# npm run smoke:word:diff
|
|
16
|
+
|
|
17
|
+
$ErrorActionPreference = 'Stop'
|
|
18
|
+
|
|
19
|
+
function Get-ComparableText([string]$text, [string]$fidelity = 'exact') {
|
|
20
|
+
if ($null -eq $text) { return '' }
|
|
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
|
+
# Messages go straight to stdout: Write-Output inside a function that also returns a boolean would make the
|
|
33
|
+
# return value a non-empty array, and `-not` of that array is always false (failures would be silently ignored).
|
|
34
|
+
function Test-ContainsAssertions([string]$text, $required, $absent, [string]$phase, [string]$name) {
|
|
35
|
+
$passed = $true
|
|
36
|
+
foreach ($value in @($required)) {
|
|
37
|
+
if (-not $text.Contains([string]$value)) {
|
|
38
|
+
[Console]::Out.WriteLine("FAIL ${name}: ${phase} is missing expected text: $value")
|
|
39
|
+
$passed = $false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
foreach ($value in @($absent)) {
|
|
43
|
+
if ($text.Contains([string]$value)) {
|
|
44
|
+
[Console]::Out.WriteLine("FAIL ${name}: ${phase} still contains superseded text: $value")
|
|
45
|
+
$passed = $false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return $passed
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Test-UntouchedPartHashes([string]$docxPath, $expectedHashes, [string]$name) {
|
|
52
|
+
$properties = @($expectedHashes.PSObject.Properties)
|
|
53
|
+
if ($properties.Count -eq 0) { return $true }
|
|
54
|
+
|
|
55
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
56
|
+
$archive = [System.IO.Compression.ZipFile]::OpenRead($docxPath)
|
|
57
|
+
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
|
58
|
+
try {
|
|
59
|
+
foreach ($property in $properties) {
|
|
60
|
+
$entry = $archive.GetEntry([string]$property.Name)
|
|
61
|
+
if ($null -eq $entry) {
|
|
62
|
+
Write-Output "FAIL ${name}: package is missing untouched part $($property.Name)"
|
|
63
|
+
return $false
|
|
64
|
+
}
|
|
65
|
+
$stream = $entry.Open()
|
|
66
|
+
try {
|
|
67
|
+
$actual = ([System.BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '').ToLowerInvariant()
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
$stream.Dispose()
|
|
71
|
+
}
|
|
72
|
+
if ($actual -ne [string]$property.Value) {
|
|
73
|
+
Write-Output "FAIL ${name}: untouched part hash changed for $($property.Name)"
|
|
74
|
+
return $false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
$sha256.Dispose()
|
|
80
|
+
$archive.Dispose()
|
|
81
|
+
}
|
|
82
|
+
return $true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$resolvedDir = Resolve-Path -LiteralPath $FixturesDir -ErrorAction SilentlyContinue
|
|
86
|
+
if (-not $resolvedDir) {
|
|
87
|
+
Write-Error "Fixtures directory '$FixturesDir' not found. Run: node scripts/export-validation-fixtures.mjs"
|
|
88
|
+
exit 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
$suitePath = Join-Path $resolvedDir 'suite.json'
|
|
92
|
+
if (Test-Path -LiteralPath $suitePath) {
|
|
93
|
+
$suite = Get-Content -LiteralPath $suitePath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
94
|
+
$expectations = @($suite.cases | ForEach-Object {
|
|
95
|
+
$caseName = if ($_ -is [string]) { [string]$_ } else { [string]$_.name }
|
|
96
|
+
Get-Item -LiteralPath (Join-Path $resolvedDir "$caseName.expected.json") -ErrorAction Stop
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
$expectations = @(Get-ChildItem -LiteralPath $resolvedDir -Filter '*.expected.json' | Sort-Object Name)
|
|
101
|
+
}
|
|
102
|
+
if ($expectations.Count -eq 0) {
|
|
103
|
+
Write-Error "No *.expected.json fixtures in '$resolvedDir'. Run: node scripts/export-validation-fixtures.mjs"
|
|
104
|
+
exit 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$word = $null
|
|
108
|
+
$failures = 0
|
|
109
|
+
$results = @()
|
|
110
|
+
|
|
111
|
+
function Open-FixtureDocument($word, [string]$path) {
|
|
112
|
+
# OpenNoRepairDialog prevents malformed real-world packages from blocking
|
|
113
|
+
# a headless run behind an invisible repair prompt. It still leaves the
|
|
114
|
+
# document writable for accept/reject; nothing is ever saved.
|
|
115
|
+
return $word.Documents.OpenNoRepairDialog($path)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Get-ScopeText($document, [string]$scope, [string]$fidelity) {
|
|
119
|
+
if ($scope -ne 'headers') {
|
|
120
|
+
return Get-ComparableText $document.Content.Text $fidelity
|
|
121
|
+
}
|
|
122
|
+
$parts = @()
|
|
123
|
+
foreach ($section in @($document.Sections)) {
|
|
124
|
+
foreach ($headerType in 1..3) {
|
|
125
|
+
$header = $section.Headers.Item($headerType)
|
|
126
|
+
if ($header.Exists) {
|
|
127
|
+
$parts += Get-ComparableText $header.Range.Text $fidelity
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return ($parts -join "`n")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Paragraph structure, not just text: an untracked paragraph-mark change can leave the
|
|
135
|
+
# Reject All text identical while adding or removing an (empty) paragraph.
|
|
136
|
+
function Get-ScopeParagraphCount($document, [string]$scope) {
|
|
137
|
+
if ($scope -ne 'headers') { return $document.Paragraphs.Count }
|
|
138
|
+
$count = 0
|
|
139
|
+
foreach ($section in @($document.Sections)) {
|
|
140
|
+
foreach ($headerType in 1..3) {
|
|
141
|
+
$header = $section.Headers.Item($headerType)
|
|
142
|
+
if ($header.Exists) { $count += $header.Range.Paragraphs.Count }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return $count
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# List membership, not just text: Range.Text never contains list labels, so a numbering binding
|
|
149
|
+
# (w:numPr in or out of a w:pPrChange) is only visible through ListFormat.ListString per paragraph.
|
|
150
|
+
function Test-ListStrings($document, $expectedStrings, [string]$phase, [string]$name) {
|
|
151
|
+
if ($null -eq $expectedStrings) { return $true }
|
|
152
|
+
$actual = @(foreach ($paragraph in @($document.Paragraphs)) { [string]$paragraph.Range.ListFormat.ListString })
|
|
153
|
+
$expectedList = @($expectedStrings | ForEach-Object { [string]$_ })
|
|
154
|
+
if (($actual -join '|') -ne ($expectedList -join '|')) {
|
|
155
|
+
[Console]::Out.WriteLine("FAIL ${name}: $phase list labels differ")
|
|
156
|
+
[Console]::Out.WriteLine(" expected: $($expectedList -join ' | ')")
|
|
157
|
+
[Console]::Out.WriteLine(" actual: $($actual -join ' | ')")
|
|
158
|
+
return $false
|
|
159
|
+
}
|
|
160
|
+
return $true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# List identity, not just labels: a start value can make two separate lists read A. and B., so each paragraph's
|
|
164
|
+
# ListFormat.List.ListParagraphs.Count says how many paragraphs share its list (0 = not a list item).
|
|
165
|
+
function Test-ListGroupSizes($document, $expectedSizes, [string]$phase, [string]$name) {
|
|
166
|
+
if ($null -eq $expectedSizes) { return $true }
|
|
167
|
+
$actual = @(foreach ($paragraph in @($document.Paragraphs)) {
|
|
168
|
+
$list = $paragraph.Range.ListFormat.List
|
|
169
|
+
if ($null -eq $list) { 0 } else { [int]$list.ListParagraphs.Count }
|
|
170
|
+
})
|
|
171
|
+
$expectedList = @($expectedSizes | ForEach-Object { [int]$_ })
|
|
172
|
+
if (($actual -join '|') -ne ($expectedList -join '|')) {
|
|
173
|
+
[Console]::Out.WriteLine("FAIL ${name}: $phase list group sizes differ")
|
|
174
|
+
[Console]::Out.WriteLine(" expected: $($expectedList -join ' | ')")
|
|
175
|
+
[Console]::Out.WriteLine(" actual: $($actual -join ' | ')")
|
|
176
|
+
return $false
|
|
177
|
+
}
|
|
178
|
+
return $true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Use the Revisions collection, which matches the ribbon's "Accept/Reject All Changes"
|
|
182
|
+
# (CommandBars.ExecuteMso and WordBasic agree). Document.AcceptAllRevisions() /
|
|
183
|
+
# RejectAllRevisions() can leave a revision behind: on a document whose last body paragraph
|
|
184
|
+
# has a tracked-inserted mark, RejectAllRevisions() keeps that mark and an extra empty
|
|
185
|
+
# paragraph (verified on Word 16.0.20430, 2026-10-10).
|
|
186
|
+
function Invoke-AllRevisions($document, [string]$scope, [string]$action) {
|
|
187
|
+
$ranges = if ($scope -ne 'headers') { @($document.Content) } else {
|
|
188
|
+
@(foreach ($section in @($document.Sections)) {
|
|
189
|
+
foreach ($headerType in 1..3) {
|
|
190
|
+
$header = $section.Headers.Item($headerType)
|
|
191
|
+
if ($header.Exists) { $header.Range }
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
if ($scope -ne 'headers') {
|
|
196
|
+
if ($action -eq 'accept') { $document.Revisions.AcceptAll() } else { $document.Revisions.RejectAll() }
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
foreach ($range in $ranges) {
|
|
200
|
+
if ($action -eq 'accept') { $range.Revisions.AcceptAll() } else { $range.Revisions.RejectAll() }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return (Get-ScopeRevisionCount $document $scope)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Get-ScopeRevisionCount($document, [string]$scope) {
|
|
207
|
+
if ($scope -ne 'headers') { return $document.Revisions.Count }
|
|
208
|
+
$count = 0
|
|
209
|
+
foreach ($section in @($document.Sections)) {
|
|
210
|
+
foreach ($headerType in 1..3) {
|
|
211
|
+
$header = $section.Headers.Item($headerType)
|
|
212
|
+
if ($header.Exists) { $count += $header.Range.Revisions.Count }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return $count
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function Close-WordDocumentSafely($doc) {
|
|
219
|
+
if ($null -eq $doc) { return }
|
|
220
|
+
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
|
221
|
+
try {
|
|
222
|
+
$doc.Close(0) | Out-Null
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
catch [System.Runtime.InteropServices.COMException] {
|
|
226
|
+
$hr = $_.Exception.HResult
|
|
227
|
+
# 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
|
|
228
|
+
if ($hr -eq -2147418111 -and $attempt -lt 5) {
|
|
229
|
+
Start-Sleep -Milliseconds 200
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function Stop-WordSafely($application) {
|
|
244
|
+
if ($null -eq $application) { return }
|
|
245
|
+
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
|
246
|
+
try {
|
|
247
|
+
$application.Quit(0) | Out-Null
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
catch [System.Runtime.InteropServices.COMException] {
|
|
251
|
+
$hr = $_.Exception.HResult
|
|
252
|
+
# 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
|
|
253
|
+
# 0x80010108 = RPC_E_DISCONNECTED (-2147417848)
|
|
254
|
+
if ($hr -eq -2147417848) {
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
if ($hr -eq -2147418111 -and $attempt -lt 5) {
|
|
258
|
+
Start-Sleep -Milliseconds 300
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($application) | Out-Null
|
|
269
|
+
} catch {}
|
|
270
|
+
[System.GC]::Collect()
|
|
271
|
+
[System.GC]::WaitForPendingFinalizers()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
$word = New-Object -ComObject Word.Application
|
|
276
|
+
$word.Visible = $false
|
|
277
|
+
$word.DisplayAlerts = 0 # wdAlertsNone
|
|
278
|
+
|
|
279
|
+
foreach ($expectationFile in $expectations) {
|
|
280
|
+
$name = $expectationFile.BaseName -replace '\.expected$', ''
|
|
281
|
+
$docxPath = Join-Path $resolvedDir "$name.docx"
|
|
282
|
+
if (-not (Test-Path -LiteralPath $docxPath)) {
|
|
283
|
+
Write-Warning "SKIP ${name}: no matching .docx"
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# -Encoding UTF8 is required: Windows PowerShell 5.1 otherwise reads
|
|
288
|
+
# BOM-less UTF-8 sidecars as ANSI and garbles non-ASCII expectations.
|
|
289
|
+
$expected = Get-Content -LiteralPath $expectationFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
290
|
+
$fidelity = if ($expected.textFidelity) { [string]$expected.textFidelity } else { 'exact' }
|
|
291
|
+
$assertionMode = if ($expected.assertionMode) { [string]$expected.assertionMode } else { 'exact' }
|
|
292
|
+
$assertionScope = if ($expected.assertionScope) { [string]$expected.assertionScope } else { 'document' }
|
|
293
|
+
if ($assertionMode -eq 'exact') {
|
|
294
|
+
$expectedAccepted = Get-ComparableText $expected.expectedAcceptedText $fidelity
|
|
295
|
+
$expectedRejected = Get-ComparableText $expected.expectedRejectedText $fidelity
|
|
296
|
+
}
|
|
297
|
+
elseif ($assertionMode -eq 'word-source-exact') {
|
|
298
|
+
if (-not $SourcesDir) { throw "${name}: word-source-exact requires -SourcesDir" }
|
|
299
|
+
$sourcePath = Join-Path (Resolve-Path -LiteralPath $SourcesDir) "$($expected.sourceId).docx"
|
|
300
|
+
$sourceDocument = $null
|
|
301
|
+
try {
|
|
302
|
+
$sourceDocument = Open-FixtureDocument $word ([string]$sourcePath)
|
|
303
|
+
$expectedRejected = Get-ScopeText $sourceDocument $assertionScope $fidelity
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
Close-WordDocumentSafely $sourceDocument
|
|
307
|
+
$sourceDocument = $null
|
|
308
|
+
}
|
|
309
|
+
$replacements = if ($expected.replacements) { @($expected.replacements) } else { @(@{
|
|
310
|
+
originalTarget = $expected.originalTarget
|
|
311
|
+
modifiedTarget = $expected.modifiedTarget
|
|
312
|
+
}) }
|
|
313
|
+
$expectedAccepted = $expectedRejected
|
|
314
|
+
foreach ($replacement in $replacements) {
|
|
315
|
+
$targetCount = [regex]::Matches($expectedAccepted, [regex]::Escape([string]$replacement.originalTarget)).Count
|
|
316
|
+
if ($targetCount -ne 1) {
|
|
317
|
+
throw "${name}: expected target '$($replacement.originalTarget)' occurs $targetCount times in Word's source $assertionScope text"
|
|
318
|
+
}
|
|
319
|
+
$expectedAccepted = $expectedAccepted.Replace([string]$replacement.originalTarget, [string]$replacement.modifiedTarget)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
$caseFailed = $false
|
|
323
|
+
$document = $null
|
|
324
|
+
|
|
325
|
+
if ($expected.untouchedPartSha256 -and -not (Test-UntouchedPartHashes $docxPath $expected.untouchedPartSha256 $name)) {
|
|
326
|
+
$failures++
|
|
327
|
+
$results += "FAIL $name"
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Phase 1: open cleanly, revisions present, accept-all matches intent.
|
|
332
|
+
try {
|
|
333
|
+
$document = Open-FixtureDocument $word ([string]$docxPath)
|
|
334
|
+
$revisionCount = Get-ScopeRevisionCount $document $assertionScope
|
|
335
|
+
if ($revisionCount -lt 1) {
|
|
336
|
+
Write-Output "FAIL ${name}: Word sees no tracked revisions"
|
|
337
|
+
$caseFailed = $true
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
$leftAfterAccept = Invoke-AllRevisions $document $assertionScope 'accept'
|
|
341
|
+
if ($leftAfterAccept -ne 0) {
|
|
342
|
+
Write-Output "FAIL ${name}: $leftAfterAccept revision(s) remain after accept-all"
|
|
343
|
+
$caseFailed = $true
|
|
344
|
+
}
|
|
345
|
+
$acceptedText = Get-ScopeText $document $assertionScope $fidelity
|
|
346
|
+
if ($assertionMode -eq 'contains') {
|
|
347
|
+
if (-not (Test-ContainsAssertions $acceptedText $expected.expectedAcceptedContains $expected.expectedAcceptedAbsent 'accept-all' $name)) {
|
|
348
|
+
$caseFailed = $true
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
elseif ($acceptedText -ne $expectedAccepted) {
|
|
352
|
+
Write-Output "FAIL ${name}: accept-all mismatch"
|
|
353
|
+
Write-Output " expected: $expectedAccepted"
|
|
354
|
+
Write-Output " actual: $acceptedText"
|
|
355
|
+
$caseFailed = $true
|
|
356
|
+
}
|
|
357
|
+
if (-not $caseFailed -and -not (Test-ListStrings $document $expected.expectedAcceptedListStrings 'accept-all' $name)) {
|
|
358
|
+
$caseFailed = $true
|
|
359
|
+
}
|
|
360
|
+
if (-not $caseFailed -and -not (Test-ListGroupSizes $document $expected.expectedAcceptedListGroupSizes 'accept-all' $name)) {
|
|
361
|
+
$caseFailed = $true
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
Write-Output "FAIL ${name}: Word could not open/accept: $($_.Exception.Message)"
|
|
367
|
+
$caseFailed = $true
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
Close-WordDocumentSafely $document
|
|
371
|
+
$document = $null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# Phase 2: fresh open, reject-all restores the original text.
|
|
375
|
+
if (-not $caseFailed) {
|
|
376
|
+
try {
|
|
377
|
+
$document = Open-FixtureDocument $word ([string]$docxPath)
|
|
378
|
+
$leftAfterReject = Invoke-AllRevisions $document $assertionScope 'reject'
|
|
379
|
+
if ($leftAfterReject -ne 0) {
|
|
380
|
+
Write-Output "FAIL ${name}: $leftAfterReject revision(s) remain after reject-all"
|
|
381
|
+
$caseFailed = $true
|
|
382
|
+
}
|
|
383
|
+
$rejectedText = Get-ScopeText $document $assertionScope $fidelity
|
|
384
|
+
if ($assertionMode -eq 'contains') {
|
|
385
|
+
if (-not (Test-ContainsAssertions $rejectedText $expected.expectedRejectedContains $expected.expectedRejectedAbsent 'reject-all' $name)) {
|
|
386
|
+
$caseFailed = $true
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
elseif ($rejectedText -ne $expectedRejected) {
|
|
390
|
+
Write-Output "FAIL ${name}: reject-all mismatch"
|
|
391
|
+
Write-Output " expected: $expectedRejected"
|
|
392
|
+
Write-Output " actual: $rejectedText"
|
|
393
|
+
$caseFailed = $true
|
|
394
|
+
}
|
|
395
|
+
if (-not $caseFailed -and -not (Test-ListStrings $document $expected.expectedRejectedListStrings 'reject-all' $name)) {
|
|
396
|
+
$caseFailed = $true
|
|
397
|
+
}
|
|
398
|
+
$sourceDocxPath = Join-Path $resolvedDir "$name.source.docx"
|
|
399
|
+
if (-not $caseFailed -and (Test-Path -LiteralPath $sourceDocxPath)) {
|
|
400
|
+
$rejectedParagraphs = Get-ScopeParagraphCount $document $assertionScope
|
|
401
|
+
$sourceCountDocument = $null
|
|
402
|
+
try {
|
|
403
|
+
$sourceCountDocument = Open-FixtureDocument $word ([string]$sourceDocxPath)
|
|
404
|
+
$sourceParagraphs = Get-ScopeParagraphCount $sourceCountDocument $assertionScope
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
Close-WordDocumentSafely $sourceCountDocument
|
|
408
|
+
$sourceCountDocument = $null
|
|
409
|
+
}
|
|
410
|
+
if ($rejectedParagraphs -ne $sourceParagraphs) {
|
|
411
|
+
Write-Output "FAIL ${name}: reject-all paragraph count $rejectedParagraphs, source has $sourceParagraphs"
|
|
412
|
+
$caseFailed = $true
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
Write-Output "FAIL ${name}: Word could not open/reject: $($_.Exception.Message)"
|
|
418
|
+
$caseFailed = $true
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
Close-WordDocumentSafely $document
|
|
422
|
+
$document = $null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if ($caseFailed) {
|
|
427
|
+
$failures++
|
|
428
|
+
$results += "FAIL $name"
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
Write-Output "PASS ${name} (revisions: $revisionCount)"
|
|
432
|
+
$results += "PASS $name"
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
Stop-WordSafely $word
|
|
438
|
+
$word = $null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
Write-Output ""
|
|
442
|
+
Write-Output "Word differential: $($results.Count - $failures)/$($results.Count) fixtures passed."
|
|
443
|
+
if ($failures -gt 0) { exit 1 }
|