Home  ›  Guides  ›  PDF/A

PDF/A conformance in pdfnative

PDF/A-1b, 2b, 2u and 3b via layout: { tagged: … }, validated against the veraPDF reference validator in CI. Since v1.7.0, a configuration that would break the declared level surfaces a diagnostic — console.warn by default, a custom onDiagnostic sink, or a thrown error under strict: true.

PDF/A is the ISO archival profile for PDF (ISO 19005). pdfnative supports PDF/A-1b, 2b, 2u, and 3b via the tagged build option. This guide explains what works today, what's still in flight, and how to validate your output against the official reference validator.

TL;DR#

import { buildPDFBytes } from 'pdfnative';

const pdf = buildPDFBytes(params, { tagged: true });        // PDF/A-2b (default)
const pdf1b = buildPDFBytes(params, { tagged: 'pdfa1b' });  // PDF/A-1b
const pdf2u = buildPDFBytes(params, { tagged: 'pdfa2u' });  // PDF/A-2u
const pdf3b = buildPDFBytes(params, { tagged: 'pdfa3b' });  // PDF/A-3b + attachments

tagged alone is not enough: embed a font#

ISO 19005 requires every font in the file to be embedded. tagged writes the XMP conformance declaration, the structure tree and the output intent — but it does not embed a font for you. If your document uses only the viewer's built-in fonts (which is what happens when you pass no fontEntries), pdfnative writes a file that claims PDF/A while referencing non-embedded Helvetica, and veraPDF will reject it.

Pass at least a Latin font whenever you set tagged:

import { buildDocumentPDFBytes, registerFonts, loadFontData } from 'pdfnative';

registerFonts({ latin: () => import('pdfnative/fonts/noto-sans-data.js') });
const fontData = await loadFontData('latin');

// fontRef becomes a PDF resource name, so it needs the leading slash.
// /F1 and /F2 are reserved by the engine — start at /F3.

const pdf = buildDocumentPDFBytes(
  { title: 'Archival', blocks, fontEntries: [{ fontData, fontRef: '/F3', lang: 'latin' }] },
  { tagged: 'pdfa2b' },
);

Since v1.7.0 the builders guard this declaration themselves: requesting any 'pdfa*' level (or tagged: true) with no fontEntries emits the PDFA_NO_FONT_ENTRIES diagnostic — a console.warn by default, or a thrown Error under strict: true, before any output bytes are produced. See Conformance diagnostics below.

The sample generators in scripts/generators/ all do this, which is why the veraPDF CI job passes. If you author through pdfnative-react, its lintDocument() rule L_TAGGED_NO_FONTS still catches the same trap earlier, at the document-model level — a complementary check to the core diagnostic.

Every output written with tagged set ships:

Conformance diagnostics (v1.7.0)#

Configurations that produce a PDF/A claim veraPDF would reject no longer fail silently. Both builders (buildPDFBytes and buildDocumentPDFBytes) surface them through a single diagnostics channel:

Code Trigger
PDFA_NO_FONT_ENTRIES A 'pdfa*' level (or tagged: true) requested with no fontEntries — the file would claim PDF/A while referencing unembedded standard-14 Helvetica (ISO 19005 §6.2.11.4.1).
PDFA_DEVICE_CMYK_IMAGE A DeviceCMYK image embedded under a PDF/A claim with an sRGB OutputIntent (ISO 19005-2 §6.2.4.3).
PDFA_UNEMBEDDED_FORM_FONT AcroForm fields under a PDF/A claim — form appearances render through an unembedded base-14 /Helv font (same §6.2.11.4.1 rule). Flatten the form or drop the level.

By default each diagnostic is a console.warn, deduplicated once per code per build. Two layout options change that:

import { buildPDFBytes, type PdfDiagnostic } from 'pdfnative';

// CI / tests: escalate to a thrown Error, before any bytes are produced.
buildPDFBytes(params, { tagged: 'pdfa2b', strict: true });

// Custom sink — receives every diagnostic (no deduplication).
// Pass () => {} to silence entirely.
const diagnostics: PdfDiagnostic[] = [];
const pdf = buildPDFBytes(params, {
  tagged: 'pdfa2b',
  onDiagnostic: (d) => diagnostics.push(d),
});

Each PdfDiagnostic carries a machine-readable code, a severity ('warning'), and an actionable message that includes the remedy. onDiagnostic is ignored when strict is set — diagnostics throw instead. The code list is a stable, additions-only union (PdfDiagnosticCode), so a sink written today keeps compiling as future codes are added.

