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 conformance levels, common pitfalls, and how to integrate validation into your document pipeline.

pdfpdf/acompliancearchivalnode.jsdocument-generation

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:

  1. Embed all fonts used (including standard fonts like Helvetica).
  2. Embed an ICC color profile for any device-dependent color.
  3. Include XMP metadata identifying the conformance level.
  4. Contain no encryption, no external references, no JavaScript, no audio/video.
  5. Declare a PDF/A Identification schema 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.

ts
import { 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:

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

Then fail the pipeline if there are any assertion failures:

ts
import { 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:

bash
docker 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