gopdfrab

September 1, 2026 · View on GitHub

codecov Go Reference Mentioned in 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 and Fuzzing & Stress Testing). The API is stable as of 1.0CHANGELOG.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:

FlagMeaning
--profilepdfa1b (default), legacy1b, or pdf
--passwordpassword for an encrypted input
--max-decoded-mbcap a single stream's decoded output in MB (0 = default 256)
--max-resident-mbcap a document's rebuildable caches in MB (0 = default 64)
--jsonemit machine-readable JSON

convert also takes:

FlagMeaning
--dpiraster fallback resolution (0 = default 150)
--max-iterationsverify/fix loop bound (0 = default 4)
-ooutput 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}, 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

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:

ErrorMeaning
gopdfrab.ErrNotPDFthe input is not a PDF (no %PDF- header)
gopdfrab.ErrDamageda PDF whose cross-reference or trailer structure could not be parsed
gopdfrab.ErrEncryptedan encryption scheme gopdfrab does not implement
gopdfrab.ErrPasswordRequireda correct password is required to open the file
gopdfrab.ErrUnresolvableGraphConvert 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 fieldSpec area
Checks.Structure6.1.x — file header, trailer, xref, object framing, limits
Checks.Colour6.2.2 OutputIntent, 6.2.3.x device colours, 6.2.9–10
Checks.Image6.2.4–6.2.7 image/form/PostScript XObjects
Checks.Transparency6.2.8 transfer functions, 6.4 soft masks/blend modes/alpha
Checks.Font6.3.x embedding, subsets, metrics, encoding
Checks.Annotation6.5.x annotation types and dictionaries
Checks.Action6.6.x action types and additional actions
Checks.Metadata6.7.x XMP metadata, extension schemas, PDF/A identifier
Checks.Form6.9 interactive forms
Checks.ObjectModelGeneric 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.

Benchmarkgopdfrab vs veraPDFgopdfrab vs PDFBox Preflight
Startup time149x faster22x faster
Single file (cold, median file)160x faster50x faster
Batch throughput20x faster15x faster
Batch peak memory11x smaller15x smaller
Deployment footprint9x smaller3x 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 pipelineFuzzOpenBytes/FuzzLexer (parser), FuzzVerifyBytes, FuzzConvertBytes, FuzzConvertRoundTrip, and FuzzGeneratedSeed (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": FuzzVerifyDeterministic and FuzzConvertDeterministic (repeat runs must match byte-for-byte), FuzzConvertHonest (a conversion reported valid must independently re-verify as valid), and FuzzConvertConverges.

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.