On the MCP surface (pdfnative-mcp 1.6.0), the same honesty is exposed as three opt-in inputs on every document tool: embedFonts: true embeds Noto Sans Latin so a PDF/A claim on base-14 text is actually accepted by veraPDF, strict: true fails instead of producing a non-conformant file, and includeDiagnostics: true echoes the engine's diagnostics (PDFA_NO_FONT_ENTRIES, PDFA_UNEMBEDDED_FORM_FONT, PDFA_DEVICE_CMYK_IMAGE) in the tool result. Known engine limitation: add_form (or a formField block) under a PDF/A claim still fails veraPDF even with embedFonts — the AcroForm /DR /Helv font is an unembedded Type1 (ISO 19005-2 §6.2.11.4.1).

v1.1.0 status — fully validated#

v1.1.0 ships full PDF/A-1b / 2b / 2u / 3b conformance against the official veraPDF reference validator. The validator runs as a blocking check on every PR (see .github/workflows/verapdf.yml).

Rule Status Fixed in
ISO 19005-1 §6.1.3 — trailer /ID always present v1.0.4
veraPDF 6.7.3 t1 — CreationDatexmp:CreateDate parity v1.0.4
veraPDF 6.7.3 t1 — dc:title/Info /Title parity v1.1.0
veraPDF 6.7.3 t4 — dc:description/Info /Subject parity v1.1.0
veraPDF 6.7.3 t5 — pdf:Keywords/Info /Keywords parity v1.1.0
veraPDF 6.7.3 — dc:creator/Info /Author parity v1.0.4
ISO 19005-1 §6.3.4 — Latin font embedding v1.1.0
ISO 19005-2 §6.2.11.4.1 — Type0 font references v1.1.0
veraPDF 6.2.3.3 — DeviceRGB OutputIntent v1.0.4

To produce a strictly veraPDF-compliant PDF/A document, register the Latin font module and resolve it. Registering alone is not enough: the builders are synchronous and cannot await a loader, so the font never reaches the document and you get an unembedded Helvetica reference — verified: 0 embedded fonts.

import { registerFonts, loadFontData, buildPDFBytes } from 'pdfnative';

registerFonts({ latin: () => import('pdfnative/fonts/noto-sans-data.js') });
const fontData = await loadFontData('latin');

const pdf = buildPDFBytes(
  { ...params, fontEntries: [{ fontData, fontRef: '/F3', lang: 'latin' }] },
  { tagged: true },
);

Without the 'latin' font registered, pdfnative falls back to the unembedded Helvetica standard-14 references for byte-stable v1.0.x output — convenient for non-archival rendering but invalid under PDF/A.

Validating your output#

pdfnative ships a thin wrapper around the official veraPDF CLI:

# 1. Generate the sample suite (writes test-output/)
npm run test:generate

# 2. Run veraPDF against every PDF/A-claiming sample
npm run validate:pdfa

The script auto-detects veraPDF on $PATH or via the VERAPDF_HOME env var. If veraPDF is not installed it exits 0 with install instructions — local development never blocks. CI installs veraPDF deterministically (pinned to 1.30.2) and runs the same script on every engine PR — see .github/workflows/verapdf.yml — and again as a blocking gate before every npm publish.

Detection is automatic and guarded: every sample declaring pdfaid:part — currently the 18 PDF/A-claiming samples — is validated without any registration, and a coverage canary fails the run if the detected count drifts from declared.pdfaSamples in docs/assets/ecosystem.json (bump it when adding or removing a claiming sample; a mismatch with no sample change means detection or generation regressed).

Which samples are subject to PDF/A validation? Only files that declare a conformance level in their XMP (pdfaid:part) — the ones built with tagged: 'pdfa…', e.g. the pdfa/ and pdfa-latin/ sample categories. The script skips everything else. Most showcase samples (bidi/, alphabet/, charts/, …) intentionally make no PDF/A claim: they have no XMP packet at all, so force-validating one against a PDF/A profile in the veraPDF GUI fails by construction (missing /Metadata, uncalibrated DeviceRGB, …) — that is expected and not a defect.

veraPDF is invoked as an external Java tool. pdfnative remains a zero-runtime-dependency library; veraPDF is never bundled, linked, or required by consumers of the npm package.

Installing veraPDF locally#

veraPDF is a Java application. Pick whichever path matches your OS. After install, either expose verapdf on $PATH or set the VERAPDF_HOME environment variable to the install directory.

macOS — Homebrew cask:

brew install --cask verapdf
verapdf --version

Linux — official installer (headless):

