gopdfrab
September 1, 2026 · View on GitHub
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 and Fuzzing & Stress Testing). The API is stable as of 1.0 — CHANGELOG.md states the versioning and stability policy. To report a vulnerability, see 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:
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.
Both subcommands take:
| Flag | Meaning |
|---|---|
--profile | pdfa1b (default), legacy1b, or pdf |
--password | password for an encrypted input |
--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 |
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:
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}, whereoutputis the converted PDF as aUint8Array.
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
go get github.com/voidrab/gopdfrab
Import gopdfrab
import (
"github.com/voidrab/gopdfrab"
)
Initialize a Document
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:
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
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.
doc.Close()
Verify a File
Verify opens, verifies, and closes a file.
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.
result, err := gopdfrab.VerifyBytes(data, gopdfrab.PDFA1B)
Verifying Multiple Files
VerifyAll opens, verifies, and closes a batch of files concurrently.
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 |
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.
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:
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:
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
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.
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).
_, 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.
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:
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
cr, err := doc.Convert(gopdfrab.PDFA1B)
Converting In-Memory Data
ConvertBytes is Convert for an in-memory PDF.
cr, err := gopdfrab.ConvertBytes(data, gopdfrab.PDFA1B)
Converting Multiple Files
ConvertAll opens, converts, and closes a batch of files concurrently.
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).
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.
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:
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
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
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:
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, the machine-readable ISO 32000 object model. They answer "is this even valid PDF," independent of any PDF/A conformance level.
res, err := gopdfrab.VerifyObjectModel(path)
VerifyObjectModelBytes is the in-memory equivalent, and doc.VerifyObjectModel() runs it on an already-open Document:
res, err := gopdfrab.VerifyObjectModelBytes(data)
res, err := doc.VerifyObjectModel()
These are shorthand for Verify/VerifyBytes/doc.Verify with gopdfrab.PDF, the profile enabling only the six checks above:
res, err := doc.Verify(gopdfrab.PDF)
ObjectModelOnly() builds a fresh profile equal to PDF, for a caller who wants
one that nothing else shares.
ConvertObjectModel is the conversion counterpart: it produces a rewrite repaired against the object-model checks only, applying every fix that is safe and semantics-preserving and reporting anything else as a residual.
cr, err := gopdfrab.ConvertObjectModel(path)
cr, err := gopdfrab.ConvertObjectModelBytes(data)
cr, err := doc.ConvertObjectModel()
Performance
gopdfrab's PDF/A-1b verification performance is (unfairly) measured against the Java-based veraPDF and PDFBox Preflight on the combined Isartor + veraPDF corpora (773 files); see benchmarks/README.md for methodology.
| Benchmark | gopdfrab vs veraPDF | gopdfrab vs PDFBox Preflight |
|---|---|---|
| Startup time | 149x faster | 22x faster |
| Single file (cold, median file) | 160x faster | 50x faster |
| Batch throughput | 20x faster | 15x faster |
| Batch peak memory | 11x smaller | 15x smaller |
| Deployment footprint | 9x smaller | 3x smaller |
Absolute numbers from the same run: the full 773-file batch verifies in 0.29 s (2749 files/s) at 67 MB peak RSS, and a cold single-file verification of the median corpus file takes ~5 ms including process startup.
Due to JVM startup overhead, startup time and cold single-file verification are significantly slower for veraPDF and Preflight.
Isartor Compatibility
The Isartor test suite is the old reference test suite for PDF/A-1b document compatibility before the veraPDF project was initiated.
If you require PDF/A-1b compatibility based on Isartor for your application, use the Legacy1B profile.
Fuzzing & Stress Testing
Because gopdfrab's whole job is to read untrusted, frequently-malformed PDFs, the
internal/pdfgen package programmatically generates "crazy, broken" PDF documents
— structurally-valid skeletons deliberately corrupted with truncation, bad
cross-reference offsets, negative stream lengths, dangling and circular
references, deep nesting, and more. Everything is generated in memory from a seed
(no external document files), so any crash is reproducible from its seed alone via
pdfgen.Generate(seed).
The generator also builds fresh random object graphs from a small PDF grammar
(pdfgen.GenerateGrammar) to reach shapes that corrupting a fixed seed never
produces.
These inputs drive native Go fuzz targets at three levels:
- Whole pipeline —
FuzzOpenBytes/FuzzLexer(parser),FuzzVerifyBytes,FuzzConvertBytes,FuzzConvertRoundTrip, andFuzzGeneratedSeed(which lets the fuzzer explore the generator's own seed space under coverage guidance). - Isolated subsystems — the decoders and parsers that whole-file fuzzing only
reaches shallowly:
FuzzDecodeStream,FuzzInflateZlib,FuzzDecodeASCIIHex,FuzzDecodeASCII85,FuzzDecodeLZW,FuzzDecodeCCITT,FuzzUndoPredictor,FuzzTokenizeContent,FuzzParseFunction,FuzzResolveColor, and the writer targets (FuzzWritePDF,FuzzWriteContentStream,FuzzBuildInlineImageBytes). - Semantic oracles — beyond "does not panic":
FuzzVerifyDeterministicandFuzzConvertDeterministic(repeat runs must match byte-for-byte),FuzzConvertHonest(a conversion reported valid must independently re-verify as valid), andFuzzConvertConverges.
Every target seeds its corpus in code, so the generated broken PDFs replay on
every go test run; TestGeneratedCorpusDoesNotPanic additionally drives a
deterministic batch through the public API on every build, and named
TestCrasher_* reproducers guard each previously-fixed crash. Concurrency and
resource bounds are covered by TestGeneratedCorpusRace /
TestConcurrentDecodeIsSafe (run under -race) and TestGeneratedCorpusTimeBounded.
To actively hunt for new crashes locally:
go test -run '^$' -fuzz=FuzzOpenBytes -fuzztime=60s ./internal/pdf/
go test -run '^$' -fuzz=FuzzParseFunction -fuzztime=60s ./internal/pdf/
go test -run '^$' -fuzz=FuzzConvertRoundTrip -fuzztime=60s .
go test -race -run 'TestGeneratedCorpusRace|TestConcurrentDecodeIsSafe' ./...
Security
gopdfrab parses untrusted, frequently-hostile input by design. To report a suspected vulnerability, follow SECURITY.md — please do not open a public issue for one.
Licensing
This work is dual-licensed under GNU AGPL 3.0 and our commercial license. Get in touch for more information about our commercial licensing options.
gopdfrab bundles third-party fonts and ICC profiles, and embeds them into converted files. NOTICE says where each one comes from and under what licence.
Code contributions are covered by a one-time Contributor Licence Agreement; see below.
Contributing
CONTRIBUTING.md goes over how to run the test suites, where the test corpora come from, and walkthroughs. ARCHITECTURE.md is a map of the codebase.
The short version:
- A PDF that fails and should not, or passes and should not. This is the most useful thing you can send. Please attach the file to an issue.
- Code changes — one concern per pull request, with a test.
- New checks — mostly a data change in the check registry. See the walkthrough in CONTRIBUTING.md.
Code and documentation are dual-licensed, AGPL 3.0 and a commercial licence, and that only works if one party can release the whole codebase under both, so pull requests need a one-time signature on the Contributor Licence Agreement. You keep your copyright; you are granting permission, not handing anything over. A bot will ask you to confirm it in a comment the first time you open a pull request.