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:
- All fonts must be embedded. No system font fallback. This includes standard fonts like Helvetica.
- No encryption. PDF/A files cannot be password-protected.
- Color spaces must be defined. Every color needs an ICC profile or output intent. You can't use device-dependent RGB without declaring one.
- No external references. No linked images, no JavaScript, no external streams.
- XMP metadata is mandatory. Not the old Info dictionary — XMP, and it must declare the PDF/A version and conformance.
- Transparency is restricted (banned in A-1, allowed in A-2+).
- 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:
tsimport 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:
bashgs -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:
bashverapdf --format json --flavour 2b invoice-pdfa.pdf > report.json
A minimal check in Node:
tsimport { 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:
tsimport { kamy } from "@kamydev/sdk"; const pdf =