How to handle PDF/A archival compliance in your document pipeline

A practical guide to generating PDF/A-compliant documents for long-term archival, including font embedding, color profiles, metadata, and validation in your pipeline.

pdfpdf/aarchivalcompliancenode.jsdocument-generation

How to handle PDF/A archival compliance in your document pipeline

If you're generating invoices, contracts, or regulatory filings that need to live for 7+ years, "just save a PDF" is not enough. Regulators in finance, healthcare, and government routinely require PDF/A — a constrained subset of PDF designed for long-term archival. Get it wrong, and you'll discover the problem years later when a document fails an audit because a font is missing or a color profile drifted.

This post walks through what PDF/A actually requires, where most pipelines break, and how to bake compliance into your document generation flow.

What PDF/A actually constrains

PDF/A is defined by ISO 19005. The core idea: a PDF/A file must be fully self-contained and deterministic to render the same way 30 years from now as it does today.

In practice, that means:

  • All fonts must be embedded (full subsets, not references to system fonts)
  • All colors must use device-independent color spaces (typically via an embedded ICC profile)
  • No external dependencies — no JavaScript, no audio/video, no encryption, no external hyperlinks that change rendering
  • Required XMP metadata identifying the document as PDF/A
  • No transparency (PDF/A-1) or controlled transparency (PDF/A-2+)

The flavors you'll encounter:

  • PDF/A-1b — basic visual reproduction, most strict, widely required by older systems
  • PDF/A-2b — allows JPEG2000, transparency, layers
  • PDF/A-3b — same as 2b but allows embedded files (useful for invoices with embedded XML, e.g. ZUGFeRD)

If you're not sure which level to target, PDF/A-2b is a safe default for most modern archival use cases.

Where document pipelines usually break

Most pipelines start with HTML-to-PDF rendering (headless Chrome, wkhtmltopdf, etc.) and stop there. That output is almost never PDF/A compliant. The common failures:

  1. Fonts loaded from the OS — Chrome will happily render with whatever font the container image has. If that font isn't embedded, validators reject the file.
  2. sRGB without an embedded profile — colors are device-dependent.
  3. Missing XMP pdfaid:part and pdfaid:conformance entries.
  4. Transparency in PNGs or CSS — flagged in PDF/A-1.
  5. Hyperlinks with JavaScript: actions or form fields with JS validation.

A practical conversion flow

The realistic pipeline looks like this:

txt
HTML/template → render PDF → post-process to PDF/A → validate → archive

Here's a minimal Node.js example using Ghostscript for the conversion step:

bash
gs -dPDFA=2 \ -dBATCH \ -dNOPAUSE \ -sProcessColorModel=DeviceRGB \ -sDEVICE=pdfwrite \ -sPDFACompatibilityPolicy=1 \ -sOutputFile=invoice.pdfa.pdf \ PDFA_def.ps invoice.pdf

You'll also need a PDFA_def.ps file that points to your ICC profile and sets the required metadata. A minimal version:

txt
%! [ /Title (Invoice INV-001) /DOCINFO pdfmark [ /_objdef {icc_PDFA} /type /stream /OBJ pdfmark [ {icc_PDFA} <</N 3>> /PUT pdfmark [ {icc_PDFA} (sRGB2014.icc) (r) file /PUT pdfmark [ /_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark [ {OutputIntent_PDFA} << /Type /OutputIntent /S /GTS_PDFA1 /DestOutputProfile {icc_PDFA} /OutputConditionIdentifier (sRGB) >> /PUT pdfmark [ {Catalog} <</OutputIntents [{OutputIntent_PDFA}]>> /PUT pdfmark

Wrap it in a Node helper:

ts
import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export async function toPdfA(input: string, output: string) { await run("gs", [ "-dPDFA=2", "-dBATCH", "-dNOPAUSE", "-sProcessColorModel=DeviceRGB", "-sDEVICE=pdfwrite", "-sPDFACompatibilityPolicy=1", `-sOutputFile=${output}`, "PDFA_def.ps", input, ]); }

Don't skip validation

Generating a file is half the job. You must verify it. The standard open-source validator is veraPDF:

bash
verapdf --flavour 2b --format text invoice.pdfa.pdf

A clean run looks like:

txt
PASS invoice.pdfa.pdf

Wire this into CI so a regression in your templates (someone adds a custom font, an embedded SVG with transparency) fails the build:

bash
verapdf --flavour 2b --format xml *.pdf > report.xml test $(grep -c 'isCompliant="false"' report.xml) -eq 0

Embedding metadata correctly

PDF/A requires XMP metadata declaring the conformance level. If you're building PDFs directly with a library like pdf-lib, you need to inject this yourself:

ts
import