vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
<#
.SYNOPSIS
Compares two veraPDF JSON batch reports and flags any file whose
validation outcome changed between the two runs.
.DESCRIPTION
Used to verify that resaving PDFs through KillerPDF does not degrade
standards conformance. Workflow:
1. Baseline the corpus:
verapdf --recurse --format json C:\pdf-corpus > baseline.json
2. Resave every corpus file through KillerPDF into a mirror folder,
preserving relative paths (see --batch-resave).
3. Validate the resaved tree:
verapdf --recurse --format json C:\pdf-corpus-resaved > after.json
4. Compare:
.\Compare-VeraPDF.ps1 -Baseline baseline.json -After after.json `
-BaselineRoot C:\pdf-corpus -AfterRoot C:\pdf-corpus-resaved
Files are matched by path relative to the given roots. The check that
matters for release: zero regressions. A regression is any of:
- NEW_FAIL: compliant in baseline, non-compliant after
- rules added: a file fails rules after that it did not fail before
(even if it already failed others)
- PARSE_ERROR_AFTER: veraPDF parsed the baseline file but not the resave
- MISSING_AFTER: file present in baseline report but absent in after
Exit code 0 = no regressions, 1 = regressions found, 2 = usage/input error.
.NOTES
Compatible with Windows PowerShell 5.1 and PowerShell 7.
Part of the KillerPDF validation harness (validation/).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string]$Baseline,
[Parameter(Mandatory = $true)] [string]$After,
[Parameter(Mandatory = $true)] [string]$BaselineRoot,
[Parameter(Mandatory = $true)] [string]$AfterRoot,
[string]$CsvOut,
[switch]$ShowUnchanged
)
$ErrorActionPreference = 'Stop'
function Get-VeraJobs {
param([string]$Path, [string]$Root)
if (-not (Test-Path -LiteralPath $Path)) {
Write-Error "Report not found: $Path"
exit 2
}
$raw = Get-Content -LiteralPath $Path -Raw
try {
$json = $raw | ConvertFrom-Json
} catch {
Write-Error "Could not parse JSON in ${Path}: $($_.Exception.Message)"
exit 2
}
if (-not $json.report -or -not $json.report.jobs) {
Write-Error "No report.jobs found in $Path - is this a veraPDF --format json report?"
exit 2
}
$rootNorm = $Root.TrimEnd('\', '/').ToLowerInvariant()
$jobs = @{}
foreach ($job in $json.report.jobs) {
$name = [string]$job.itemDetails.name
$key = $name.ToLowerInvariant()
if ($rootNorm.Length -gt 0 -and $key.StartsWith($rootNorm)) {
$key = $key.Substring($rootNorm.Length).TrimStart('\', '/')
}
# Parse failures produce a job with no validationResult entry.
$vr = $null
$vrProp = $job.PSObject.Properties['validationResult']
if ($null -ne $vrProp -and $null -ne $job.validationResult) {
$vrArr = @($job.validationResult)
if ($vrArr.Count -gt 0) { $vr = $vrArr[0] }
}
$compliant = $null
$failedRules = @()
if ($null -ne $vr) {
$compliant = [bool]$vr.compliant
if ($vr.details -and $vr.details.ruleSummaries) {
foreach ($rs in @($vr.details.ruleSummaries)) {
if ([string]$rs.ruleStatus -eq 'FAILED') {
$failedRules += ('{0} clause {1} test {2}' -f $rs.specification, $rs.clause, $rs.testNumber)
}
}
}
}
$jobs[$key] = [pscustomobject]@{
Name = $name
ParseError = ($null -eq $vr)
Compliant = $compliant
FailedRules = @($failedRules | Sort-Object -Unique)
}
}
return $jobs
}
$baseJobs = Get-VeraJobs -Path $Baseline -Root $BaselineRoot
$afterJobs = Get-VeraJobs -Path $After -Root $AfterRoot
$results = New-Object System.Collections.Generic.List[object]
$regressionCount = 0
$unchangedCount = 0
foreach ($key in @($baseJobs.Keys | Sort-Object)) {
$b = $baseJobs[$key]
if (-not $afterJobs.ContainsKey($key)) {
$results.Add([pscustomobject]@{
File = $key; Change = 'MISSING_AFTER'; Regression = $true
Detail = 'In baseline report but absent from after report (resave failed or file skipped)'
})
$regressionCount++
continue
}
$a = $afterJobs[$key]
if ($b.ParseError -and $a.ParseError) {
# Unparseable before and after: no change, nothing to compare.
$unchangedCount++
if ($ShowUnchanged) {
$results.Add([pscustomobject]@{
File = $key; Change = 'UNCHANGED_PARSE_ERROR'; Regression = $false
Detail = 'veraPDF could not parse this file in either run'
})
}
continue
}
if (-not $b.ParseError -and $a.ParseError) {
$results.Add([pscustomobject]@{
File = $key; Change = 'PARSE_ERROR_AFTER'; Regression = $true
Detail = 'Baseline validated, but veraPDF could not parse the resaved file'
})
$regressionCount++
continue
}
if ($b.ParseError -and -not $a.ParseError) {
$results.Add([pscustomobject]@{
File = $key; Change = 'PARSE_ERROR_RESOLVED'; Regression = $false
Detail = 'Baseline was unparseable, resave validates. Not a regression, but verify the resave kept the file intact'
})
continue
}
$added = @($a.FailedRules | Where-Object { $b.FailedRules -notcontains $_ })
$removed = @($b.FailedRules | Where-Object { $a.FailedRules -notcontains $_ })
if ($added.Count -eq 0 -and $removed.Count -eq 0) {
$unchangedCount++
if ($ShowUnchanged) {
$results.Add([pscustomobject]@{
File = $key; Change = 'UNCHANGED'; Regression = $false
Detail = ('compliant={0}, failedRules={1}' -f $b.Compliant, $b.FailedRules.Count)
})
}
continue
}
$change = 'RULES_CHANGED'
if ($b.Compliant -and -not $a.Compliant) { $change = 'NEW_FAIL' }
elseif (-not $b.Compliant -and $a.Compliant) { $change = 'NOW_COMPLIANT' }
$isRegression = ($added.Count -gt 0)
if ($isRegression) { $regressionCount++ }
$detailParts = @()
if ($added.Count -gt 0) { $detailParts += ('ADDED: ' + ($added -join ' | ')) }
if ($removed.Count -gt 0) { $detailParts += ('removed: ' + ($removed -join ' | ')) }
$results.Add([pscustomobject]@{
File = $key; Change = $change; Regression = $isRegression
Detail = ($detailParts -join ' ; ')
})
}
foreach ($key in @($afterJobs.Keys | Sort-Object)) {
if (-not $baseJobs.ContainsKey($key)) {
$results.Add([pscustomobject]@{
File = $key; Change = 'MISSING_BASELINE'; Regression = $false
Detail = 'In after report but not in baseline (extra output file?)'
})
}
}
# ---- Output ----------------------------------------------------------------
$changed = @($results | Where-Object { $_.Change -notlike 'UNCHANGED*' })
Write-Host ''
Write-Host ('Baseline jobs : {0}' -f $baseJobs.Count)
Write-Host ('After jobs : {0}' -f $afterJobs.Count)
Write-Host ('Unchanged : {0}' -f $unchangedCount)
Write-Host ('Changed : {0}' -f $changed.Count)
Write-Host ('Regressions : {0}' -f $regressionCount)
Write-Host ''
if ($results.Count -gt 0) {
# NOTE: keep $toShow a plain array. Wrapping the generic List in @(...) and reading .Count
# makes the Windows PowerShell 5.1 binder throw "Argument types do not match".
$toShow = $changed
if ($ShowUnchanged) { $toShow = $results.ToArray() }
if ($toShow.Count -gt 0) {
# Plain padded strings instead of Format-Table (its 5.1 formatter has its own quirks);
# the CSV is the real record anyway.
$fileW = 60
foreach ($r in $toShow) { if ($r.File.Length -gt $fileW) { $fileW = $r.File.Length } }
Write-Host (('{0,-' + $fileW + '} {1,-20} {2,-5} {3}') -f 'File', 'Change', 'Reg', 'Detail')
Write-Host (('{0,-' + $fileW + '} {1,-20} {2,-5} {3}') -f '----', '------', '---', '------')
foreach ($r in $toShow) {
Write-Host (('{0,-' + $fileW + '} {1,-20} {2,-5} {3}') -f $r.File, $r.Change, $r.Regression, $r.Detail)
}
Write-Host ''
}
}
if ($CsvOut) {
$results | Export-Csv -LiteralPath $CsvOut -NoTypeInformation -Encoding UTF8
Write-Host ('Full results written to {0}' -f $CsvOut)
}
if ($regressionCount -gt 0) {
Write-Host 'RESULT: FAIL - KillerPDF resave introduced regressions.' -ForegroundColor Red
exit 1
} else {
Write-Host 'RESULT: PASS - no conformance regressions introduced.' -ForegroundColor Green
exit 0
}
+85
View File
@@ -0,0 +1,85 @@
<#
.SYNOPSIS
qpdf structural sweep: runs `qpdf --check` on every original/resave pair and flags any
file whose exit code worsened.
.DESCRIPTION
The second half of the release validation (Compare-VeraPDF.ps1 is the first). Reads the
resave log written by `KillerPDF.exe --batch-resave` and checks only the rows marked OK -
skipped files were never written and have nothing to compare.
qpdf exit codes: 0 = clean, 2 = errors, 3 = warnings only. "Worsened" is any pair whose
after-code is higher than its before-code. The release bar is zero worsened.
.\QpdfSweep.ps1 -Corpus C:\pdf-corpus -Resaved C:\pdf-corpus-resaved `
-ResaveLog ..\resave.csv -CsvOut qpdf-results.csv
Exit code 0 = no worsened pairs, 1 = worsened pairs found, 2 = usage/input error.
.NOTES
Compatible with Windows PowerShell 5.1 and PowerShell 7.
Part of the KillerPDF validation harness (validation/).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string]$Corpus,
[Parameter(Mandatory = $true)] [string]$Resaved,
[Parameter(Mandatory = $true)] [string]$ResaveLog,
[Parameter(Mandatory = $true)] [string]$CsvOut
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command qpdf -ErrorAction SilentlyContinue)) { Write-Error 'qpdf not on PATH'; exit 2 }
if (-not (Test-Path -LiteralPath $ResaveLog)) { Write-Error "Resave log not found: $ResaveLog"; exit 2 }
function Get-QpdfCheckCode {
param([string]$Path)
# qpdf writes ordinary exit-3 warnings to stderr. Under Windows PowerShell, redirecting
# native stderr while ErrorActionPreference is Stop turns that expected diagnostic into a
# terminating NativeCommandError before the sweep can record the exit code.
$oldPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
& qpdf --check $Path *> $null
return $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldPreference
}
}
$rows = Import-Csv -LiteralPath $ResaveLog | Where-Object { $_.Status -eq 'OK' }
$results = New-Object System.Collections.Generic.List[object]
$i = 0
foreach ($r in $rows) {
$i++
if ($i % 100 -eq 0) { Write-Host ("{0} / {1}" -f $i, $rows.Count) }
$o = Join-Path $Corpus $r.File
$n = Join-Path $Resaved $r.File
if (-not (Test-Path -LiteralPath $n)) {
$results.Add([pscustomobject]@{ File = $r.File; Before = -1; After = -1; Worsened = 'MISSING' })
continue
}
$b = Get-QpdfCheckCode $o
$a = Get-QpdfCheckCode $n
$results.Add([pscustomobject]@{ File = $r.File; Before = $b; After = $a; Worsened = ($a -gt $b) })
}
$results | Export-Csv -LiteralPath $CsvOut -NoTypeInformation -Encoding UTF8
Write-Host ''
Write-Host ('Pairs checked : {0}' -f $results.Count)
$results | Group-Object { '{0} -> {1}' -f $_.Before, $_.After } | Sort-Object Count -Descending |
ForEach-Object { Write-Host (' {0,-10} {1}' -f $_.Name, $_.Count) }
$worse = @($results | Where-Object { $_.Worsened -eq $true -or $_.Worsened -eq 'MISSING' })
Write-Host ('Worsened : {0}' -f $worse.Count)
if ($worse.Count -gt 0) {
Write-Host 'RESULT: FAIL - structural health worsened on at least one file.' -ForegroundColor Red
exit 1
} else {
Write-Host 'RESULT: PASS - no file''s structural health got worse.' -ForegroundColor Green
exit 0
}
+144
View File
@@ -0,0 +1,144 @@
# Standards-conformance validation results - KillerPDF 1.7.5
veraPDF run date: 2026-08-22, against the 1.7.5 release build. This small maintenance release
changes live annotation rotation behavior, mouse-wheel navigation, shortcuts, and localization,
without changing the PDF serializer. The standard open/save pipeline was nevertheless run fresh
across the complete corpus because every KillerPDF release must independently meet the same
zero-regression bar. The run reproduces every established count exactly: 2,236 successful resaves, 671 refusals
matching the SKIP rows one for one, 63 improvements, and the same single documented PDF/A-4
header case as the only flagged saved file. The qpdf sweep also reproduces its table exactly:
2,032 clean both sides, 195 improved, 9 kept preexisting warnings, 0 worsened.
Question under test: does saving a PDF through KillerPDF degrade its
standards conformance? Every file in a 2,907-file public corpus was validated, resaved through
KillerPDF's standard open/save pipeline, and validated again.
Result: **Zero** conformance regressions across every file KillerPDF will save, with one documented engine limitation
(PDF/A-4's PDF 2.0 header). **63 files came out more conformant than they went in.**
## Tools
| Tool | Version | Role |
|---|---|---|
| veraPDF | 1.30.2 | PDF/A + PDF/UA validation (the industry reference validator) |
| qpdf | 12.3.2 | Structural check (`--check` exit codes) |
| KillerPDF | 1.7.5 | `--batch-resave` through the standard open/save pipeline |
| Compare-VeraPDF.ps1 | this folder | Diffs the two veraPDF reports file by file |
| QpdfSweep.ps1 | this folder | Structural before/after sweep (`qpdf --check` exit codes) |
## Corpus
2,907 PDFs from the public conformance suites: the veraPDF test corpus (PDF/A-1, PDF/A-2,
PDF/A-4, PDF/UA-1, PDF/UA-2), the Isartor PDF/A-1b test suite, and the TWG test files. These
are deliberately hostile files: most are constructed to violate exactly one clause of a
standard, so any structural damage a resave introduces shows up as a new failed rule.
## Method
1. Validate the pristine corpus: `verapdf --recurse --format json <corpus> > baseline.json`
2. Resave every file through KillerPDF: `KillerPDF.exe --batch-resave <corpus> <resaved> --log resave.csv`
3. Validate the resaved tree the same way into `after.json`
4. `Compare-VeraPDF.ps1` matches files by relative path and flags any file that fails a rule
after the resave that it did not fail before
5. qpdf sweep: `qpdf --check` on original and resave of all 2,236 saved files; flag any file
whose exit code worsened
## veraPDF results
| Outcome | Files |
|---|---|
| Corpus total | 2,907 |
| Resaved OK | 2,236 |
| Skipped (refused, source untouched) | 671 |
| Resave failures | 0 |
| Validation outcome unchanged | 2,172 |
| Improved (noncompliant before, fully compliant after) | 59 |
| Improved (fails fewer rules than before) | 4 |
| Regressed | 1 (the documented PDF/A-4 header case below) |
The 671 skips are encrypted files and files damaged beyond parsing. KillerPDF refuses to
resave what it cannot fully read rather than risk writing a damaged file; each one is a SKIP
row in `resave.csv`, and all 671 files absent from the after-report cross-check exactly
against those SKIP rows. No file went missing for any other reason.
The 63 improvements are a side effect, not a goal: many corpus files carry deliberately
malformed structure (bad trailers, broken xref, wrong stream lengths), and rewriting the file
through a clean serializer repairs that class of defect.
## The one known limitation: PDF/A-4
ISO 19005-4 (PDF/A-4) is built on PDF 2.0 and requires a `%PDF-2.0` header. KillerPDF's write
engine serializes PDF 1.7, so the single PDF/A-4 corpus file gains ISO 19005-4:2020 clause
6.1.3 tests 4 and 5 after a resave. This is a version-marker limitation, not structural
damage: qpdf reports the resaved file clean. PDF 2.0 serialization is future work; KillerPDF
does not claim PDF/A-4 output.
## qpdf structural sweep
`qpdf --check` on the original and the resave of all 2,236 saved files:
| Exit code before -> after | Files |
|---|---|
| 0 -> 0 (clean both sides) | 2,032 |
| 3 -> 0 (warnings before, clean after) | 195 |
| 3 -> 3 (kept preexisting warnings) | 9 |
| Worsened | 0 |
No file's structural health got worse; 195 files with qpdf warnings came out clean.
## What had to be fixed to get here
The write engine is PdfSharpCore 1.3.67 (MIT), vendored under `third_party/PdfSharpCore/`
with six patches, each marked `KillerPDF patch` in the source:
1. **No Producer/Creator stamping** into an imported document's Info dictionary. PDF/A
(ISO 19005-1 clause 6.7.3) requires the Info dictionary to stay equivalent to the XMP
metadata; silently rewriting Producer broke that on every save.
2. **No /ModDate rewrite at open.** Same clause: the reader stamped a new modification date
into every document the moment it was opened for modification.
3. **No transparency /Group injected into pages.** The writer force-added
`/Group << /S /Transparency >>` to every page; PDF/A-1 (clause 6.4) forbids transparency.
4. **Stream /Length always matches the spec's byte count** (clause 6.1.7), including
zero-length streams, which were serialized with no EOL between `stream` and `endstream`.
5. **Debug verbose file layout removed.** Debug builds padded object tokens with extra
spacing that violates the object syntax rules (clause 6.1.8).
6. **Booleans written as the PDF keywords `true` / `false`.** .NET's `Boolean.ToString()`
leaked into indirect boolean objects as `True`, which is not a valid PDF token
(ISO 32000-1 clause 7.3.2). This broke `/MarkInfo /Marked` in PDF/UA files.
On top of the library patches, every save runs three scrubs in KillerPDF itself:
- **Dangling /Outlines removal** - reading `doc.Outlines` plants an empty outline dictionary
that becomes a dangling reference (the 1.6.3 corruption bug).
- **Degenerate /CropBox removal** - reading page boxes planted `[0 0 0 0]` boxes that Adobe
rejects as out-of-range page dimensions (the other 1.6.3 corruption bug).
- **Dead signature values stripped** - a digital signature's digest must cover the entire
file, so any resave invalidates it. Leaving the stale `/V` and `/Perms /DocMDP` entries in
place fails strict validation; the save now removes the dead values and keeps the empty
signature fields.
## Reproducing this run
Everything needed ships in this folder or is a free download (veraPDF, qpdf, the public
corpora). On a tree containing the corpus:
```
verapdf --recurse --format json C:\pdf-corpus > baseline.json
Start-Process -Wait KillerPDF.exe -ArgumentList '--batch-resave','C:\pdf-corpus','C:\pdf-corpus-resaved','--log','resave.csv'
verapdf --recurse --format json C:\pdf-corpus-resaved > after.json
.\Compare-VeraPDF.ps1 -Baseline baseline.json -After after.json `
-BaselineRoot C:\pdf-corpus -AfterRoot C:\pdf-corpus-resaved -CsvOut compare.csv
.\QpdfSweep.ps1 -Corpus C:\pdf-corpus -Resaved C:\pdf-corpus-resaved `
-ResaveLog resave.csv -CsvOut qpdf-results.csv
```
**The resave step must be `Start-Process -Wait`** (or otherwise blocked on): KillerPDF.exe is
a GUI-subsystem binary, so a bare invocation returns immediately and the after-scan then
validates a half-written tree - every not-yet-written file shows up as MISSING_AFTER (this
burned the 1.7.0 run, twice).
The compare script counts every MISSING_AFTER as a regression by design, so a run with skips
exits 1 even when clean. The release bar is: every MISSING_AFTER row cross-checks against a
SKIP row in `resave.csv` (encrypted/unparseable files KillerPDF refuses to touch), and the
only rule-level change is the documented PDF/A-4 header case. Anything beyond that is a real
regression.