@ansonlai/docx-redline-js 0.8.4 → 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.
Files changed (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
@@ -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
- 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
81
- }
82
-
83
- $resolvedDir = Resolve-Path -LiteralPath $FixturesDir -ErrorAction SilentlyContinue
84
- if (-not $resolvedDir) {
85
- Write-Error "Fixtures directory '$FixturesDir' not found. Run: node scripts/export-validation-fixtures.mjs"
86
- exit 1
87
- }
88
-
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
- }
100
- if ($expectations.Count -eq 0) {
101
- Write-Error "No *.expected.json fixtures in '$resolvedDir'. Run: node scripts/export-validation-fixtures.mjs"
102
- exit 1
103
- }
104
-
105
- $word = $null
106
- $failures = 0
107
- $results = @()
108
-
109
- function Open-FixtureDocument($word, [string]$path) {
110
- # OpenNoRepairDialog prevents malformed real-world packages from blocking
111
- # a headless run behind an invisible repair prompt. It still leaves the
112
- # document writable for accept/reject; nothing is ever saved.
113
- return $word.Documents.OpenNoRepairDialog($path)
114
- }
115
-
116
- function Get-ScopeText($document, [string]$scope, [string]$fidelity) {
117
- if ($scope -ne 'headers') {
118
- return Get-ComparableText $document.Content.Text $fidelity
119
- }
120
- $parts = @()
121
- foreach ($section in @($document.Sections)) {
122
- foreach ($headerType in 1..3) {
123
- $header = $section.Headers.Item($headerType)
124
- if ($header.Exists) {
125
- $parts += Get-ComparableText $header.Range.Text $fidelity
126
- }
127
- }
128
- }
129
- return ($parts -join "`n")
130
- }
131
-
132
- function Get-ScopeRevisionCount($document, [string]$scope) {
133
- if ($scope -ne 'headers') { return $document.Revisions.Count }
134
- $count = 0
135
- foreach ($section in @($document.Sections)) {
136
- foreach ($headerType in 1..3) {
137
- $header = $section.Headers.Item($headerType)
138
- if ($header.Exists) { $count += $header.Range.Revisions.Count }
139
- }
140
- }
141
- return $count
142
- }
143
-
144
- function Close-WordDocumentSafely($doc) {
145
- if ($null -eq $doc) { return }
146
- for ($attempt = 1; $attempt -le 5; $attempt++) {
147
- try {
148
- $doc.Close(0) | Out-Null
149
- break
150
- }
151
- catch [System.Runtime.InteropServices.COMException] {
152
- $hr = $_.Exception.HResult
153
- # 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
154
- if ($hr -eq -2147418111 -and $attempt -lt 5) {
155
- Start-Sleep -Milliseconds 200
156
- continue
157
- }
158
- break
159
- }
160
- catch {
161
- break
162
- }
163
- }
164
- try {
165
- [System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
166
- } catch {}
167
- }
168
-
169
- function Stop-WordSafely($application) {
170
- if ($null -eq $application) { return }
171
- for ($attempt = 1; $attempt -le 5; $attempt++) {
172
- try {
173
- $application.Quit(0) | Out-Null
174
- break
175
- }
176
- catch [System.Runtime.InteropServices.COMException] {
177
- $hr = $_.Exception.HResult
178
- # 0x80010001 = RPC_E_CALL_REJECTED (-2147418111)
179
- # 0x80010108 = RPC_E_DISCONNECTED (-2147417848)
180
- if ($hr -eq -2147417848) {
181
- break
182
- }
183
- if ($hr -eq -2147418111 -and $attempt -lt 5) {
184
- Start-Sleep -Milliseconds 300
185
- continue
186
- }
187
- break
188
- }
189
- catch {
190
- break
191
- }
192
- }
193
- try {
194
- [System.Runtime.InteropServices.Marshal]::ReleaseComObject($application) | Out-Null
195
- } catch {}
196
- [System.GC]::Collect()
197
- [System.GC]::WaitForPendingFinalizers()
198
- }
199
-
200
- try {
201
- $word = New-Object -ComObject Word.Application
202
- $word.Visible = $false
203
- $word.DisplayAlerts = 0 # wdAlertsNone
204
-
205
- foreach ($expectationFile in $expectations) {
206
- $name = $expectationFile.BaseName -replace '\.expected$', ''
207
- $docxPath = Join-Path $resolvedDir "$name.docx"
208
- if (-not (Test-Path -LiteralPath $docxPath)) {
209
- Write-Warning "SKIP ${name}: no matching .docx"
210
- continue
211
- }
212
-
213
- # -Encoding UTF8 is required: Windows PowerShell 5.1 otherwise reads
214
- # BOM-less UTF-8 sidecars as ANSI and garbles non-ASCII expectations.
215
- $expected = Get-Content -LiteralPath $expectationFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
216
- $fidelity = if ($expected.textFidelity) { [string]$expected.textFidelity } else { 'exact' }
217
- $assertionMode = if ($expected.assertionMode) { [string]$expected.assertionMode } else { 'exact' }
218
- $assertionScope = if ($expected.assertionScope) { [string]$expected.assertionScope } else { 'document' }
219
- if ($assertionMode -eq 'exact') {
220
- $expectedAccepted = Get-ComparableText $expected.expectedAcceptedText $fidelity
221
- $expectedRejected = Get-ComparableText $expected.expectedRejectedText $fidelity
222
- }
223
- elseif ($assertionMode -eq 'word-source-exact') {
224
- if (-not $SourcesDir) { throw "${name}: word-source-exact requires -SourcesDir" }
225
- $sourcePath = Join-Path (Resolve-Path -LiteralPath $SourcesDir) "$($expected.sourceId).docx"
226
- $sourceDocument = $null
227
- try {
228
- $sourceDocument = Open-FixtureDocument $word ([string]$sourcePath)
229
- $expectedRejected = Get-ScopeText $sourceDocument $assertionScope $fidelity
230
- }
231
- finally {
232
- Close-WordDocumentSafely $sourceDocument
233
- $sourceDocument = $null
234
- }
235
- $replacements = if ($expected.replacements) { @($expected.replacements) } else { @(@{
236
- originalTarget = $expected.originalTarget
237
- modifiedTarget = $expected.modifiedTarget
238
- }) }
239
- $expectedAccepted = $expectedRejected
240
- foreach ($replacement in $replacements) {
241
- $targetCount = [regex]::Matches($expectedAccepted, [regex]::Escape([string]$replacement.originalTarget)).Count
242
- if ($targetCount -ne 1) {
243
- throw "${name}: expected target '$($replacement.originalTarget)' occurs $targetCount times in Word's source $assertionScope text"
244
- }
245
- $expectedAccepted = $expectedAccepted.Replace([string]$replacement.originalTarget, [string]$replacement.modifiedTarget)
246
- }
247
- }
248
- $caseFailed = $false
249
- $document = $null
250
-
251
- if ($expected.untouchedPartSha256 -and -not (Test-UntouchedPartHashes $docxPath $expected.untouchedPartSha256 $name)) {
252
- $failures++
253
- $results += "FAIL $name"
254
- continue
255
- }
256
-
257
- # Phase 1: open cleanly, revisions present, accept-all matches intent.
258
- try {
259
- $document = Open-FixtureDocument $word ([string]$docxPath)
260
- $revisionCount = Get-ScopeRevisionCount $document $assertionScope
261
- if ($revisionCount -lt 1) {
262
- Write-Output "FAIL ${name}: Word sees no tracked revisions"
263
- $caseFailed = $true
264
- }
265
- else {
266
- $document.AcceptAllRevisions()
267
- $acceptedText = Get-ScopeText $document $assertionScope $fidelity
268
- if ($assertionMode -eq 'contains') {
269
- if (-not (Test-ContainsAssertions $acceptedText $expected.expectedAcceptedContains $expected.expectedAcceptedAbsent 'accept-all' $name)) {
270
- $caseFailed = $true
271
- }
272
- }
273
- elseif ($acceptedText -ne $expectedAccepted) {
274
- Write-Output "FAIL ${name}: accept-all mismatch"
275
- Write-Output " expected: $expectedAccepted"
276
- Write-Output " actual: $acceptedText"
277
- $caseFailed = $true
278
- }
279
- }
280
- }
281
- catch {
282
- Write-Output "FAIL ${name}: Word could not open/accept: $($_.Exception.Message)"
283
- $caseFailed = $true
284
- }
285
- finally {
286
- Close-WordDocumentSafely $document
287
- $document = $null
288
- }
289
-
290
- # Phase 2: fresh open, reject-all restores the original text.
291
- if (-not $caseFailed) {
292
- try {
293
- $document = Open-FixtureDocument $word ([string]$docxPath)
294
- $document.RejectAllRevisions()
295
- $rejectedText = Get-ScopeText $document $assertionScope $fidelity
296
- if ($assertionMode -eq 'contains') {
297
- if (-not (Test-ContainsAssertions $rejectedText $expected.expectedRejectedContains $expected.expectedRejectedAbsent 'reject-all' $name)) {
298
- $caseFailed = $true
299
- }
300
- }
301
- elseif ($rejectedText -ne $expectedRejected) {
302
- Write-Output "FAIL ${name}: reject-all mismatch"
303
- Write-Output " expected: $expectedRejected"
304
- Write-Output " actual: $rejectedText"
305
- $caseFailed = $true
306
- }
307
- }
308
- catch {
309
- Write-Output "FAIL ${name}: Word could not open/reject: $($_.Exception.Message)"
310
- $caseFailed = $true
311
- }
312
- finally {
313
- Close-WordDocumentSafely $document
314
- $document = $null
315
- }
316
- }
317
-
318
- if ($caseFailed) {
319
- $failures++
320
- $results += "FAIL $name"
321
- }
322
- else {
323
- Write-Output "PASS ${name} (revisions: $revisionCount)"
324
- $results += "PASS $name"
325
- }
326
- }
327
- }
328
- finally {
329
- Stop-WordSafely $word
330
- $word = $null
331
- }
332
-
333
- Write-Output ""
334
- Write-Output "Word differential: $($results.Count - $failures)/$($results.Count) fixtures passed."
335
- if ($failures -gt 0) { exit 1 }
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 }