How to Handle PDF/A Archival Compliance in Your Document Pipeline

A practical guide to generating PDF/A-compliant documents for long-term archival, covering the standard's requirements, common pitfalls, and how to validate output in your pipeline.

pdfpdf/acompliancearchivalnode.jsbackend

How to Handle PDF/A Archival Compliance in Your Document Pipeline

If your product touches invoices, contracts, medical records, or anything a regulator might ask about ten years from now, plain PDF is not enough. You need PDF/A — the ISO-standardized subset of PDF designed for long-term preservation. Getting it right involves more than flipping a flag; it affects fonts, color profiles, metadata, and how you assemble your document pipeline.

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

What PDF/A actually is

PDF/A is a family of ISO 19005 standards. The important thing for developers is picking the right conformance level:

  • PDF/A-1 (2005): Based on PDF 1.4. Strictest. No transparency, no layers, no embedded files.
  • PDF/A-2 (2011): Based on PDF 1.7. Allows JPEG2000, transparency, and PDF/A file attachments.
  • PDF/A-3 (2012): Same as A-2, but allows arbitrary file attachments (e.g., embedding the source XML alongside a rendered invoice — used heavily for e-invoicing formats like ZUGFeRD and Factur-X).
  • PDF/A-4 (2020): Based on PDF 2.0. Modernized, simpler conformance model.

Each has conformance levels: b (basic — visual reproducibility), u (unicode text mapping), and a (accessibility, tagged structure). For most backend pipelines, PDF/A-2b or PDF/A-3b is the practical target.

The rules you'll actually trip over

Regardless of version, PDF/A imposes a set of constraints that will bite you if you're used to generating regular PDFs:

  1. All fonts must be embedded. No system font fallback. This includes standard fonts like Helvetica.
  2. No encryption. PDF/A files cannot be password-protected.
  3. Color spaces must be defined. Every color needs an ICC profile or output intent. You can't use device-dependent RGB without declaring one.
  4. No external references. No linked images, no JavaScript, no external streams.
  5. XMP metadata is mandatory. Not the old Info dictionary — XMP, and it must declare the PDF/A version and conformance.
  6. Transparency is restricted (banned in A-1, allowed in A-2+).
  7. No multimedia, no 3D, no forms with JavaScript.

Miss any of these and validators will reject the file.

Where pipelines usually break

Most PDF generation pipelines start with an HTML template rendered by a headless browser (Chromium via Puppeteer or Playwright). This gets you 90% of a valid PDF, but not PDF/A. Chromium doesn't embed fonts by default, doesn't emit XMP, and doesn't declare an output intent.

A typical failing flow looks like this:

ts
import puppeteer from "puppeteer"; const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setContent(html); const buffer = await page.pdf({ format: "A4" }); // This is a valid PDF. It is NOT PDF/A.

To make it PDF/A, you need a post-processing step that:

  • Embeds all referenced fonts as subsets
  • Adds an ICC output intent (typically sRGB)
  • Injects XMP metadata with the PDF/A identification
  • Strips anything disallowed (encryption, JS actions, external links)

A working post-processing approach

If you're doing this yourself, ghostscript is the workhorse. It's ugly, but it works:

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

You'll need a PDFA_def.ps file that references your ICC profile and defines the output intent. This is where teams usually give up and reach for a service, because getting Ghostscript's PostScript definition file right and keeping it in sync across environments is painful.

Validating output

Never trust that the file is valid just because your generator claims so. Validate every file in CI and, ideally, at runtime for anything going into an archive.

veraPDF is the reference open-source validator:

bash
verapdf --format json --flavour 2b invoice-pdfa.pdf > report.json

A minimal check in Node:

ts
import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export async function validatePdfA(path: string) { const { stdout } = await run("verapdf", [ "--format", "json", "--flavour", "2b", path, ]); const report = JSON.parse(stdout); const compliant = report.jobs[0].validationResult.isCompliant; if (!compliant) { throw new Error(`PDF/A validation failed for ${path}`); } }

Run this as part of your document generation tests, and against a sample from production traffic.

Using Kamy for PDF/A output

If you'd rather not maintain Ghostscript, ICC profiles, and veraPDF containers yourself, Kamy handles PDF/A conformance as part of the rendering step. You specify the target conformance in the render request and the output is validated before it's returned:

ts
import { kamy } from "@kamydev/sdk"; const pdf =