curl -fsSL -o verapdf-installer.zip https://software.verapdf.org/rel/verapdf-installer.zip
unzip verapdf-installer.zip
java -jar verapdf-izpack-installer-*.jar -console
# follow the prompts; defaults are sane
export VERAPDF_HOME="$HOME/verapdf"
export PATH="$VERAPDF_HOME:$PATH"

Windows — official GUI installer from https://docs.verapdf.org/install/, or the same headless install CI uses (download the pinned zip, run java -jar verapdf-izpack-installer-*.jar auto-install.xml — the descriptor is shown in CONTRIBUTING). After install, either add the install directory to PATH or:

$env:VERAPDF_HOME = "C:\Program Files\verapdf"
$env:Path += ";$env:VERAPDF_HOME"
verapdf.bat --version

The .bat launcher is fully supported by npm run validate:pdfa since v1.7.0 (it is spawned through a shell, as recent Node versions require for batch files).

No install at all? Drop the file into the official online demo at https://demo.verapdf.org. It validates against the same engine, which is convenient for ad-hoc checks but does not scale to a CI suite.

Troubleshooting#

"My PDF fails veraPDF for missing XMP / DeviceRGB / unembedded font, but I never asked for PDF/A."

If the file was generated without tagged: true, it is a plain ISO 32000-1 document and should not be validated against any PDF/A profile. The veraPDF online demo lets you pick a profile manually, which will then surface failures by design — every PDF/A rule about metadata, output intents, font embedding, transparency, and color spaces will fire because the file never claimed any of those things.

The npm run validate:pdfa wrapper avoids this trap: it scans each PDF for a pdfaid:part declaration in the XMP packet and skips files that don't claim PDF/A. The summary line reports how many were skipped:

Scanned 242 PDF(s); 18 claim PDF/A, 224 skipped (not PDF/A).

If you want a file to be validated, generate it with tagged: true (or any 'pdfa*' value).

"My tagged file still fails rule 6.3.4 (font embedding)."

Register the Latin font module and pass the resolved data as fontEntries (see the TL;DR above). Registration on its own does nothing for the synchronous builders. Without it, pdfnative emits Helvetica as an unembedded standard-14 reference for byte-stable v1.0.x output. With it, every glyph used in the document is embedded as CIDFontType2 / FontFile2 — see the v1.1.0 status table above. Since v1.7.0 this configuration also emits the PDFA_NO_FONT_ENTRIES diagnostic at build time (a thrown error under strict: true), so the failure surfaces before veraPDF ever runs.

Output bytes change in v1.0.4#

v1.0.4 has no public API break, but PDF outputs differ byte-for-byte from v1.0.3:

If your test fixtures snapshot full PDF bytes, regenerate them.

PDF/A vs encryption#

ISO 19005-1 §6.3.2 forbids combining PDF/A with PDF encryption. pdfnative validates this at the build boundary — passing both tagged: … and encryption: … in the same call throws.

Choosing a flavour#

Flavour Base PDF Notes
PDF/A-1b ('pdfa1b') PDF 1.4 Most conservative — required by some legacy archival systems. No transparency, no JPEG2000, no AES.
PDF/A-2b (true / 'pdfa2b') PDF 1.7 Default. Allows transparency, layers, embedded TrueType.
PDF/A-2u ('pdfa2u') PDF 1.7 2b + Unicode mapping for every glyph. Required when /ActualText and ToUnicode CMap completeness matter (recommended for accessibility).
PDF/A-3b ('pdfa3b') PDF 1.7 2b + arbitrary /EmbeddedFile attachments (XML, source data, etc.).

All four flavours share the same XMP / OutputIntent / structure-tree infrastructure — pdfnative only varies the PDF version, the pdfaid:part, and the pdfaid:conformance value.

Canonical list for tooling#

The four legal tagged strings are also exposed as a typed constant for tooling that needs to populate a JSON-schema enum: (pdfnative-mcp does this for its MCP tool descriptions so Gemini-CLI and other agents can autocomplete the right value):

import { PDF_A_CONFORMANCE_TARGETS, type PdfAConformanceTarget } from 'pdfnative';

PDF_A_CONFORMANCE_TARGETS;
// → readonly ['pdfa1b', 'pdfa2b', 'pdfa2u', 'pdfa3b']

function isValidTarget(s: string): s is PdfAConformanceTarget {
  return (PDF_A_CONFORMANCE_TARGETS as readonly string[]).includes(s);
}

The constant is the single source of truth — adding a new target in a future minor release will surface automatically in every downstream consumer that imports it.

Hard invariants for contributors#

These rules are documented in the contributor instruction file .github/instructions/pdfa-conformance.instructions.md:

See also#