How to handle PDF/A archival compliance in your document pipeline
If your system generates invoices, contracts, medical records, or any document that needs to be readable ten or twenty years from now, plain PDF is not enough. Regulators, auditors, and archival systems increasingly require PDF/A, an ISO-standardized subset of PDF designed for long-term preservation. Getting it right in an automated pipeline is trickier than most tutorials admit — the file has to be self-contained, deterministic, and pass strict validators.
This post walks through what PDF/A actually requires, where developers typically get it wrong, and how to structure a document pipeline that produces conformant output every time.
What PDF/A actually requires
PDF/A is not a single format. It's a family of conformance levels defined across several ISO revisions:
- PDF/A-1 (ISO 19005-1, based on PDF 1.4) — the strictest. No transparency, no layers, no JavaScript.
- PDF/A-2 (based on PDF 1.7) — allows transparency, JPEG2000, and PDF file attachments (if the attachments are themselves PDF/A).
- PDF/A-3 — same as A-2 but allows arbitrary file attachments (commonly used for embedding source XML, e.g. ZUGFeRD invoices).
- PDF/A-4 (ISO 19005-4, based on PDF 2.0) — the current recommendation for new systems.
Each level has two or three conformance flavors:
- B (Basic) — visual reproducibility only.
- U (Unicode) — B plus all text must have Unicode mappings.
- A (Accessible) — U plus tagged structure for accessibility.
Regardless of level, every PDF/A file must:
- Embed all fonts used (including standard fonts like Helvetica).
- Embed an ICC color profile for any device-dependent color.
- Include XMP metadata identifying the conformance level.
- Contain no encryption, no external references, no JavaScript, no audio/video.
- Declare a
PDF/A Identificationschema in metadata.
Miss any of these and validators like veraPDF will reject the file.
Common pipeline pitfalls
Most PDF generation libraries emit "regular" PDF by default. Turning on PDF/A mode is not always enough. Watch out for:
- System fonts silently substituted. If a template references "Arial" but the container doesn't have it, your renderer may fall back to Helvetica without embedding it.
- Images with embedded color profiles that conflict with the output intent. Mixing sRGB images in a CMYK-intent document is a fast way to fail validation.
- Transparent PNGs on PDF/A-1. A-1 forbids transparency. Flatten first, or upgrade to A-2.
- Hyperlinks with
javascript:URIs. These are stripped from templates only if you actively scrub them. - Form fields with actions. Interactive JavaScript in forms is forbidden.
A minimal generation flow
Here's a typical Node.js pipeline that produces PDF/A-2b output using a rendering service. The key steps: render, set conformance, validate, store.
tsimport { kamy } from "@kamydev/sdk"; import { writeFile } from "node:fs/promises"; const result = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2025-001", total: 1240.5 }, output: { format: "pdf", conformance: "PDF/A-2b", colorProfile: "sRGB", }, }); await writeFile("invoice.pdf", Buffer.from(await result.arrayBuffer()));
The conformance option instructs the renderer to embed fonts, attach the sRGB output intent, and write the XMP identification block. If you're rolling your own with a lower-level library, you'll need to handle each of these explicitly.
Validating output
Never trust that a document is PDF/A just because your renderer claims so. Run it through veraPDF, the reference validator maintained by the PDF Association. Wire it into CI or your generation worker:
bashverapdf --format json --flavour 2b invoice.pdf > report.json
Then fail the pipeline if there are any assertion failures:
tsimport { readFile } from "node:fs/promises"; const report = JSON.parse(await readFile("report.json", "utf8")); const job = report.report.jobs[0]; if (!job.validationResult[0].compliant) { const failures = job.validationResult[0].details.ruleSummaries .filter((r: any) => r.failedChecks > 0) .map((r: any) => `${r.specification} ${r.clause}-${r.testNumber}`); throw new Error(`PDF/A validation failed: ${failures.join(", ")}`); }
Running veraPDF inside a Docker sidecar keeps your worker image slim:
bashdocker run --rm -v "$PWD":/data verapdf/cli \ --format json --flavour 2b /data/invoice.pdf
Metadata and the XMP block
PDF/A requires an XMP metadata packet with a pdfaid namespace declaring the part and conformance. If your renderer doesn't produce it, no validator will pass the file. A minimal block looks like:
xml<x:xmpmeta xmlns:x="adobe:ns:meta/"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xml