gopdfrab
PDF/A processing for Go
About gopdfrab
This large README is shown as a plain-text preview. Read the full README at the source
# gopdfrab
[](https://codecov.io/gh/voidrab/gopdfrab)
[](https://pkg.go.dev/github.com/voidrab/gopdfrab)
[](https://github.com/avelino/awesome-go)
PDF/A processing for go!
Verify and convert PDF documents with a small, predictable open source library.
## Status
PDF/A-1b verification and conversion are implemented and tested against the full
Isartor and veraPDF conformance suites (see [Performance](#performance) and
[Fuzzing & Stress Testing](#fuzzing--stress-testing)). The API is **stable as of
1.0** — [CHANGELOG.md](CHANGELOG.md) states the versioning and stability policy.
To report a vulnerability, see [SECURITY.md](SECURITY.md).
## Features
- PDF structural integrity verification (Arlington model)
- PDF/A-1b verification
- PDF/A-1b conversion
- Decryption of encrypted PDFs (RC4 40/128, AES-128, AES-256), with the empty or
a supplied user/owner password
## Roadmap
PDF/A-1b is complete: every check in scope is implemented and both conformance
suites pass. PDF/A-2, -3 and -4 come next.
## Command-line tool
A CLI ships under `cmd/gopdfrab`:
```bash
go install github.com/voidrab/gopdfrab/cmd/gopdfrab@latest
gopdfrab verify docs/ # verify every PDF under a directory
gopdfrab verify --json report.pdf # machine-readable output
gopdfrab convert in.pdf out.pdf # rewrite towards PDF/A-1b
gopdfrab convert --dpi 300 in.pdf # tune the raster fallback
gopdfrab verify --max-decoded-mb 64 x.pdf # cap decoded stream output at 64 MB
```
Subcommands are `verify`, `convert`, `version` and `help`. Exit codes are `0`
conformant, `1` non-conformant, `2` error, so it drops into scripts and CI
directly. `verify` walks directories recursively.
`verify` prints a fixed layout that scripts can parse:
```text
profile: pdfa1b
FAIL issues=974 distinct=36 docs/L34a.pdf
6.1.2/5 count=1 pages=doc objs=1 "ViewerPreferences entry /PrintScaling requires PDF 1.5 or later"
6.2.3.3/2 count=149 pages=1-2 objs=127 "device colour (gray) used in content stream without matching OutputIntent"
6.3.4/1 count=3 pages=doc,1 objs=3 "font Courier-Bold is not embedded"
6.6.1/1 count=258 pages=doc,1-2,4 objs=258 "forbidden action type /JavaScript"
...
objmodel/1 count=3 pages=1-2 objs=0 "Appearance is missing required key \"N\""
PASS docs/ok.pdf
ERROR docs/broken.pdf
error "failed to parse structure: pdf: not a PDF document: no %PDF- header"
summary: pass=1 fail=1 error=1 files=3
```
- A file line starts with `PASS`, `FAIL` or `ERROR`; the path is the rest of the
line. `FAIL` gives the number of issues and of issue lines.
- Indented lines belong to the file above. An issue line covers every report of
one check with one message: clause[/subclause], `count=<n>` reports,
`pages=` the pages they were on (`doc` for document-level, runs as ranges),
`objs=<n>` distinct objects, and the messages as one Go-quoted string. Lines
are sorted by clause, then message.
- `--detail` lists every location instead, one line per page and object:
clause, `page=<n|doc>`, `obj=<num>.<gen>` or `obj=-`, `count=<n>` repeats there,
and the messages.
- `convert` prints `profile:`, `input:`, `output:`, `iterations:`, then
optionally `rasterized: pages=<ranges>` for pages rebuilt as an image,
`dropped: pages=<ranges> features=<n>` with one `pages=<ranges>` line per
feature the rasterizer could not draw (the feature Go-quoted), and
`lost: objects=<n> distinct=<n>`, and last `result: conformant` or
`result: residual issues=<n> distinct=<n>`, with the same issue lines beneath.
`--json` reports every occurrence, ungrouped.
Both subcommands take:
| Flag | Meaning |
|---|---|
| `--profile` | `pdfa1b` (default), `legacy1b`, or `pdf` |
| `--password` | password for an encrypted input. It is visible to other users in `ps`; set `GOPDFRAB_PASSWORD` instead to keep it out of the process list |
| `--max-decoded-mb` | cap a single stream's decoded output in MB (0 = default 256) |
| `--max-resident-mb` | cap a document's rebuildable caches in MB (0 = default 64) |
| `--json` | emit machine-readable JSON |
| `--detail` | list every page and object an issue was found on, instead of grouping by check and message |
`convert` also takes:
| Flag | Meaning |
|---|---|
| `--dpi` | raster fallback resolution (0 = default 150) |
| `--max-iterations` | verify/fix loop bound (0 = default 4) |
| `-o` | output path (default: the input with a `.pdfa.pdf`/`.fixed.pdf` suffix) |
## WebAssembly
`wasm/` is a `syscall/js` wrapper that runs verification and conversion in the
browser, with no server involved:
```bash
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o gopdfrab.wasm ./wasm
```
It registers two globals, both taking a `Uint8Array` and returning a Promise:
- `gopdfrabVerify(bytes)` resolves to `{valid, summary, profile, issueCount, doc, issues}`.
- `gopdfrabConvert(bytes)` resolves to `{valid, iterations, output, doc, before, resolved, residual, rasterizedPages, rasterDrops, lostObjects}`, where `output` is the converted PDF as a `Uint8Array`.
Each issue carries `{clause, subclause, name, description, group, page, documentLevel, messages}`; `doc` reports what the file says about itself (`pageCount`, `version`, `claimedPart`, `claimedLevel`, `title`, `author`), each field best-effort. The doc comments in `wasm/main.go` are the full reference.
On js/wasm there is no filesystem, so only the `*Bytes` entry points apply.
## Getting Started
### Add gopdfrab
```bash
go get github.com/voidrab/gopdfrab
```
### Import gopdfrab
```go
import (
"github.com/voidrab/gopdfrab"
)
```
### Initialize a Document
```go
doc, err := gopdfrab.Open(path)
if err != nil {
log.Fatal(err)
}
```
`OpenBytes` does the same for a PDF already in memory.
### Encrypted PDFs
Encrypted documents are decrypted transparently on open when they use the empty
user password. Supply a user or owner password
explicitly with `OpenWithPassword`:
```go
doc, err := gopdfrab.OpenWithPassword(path, []byte("secret"))
if errors.Is(err, gopdfrab.ErrPasswordRequired) {
log.Fatal("a correct password is required to open this file")
}
```
`OpenBytesWithPassword` is the same for in-memory data.
`Verify` and `Convert` decrypt the same way. A file that needs a
password is reported with `ErrPasswordRequired` rather than
producing a broken result.
### PDF/A Validation
```go
v, err := doc.Verify(gopdfrab.PDFA1B)
if err != nil {
log.Println(err)
}
if v.Valid {
fmt.Println("Document is PDF/A-1b compliant")
} else {
fmt.Println("Document is not PDF/A-1b compliant")
fmt.Println("Issues:")
for i, v := range v.Issues {
fmt.Printf("#%v: %v\n", i+1, v)
}
}
```
Finally, close doc.
```go
doc.Close()
```
### Verify a File
`Verify` opens, verifies, and closes a file.
```go
result, err := gopdfrab.Verify(path, gopdfrab.PDFA1B)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Valid)
```
### Verifying In-Memory Data
`VerifyBytes` is `Verify` for an in-memory PDF.
```go
result, err := gopdfrab.VerifyBytes(data, gopdfrab.PDFA1B)
```
### Verifying Multiple Files
`VerifyAll` opens, verifies, and closes a batch of files concurrently.
```go
results, err := gopdfrab.VerifyAll(paths, gopdfrab.PDFA1B)
if err != nil {
log.Fatal(err)
}
for _, r := range results {
if r.Err != nil {
log.Println(r.Path, r.Err)
continue
}
fmt.Println(r.Path, r.Result.Valid)
}
```
### Typed Errors
Open, verify and convert failures can be matched with `errors.Is` instead of
inspecting message text:
| Error | Meaning |
|---|---|
| `gopdfrab.ErrNotPDF` | the input is not a PDF (no `%PDF-` header) |
| `gopdfrab.ErrDamaged` | a PDF whose cross-reference or trailer structure could not be parsed |
| `gopdfrab.ErrEncrypted` | an encryption scheme gopdfrab does not implement |
| `gopdfrab.ErrPasswordRequired` | a correct password is required to open the file |
| `gopdfrab.ErrUnresolvableGraph` | `Convert` could not resolve the object graph, so no output was produced |
| `gopdfrab.ErrInternal` | a defect in gopdfrab itself, recovered from a panic; please report it with the input file |
An individual object that fails to parse — a wrong cross-reference offset, a
corrupt body — does not fail the whole document. The object is re-located by
scanning for its real `N G obj` header, or resolved to null when no intact copy
exists; either way the damage is reported as an issue and every other check
still runs. A conversion that had to null an unrecoverable object keeps that
loss in `Residual()` and never reports the result as valid.
The same applies to whole-table damage: a missing or unusable `startxref`
triggers a full-file object scan that rebuilds the cross-reference table and
recovers the trailer from the document catalog, reported as a 6.1.4 issue rather
than a hard error, so a badly damaged file still verifies and converts.
### Inspecting Issues
Each `PDFError` in `v.Issues` exposes the `Check` that flagged it, along with its page and underlying messages.
```go
for _, issue := range v.Issues {
c := issue.Check()
fmt.Println(c.Clause(), c.Subclause(), c.Name(), c.Description())
fmt.Println(issue.Page(), issue.Messages())
}
```
`Result` has helpers for grouping and summarizing issues:
```go
fmt.Println(v.Summary()) // human-readable report, one line per Check
v.Checks() // distinct Checks violated, sorted by clause
v.IssuesByCheck() // map[Check][]PDFError
v.IssuesOnPage(1) // issues found on page 1 (0 = document-level)
```
`Result`, `PDFError` and `Check` marshal to a stable JSON shape, for CLI, service, or CI integration:
```go
b, _ := json.Marshal(v)
// {"type":"A-1b","valid":false,"issueCount":2,"issues":[{"check":{"name":"...","clause":"6.1.3",...},"page":0,"documentLevel":true,"messages":["..."],"text":"..."}]}
```
### Document Helpers
```go
ok, err := doc.IsPDFA() // shorthand for Verify(PDFA1B).Valid
ok, err := doc.IsPDF() // shorthand for VerifyObjectModel().Valid
part, level, err := doc.ClaimedConformance() // e.g. "1", "B" — what the file claims, not whether it's valid
n, err := doc.PageCount() // number of pages
version, err := doc.Version() // PDF version from the header, e.g. "1.7"
info, err := doc.Metadata() // Info dictionary entries (Title, Author, ...)
xmp, err := doc.XMPMetadata() // raw XMP packet bytes, decoded to UTF-8
```
### Converting to PDF/A
`Convert` produces a PDF/A conformant rewrite. It runs pre-emptive fixups, then a verify/fix loop, and rasterizes pages as a last resort when no in-place fixer can repair them.
```go
cr, err := gopdfrab.Convert(path, gopdfrab.PDFA1B)
if err != nil {
log.Fatal(err)
}
defer cr.Close() // releases the output (a large one spills to a temp file)
if err := cr.Save("out.pdf"); err != nil {
log.Fatal(err)
}
fmt.Println(cr.Iterations) // how many verify/fixup passes it took
fmt.Println(cr.Result.Valid) // true if the output is fully PDF/A conformant
```
`cr.Save(path)` writes the output to a file and `cr.WriteTo(w)` streams it to any `io.Writer` (it implements `io.WriterTo`) — both without holding a second copy; `cr.Output()` returns the bytes when you need them in memory. All three error when there is no output. A large output spills to a temp file rather than staying resident, so call `cr.Close()` when done (`ConvertEach` closes each result for you).
```go
_, err := cr.WriteTo(w) // e.g. an http.ResponseWriter or a bytes.Buffer
```
### Options and cancellation
The two-argument forms (`Convert(path, profile)`, `Verify(path, profile)`, and their `Bytes`/`All` variants) cover the common case. Each has a `…Context` counterpart that adds a `context.Context` for cancellation and an `Options` struct for tuning — `VerifyContext`, `VerifyBytesContext`, `VerifyAllContext`, `ConvertContext`, `ConvertBytesContext`, `ConvertAllContext`. The zero `Options` value is the default behavior.
```go
cr, err := gopdfrab.ConvertContext(ctx, path, gopdfrab.PDFA1B, gopdfrab.Options{
Password: []byte("secret"), // decrypt an encrypted input
RasterDPI: 300, // raster last-resort resolution (default 150)
MaxIterations: 8, // verify/fix loop bound (default 4)
})
```
To set options without a deadline, pass `context.Background()`. `Options.Password` applies at the open step (so it works on `ConvertContext`/`VerifyContext` but not the `*Document` methods, whose file is already open — use `OpenWithPassword`). `RasterDPI` and `MaxIterations` are convert-only; `Verify` reads only `Password`.
`ConvertContext` checks the context before each verify/fix iteration and each raster pass; `ConvertAllContext`/`VerifyAllContext` stop dispatching new files once it is cancelled and record `ctx.Err()` for the rest.
### Resource limits
By default a single stream may decode to at most 256 MB, a guard against decompression bombs. Raise it for legitimately large PDFs or lower it to harden against hostile input with `SetLimits`:
```go
gopdfrab.SetLimits(gopdfrab.Limits{MaxDecodedStreamBytes: 512 << 20}) // 512 MB
```
The caps are process-wide rather than per-call — they are enforced deep in the decode path, reached from many callers that hold no document handle, so one value applies uniformly. Set them once at startup, before concurrent verify/convert. A zero or negative field resets that cap to its default; `CurrentLimits` and `DefaultLimits` report the effective and built-in values.
### Converting an Open Document
```go
cr, err := doc.Convert(gopdfrab.PDFA1B)
```
### Converting In-Memory Data
`ConvertBytes` is `Convert` for an in-memory PDF.
```go
cr, err := gopdfrab.ConvertBytes(data, gopdfrab.PDFA1B)
```
### Converting Multiple Files
`ConvertAll` opens, converts, and closes a batch of files concurrently.
```go
results, err := gopdfrab.ConvertAll(paths, gopdfrab.PDFA1B)
if err != nil {
log.Fatal(err)
}
for _, r := range results {
if r.Err != nil {
log.Println(r.Path, r.Err)
continue
}
fmt.Println(r.Path, r.Result.Result.Valid) // r.Result is a ConvertResult
}
```
For a batch too large to hold every output in memory at once, `ConvertEach` streams instead: it calls a callback on each result as it completes (serialized, in completion order), so you can write each output and let it be collected. `Options.Workers` bounds the concurrency of both forms (0 = `runtime.NumCPU`).
```go
err := gopdfrab.ConvertEach(paths, gopdfrab.PDFA1B, gopdfrab.Options{Workers: 4},
func(r gopdfrab.FileResult[gopdfrab.ConvertResult]) error {
if r.Err != nil {
return nil // skip this file, keep going
}
return r.Result.Save(filepath.Join(outDir, filepath.Base(r.Path)))
})
```
Returning a non-nil error from the callback stops the batch and is returned from `ConvertEach`. `ConvertEachContext` is the same with a `context.Context`.
### Inspecting Residuals
Even though `Convert` always returns its best attempt, the result may still carry residual issues if no automatic remediation — including the raster last resort — fully resolved them.
```go
residual := cr.Residual()
for _, iss := range residual {
check := iss.Check()
fmt.Println(check.Clause(), check.Name())
fmt.Println(iss.Page(), iss.Messages())
}
```
### Fidelity
Conforming to PDF/A is not the same as *looking* like the input — a page blanked
during conversion still verifies clean. `Options.CheckFidelity` renders the input
and the output and reports a per-page comparison so you can catch that:
```go
cr, _ := gopdfrab.ConvertContext(ctx, path, gopdfrab.PDFA1B,
gopdfrab.Options{CheckFidelity: true})
for _, pf := range cr.Fidelity {
if pf.Blanked() {
log.Printf("page %d lost its content during conversion", pf.Page)
}
}
```
Both sides are drawn by the same rasterizer, so its limitations cancel and the
comparison isolates what the conversion changed. `Blanked()` flags unambiguous
content loss without tripping on benign changes like font substitution.
When conversion has to rasterize a page as a last resort, anything the rasterizer
can't draw — shadings, inline images, Type 3 fonts — is reported per page in
`cr.RasterDrops` rather than silently omitted, so that loss is loud even though
the pixel comparison (which drops it symmetrically) cannot see it.
Content the conversion could not carry over at all — an object the reader could
not resolve, a stream nothing can decode — is listed in `cr.LostObjects`. That is
a separate fact from conformance: the file that was written can meet PDF/A-1b and
still be missing something the input had, and `cr.Result.Valid` answers only the
first question.
## Selective Check Profiles
Verification can be narrowed to a specific set of rules using `Verify`.
`PDFA1B`, `Legacy1B` and `PDF` are package variables holding the ready-made
profiles. A profile is immutable — `AddCheck`, `RemoveCheck` and `Clear` each
return a clone — but the variables are not: reassigning one changes the default
for the whole process.
### Start from the full profile and remove checks
```go
p := gopdfrab.PDFA1B.
RemoveCheck(gopdfrab.Checks.Structure.FileHeaderSignature).
RemoveCheck(gopdfrab.Checks.Font.SimpleNotEmbedded)
res, err := doc.Verify(p)
```
### Start from an empty profile and add checks
```go
p := gopdfrab.PDFA1B.Clear().
AddCheck(
gopdfrab.Checks.Transparency.ImageWithSoftMask,
gopdfrab.Checks.Metadata.PDFAIdentifierMissing,
)
res, err := doc.Verify(p)
```
### Start from nothing
`NewProfile` returns an empty profile for a conformance level, for building a
rule set up from scratch:
```go
p := gopdfrab.NewProfile(gopdfrab.A1B).
AddCheck(gopdfrab.Checks.Font.SimpleNotEmbedded)
res, err := doc.Verify(p)
```
### Available check groups
| Registry field | Spec area |
|---|---|
| `Checks.Structure` | 6.1.x — file header, trailer, xref, object framing, limits |
| `Checks.Colour` | 6.2.2 OutputIntent, 6.2.3.x device colours, 6.2.9–10 |
| `Checks.Image` | 6.2.4–6.2.7 image/form/PostScript XObjects |
| `Checks.Transparency` | 6.2.8 transfer functions, 6.4 soft masks/blend modes/alpha |
| `Checks.Font` | 6.3.x embedding, subsets, metrics, encoding |
| `Checks.Annotation` | 6.5.x annotation types and dictionaries |
| `Checks.Action` | 6.6.x action types and additional actions |
| `Checks.Metadata` | 6.7.x XMP metadata, extension schemas, PDF/A identifier |
| `Checks.Form` | 6.9 interactive forms |
| `Checks.ObjectModel` | Generic ISO 32000 object-model conformance, independent of PDF/A — see below |
An eleventh group, `Checks.LogicalStructure` (6.8.x tagging, structure tree,
role map, natural language), is registered but left out of the table: all five
of its checks are PDF/A-1a only. `AllChecks()` returns 159 checks over all
eleven groups, 154 over the ten above.
Use `gopdfrab.AllChecks()` to enumerate all registered checks with their names, descriptions, and clause numbers. `gopdfrab.CheckByClause("6.3.4", 1)` and `gopdfrab.ChecksForClause("6.3.4")` look up checks by clause directly.
## PDF Object-Model Conformance
`Checks.ObjectModel` holds six checks — `MissingRequiredKey`, `WrongValueType`, `DisallowedValue`, `IndirectRequired`, `KeyIntroducedAfterPDF14`, `ConstraintViolated` — derived from the [Arlington PDF Model](https://github.com/pdf-association/arlington-pdf-model), the machine-readable ISO 32000 object model. They answer "is this even valid PDF," independent of any PDF/A conformance level.
```go
res, err := gopdfrab.VerifyObjectModel(path)
```
Frequently Asked Questions
What is gopdfrab?
gopdfrab is a File Handling library for the Go programming language. PDF/A processing for Go
How do I install gopdfrab?
Install gopdfrab with the Go module system using `go get voidrab/gopdfrab`. Check the repository for the current installation instructions.
What category does gopdfrab belong to?
gopdfrab is listed under File Handling, specifically File Handling.