Generating PDF/A-compliant grant packages

When a Department of Defense (DoD) Broad Agency Announcement (BAA) cites federal record-retention requirements, an ordinary PDF is not a compliant deliverable — the submission must be PDF/A, the ISO-standardized archival profile that guarantees a document will render identically decades from now without any external dependency. A package that embeds every font, carries no JavaScript, no encryption, and no reference to a resource outside its own bytes, and declares its color intent explicitly, is one that a records archive can open in 2050 exactly as a reviewer saw it today. This guide takes a merged grant package produced by the parent PDF package generation stage and normalizes it to PDF/A-1b or PDF/A-2b, then verifies the conformance markers that a validator like veraPDF will check. It is a normalization pass, not a re-authoring pass: the visible content is preserved byte-faithfully while the container is made self-sufficient and archival.

The distinction that matters for compliance is between looks fine in a viewer and is archivally conformant. A package can display perfectly and still fail PDF/A because a single font is referenced but not embedded, because a transparency group appears in a profile that forbids it, or because the document lacks the XMP metadata and OutputIntent that declare its conformance level and color space. Those are the exact defects the phases below detect and repair.

Phase 1 — What PDF/A requires, and setting up the transform

PDF/A is not one thing. The two levels a grant package will encounter are PDF/A-1b (based on PDF 1.4, the most conservative and widely accepted) and PDF/A-2b (based on PDF 1.7, which permits JPEG2000 and transparency). The b suffix means “basic” conformance — reliable visual reproduction — as opposed to a (“accessible”), which additionally requires a tagged structure tree most agencies do not demand. Aim for the lowest level a given BAA accepts, because the constraints are stricter and therefore safer as you go down.

Every PDF/A level shares a core set of hard requirements. The normalization must guarantee all of them:

  1. All fonts embedded. Every font, including every subset, must carry its glyph program inside the file. A referenced-but-not-embedded font is the single most common PDF/A failure.
  2. No encryption. PDF/A forbids any security handler; the document must be openable with no password and no permission flags.
  3. No JavaScript or executable actions. Archival documents may not carry active content.
  4. No external references. No linked fonts, no remote images, no /URI dependencies for rendered content — the file must be self-contained.
  5. An OutputIntent with an embedded ICC color profile. The document must declare the color space it was authored for, so colors reproduce deterministically.
  6. XMP metadata declaring the conformance level. A specific XMP packet (pdfaid:part and pdfaid:conformance) marks the file as claiming PDF/A, and a validator checks that the claim is true.

Pin the toolchain so a normalization run reproduces exactly when the same package is re-normalized after an edit — reproducibility here is what lets an audit prove the archived bytes match the submitted ones. pikepdf (wrapping the QPDF library) does the object-model surgery in pure Python; a Ghostscript-style rewrite is an alternative where a system binary is acceptable, but the pikepdf path keeps the transform in-process and dependency-light.

bash
python -m venv .venv
source .venv/bin/activate
pip install "pikepdf==9.4.0" "pypdf==5.1.0"

This normalization runs after assembly and before the page-limit and font enforcement re-check, so the bytes that get validated for compliance are the same archival bytes that will be submitted and retained.

Phase 2 — Normalizing a merged package to PDF/A

The transform reads the assembled package, strips everything PDF/A forbids, injects everything it requires, and writes a conformant file. The order matters: remove disallowed elements first (so nothing you add gets stripped), then attach the OutputIntent and XMP that assert conformance. The annotated function below performs the core rewrite with pikepdf.

python
import pikepdf
from pikepdf import Name, Dictionary, Array

def normalize_to_pdfa(
    src_path: str,
    dst_path: str,
    icc_profile: bytes,
    part: int = 1,               # 1 -> PDF/A-1b, 2 -> PDF/A-2b
) -> None:
    """Rewrite a merged package as PDF/A-1b or -2b (basic conformance).

    Strips encryption, JavaScript, and embedded-file actions; attaches an
    sRGB OutputIntent and a pdfaid XMP packet. Assumes fonts are already
    embedded (Phase 3 handles the exceptions) — this call asserts the
    container constraints, not glyph programs.
    """
    pdf = pikepdf.open(src_path)          # opening decrypts if openable

    # 1. Remove executable/active content the profile forbids.
    root = pdf.Root
    if "/OpenAction" in root:
        del root.OpenAction               # no auto-run action on open
    if "/Names" in root and "/JavaScript" in root.Names:
        del root.Names.JavaScript         # document-level JavaScript
    for page in pdf.pages:
        annots = page.get("/Annots")
        if annots is not None:
            for annot in annots:
                if annot.get("/A", {}).get("/S") == Name("/JavaScript"):
                    del annot["/A"]       # action-based JavaScript on a widget

    # 2. Declare the color intent: an OutputIntent with an embedded ICC stream.
    icc_stream = pdf.make_stream(icc_profile)
    icc_stream.N = 3                       # sRGB has 3 components
    output_intent = Dictionary(
        Type=Name("/OutputIntent"),
        S=Name("/GTS_PDFA1"),
        OutputConditionIdentifier="sRGB IEC61966-2.1",
        DestOutputProfile=icc_stream,
    )
    root.OutputIntents = Array([output_intent])

    # 3. Assert conformance in an XMP packet (pdfaid:part / :conformance).
    with pdf.open_metadata(set_pikepdf_as_editor=False) as meta:
        meta["pdfaid:part"] = str(part)
        meta["pdfaid:conformance"] = "B"

    # 4. Save WITHOUT encryption, linearized for archival stability.
    pdf.save(dst_path, linearize=True, encryption=False)
    pdf.close()

Three lines carry most of the conformance weight. The OutputIntents array is what makes color reproduction deterministic — without it, a validator rejects any device-dependent color as ambiguous. The pdfaid XMP keys are the machine-readable claim of conformance; a validator both reads them and checks that the rest of the file honors them, so writing them on a non-conformant file produces a worse result (a file that lies about being PDF/A) rather than a passing one. And encryption=False on save is non-negotiable: any inherited security handler voids conformance outright.

The PDF/A conformance transform, from merged package to validated archival output A merged package PDF enters a PDF/A conformance transform box. Inside the box four operations are applied in order: embed all fonts and remove Type3 references, strip JavaScript and encryption, remove external references, and set XMP metadata plus an OutputIntent. The transform emits a PDF/A-1b output document, which then flows into a veraPDF validation step that confirms the conformance markers. Merged package PDF PDF/A conformance transform 1 · Embed all fonts (no Type3 refs) 2 · Strip JavaScript, encryption 3 · Remove external references 4 · Set XMP + OutputIntent PDF/A-1b output veraPDF validation
Normalization strips forbidden elements and injects the color intent and conformance metadata before an independent validator confirms the result.

Phase 3 — Edge cases and agency-specific overrides

The core transform assumes a well-behaved package. Real merged packages carry defects that the basic rewrite cannot silently fix, and a few of them require decisions rather than code:

  • Transparency in PDF/A-1. PDF/A-1b is based on PDF 1.4 and forbids transparency groups and soft masks. A figure exported from a modern tool often carries a transparency group. Either flatten the transparency (rasterize or composite the affected content) before normalizing, or target PDF/A-2b, which permits it. Choosing the target level is the cleaner fix when the BAA accepts -2b.
  • Un-embeddable Type3 or protected fonts. A Type3 font defines glyphs as PDF content streams and cannot always be embedded like a TrueType subset; some commercial fonts carry no-embed licensing flags. Neither can be made conformant by the container rewrite. The resolution is upstream: re-render the offending component with an embeddable typeface (the approved agency font set is safe) rather than trying to force the embed.
  • Color spaces and OutputIntent mismatch. If page content uses a CMYK or device-dependent color while the OutputIntent declares sRGB, a strict validator flags the inconsistency. Normalize page color to match the declared intent, or declare the intent that matches the content — but declare exactly one and make the content honor it.
  • Agency overrides on whether PDF/A is required at all. PDF/A is not a universal grant requirement. It is authoritative only when the specific announcement demands it, and the requirement is per-BAA rather than agency-wide. Read the conformance requirement from the opportunity record — the same source-of-record discipline the enforcing NIH 12-page limit rules checks apply to page ceilings — and skip normalization where it is not mandated, because a needlessly flattened package can lose fidelity.
Conformance concern PDF/A-1b PDF/A-2b Typical grant trigger
Base PDF version PDF 1.4 PDF 1.7 Chosen by the BAA’s cited standard
Transparency allowed No Yes Modern figure exports
JPEG2000 images allowed No Yes High-resolution imaging attachments
Font embedding Required Required Every package
Encryption Forbidden Forbidden Owner-locked letters of support
When required Federal record retention Same, with richer content Per-BAA statement, not agency-wide

Phase 4 — Validation and conformance verification

A claim of PDF/A means nothing until an independent validator confirms it. The archival-industry reference is veraPDF, which parses the file against the ISO rule set and reports every failed clause; a grant pipeline should treat a non-empty failure list as a hard block on submission. Where running the veraPDF binary is impractical, assert the presence and correctness of the conformance markers directly on the produced bytes — this catches the common failures (missing OutputIntent, absent XMP claim, residual encryption) even without the full rule engine.

python
import pikepdf

def assert_pdfa_markers(pdf_path: str, expected_part: str = "1") -> None:
    """Confirm the archival conformance markers on a normalized package.

    A pragmatic stand-in for a full veraPDF run: verifies the pdfaid XMP
    claim, an OutputIntent with an embedded profile, and the absence of
    encryption. A full validator additionally checks per-object rules.
    """
    with pikepdf.open(pdf_path) as pdf:
        assert not pdf.is_encrypted, "PDF/A forbids encryption"

        intents = pdf.Root.get("/OutputIntents")
        assert intents and len(intents) >= 1, "missing OutputIntent"
        assert "/DestOutputProfile" in intents[0], "OutputIntent has no ICC profile"

        with pdf.open_metadata() as meta:
            assert meta.get("pdfaid:part") == expected_part, "wrong/absent PDF/A part"
            assert meta.get("pdfaid:conformance") == "B", "not basic conformance"

Pair that with a pytest suite that runs the transform on a fixture package and checks each marker, plus a veraPDF-style acceptance checklist for a reviewer:

python
import pytest
import pikepdf

def test_output_is_not_encrypted(tmp_path, sample_package, srgb_icc) -> None:
    out = tmp_path / "archival.pdf"
    normalize_to_pdfa(sample_package, str(out), srgb_icc, part=1)
    assert not pikepdf.open(str(out)).is_encrypted

def test_conformance_markers_present(tmp_path, sample_package, srgb_icc) -> None:
    out = tmp_path / "archival.pdf"
    normalize_to_pdfa(sample_package, str(out), srgb_icc, part=1)
    assert_pdfa_markers(str(out), expected_part="1")   # raises on any gap

def test_no_document_javascript(tmp_path, sample_package, srgb_icc) -> None:
    out = tmp_path / "archival.pdf"
    normalize_to_pdfa(sample_package, str(out), srgb_icc, part=1)
    with pikepdf.open(str(out)) as pdf:
        names = pdf.Root.get("/Names", {})
        assert "/JavaScript" not in names

The manual checklist catches what marker assertions miss: run the file through veraPDF and confirm zero failed clauses; open it in a viewer and confirm every glyph renders (proving fonts embedded, not merely claimed); confirm figures reproduce in the declared color space; and confirm the file opens with no password prompt. Only a package that clears both the automated markers and the validator should be recorded to the audit logging and provenance log as the archival copy of record, and only then handed back to submission.

Frequently asked questions

Do all federal grant submissions need to be PDF/A?

No. PDF/A is required only when a specific announcement cites it, most often a Department of Defense (DoD) Broad Agency Announcement (BAA) invoking federal record-retention requirements. National Institutes of Health (NIH) and National Science Foundation (NSF) submissions generally require embedded fonts and no encryption, but not full PDF/A conformance. Read the requirement from the opportunity record and normalize only when it is mandated, because flattening a package that did not need it can lose fidelity for no benefit.

What is the difference between PDF/A-1b and PDF/A-2b for a grant package?

Both guarantee basic visual conformance with embedded fonts, no encryption, and a declared color intent. PDF/A-1b is built on PDF 1.4 and forbids transparency and JPEG2000, so it is the most conservative and widely accepted. PDF/A-2b is built on PDF 1.7 and permits transparency and JPEG2000, which matters when a package includes modern figure exports or high-resolution imaging. Target the lowest level the announcement accepts, and move up to -2b only when your content genuinely needs its allowances.

Why does writing the pdfaid XMP metadata not make a file conformant on its own?

The XMP pdfaid keys are a claim of conformance, not a guarantee of it. A validator reads the claim and then checks that the rest of the file honors every PDF/A rule — fonts embedded, no encryption, an OutputIntent present, no forbidden content. Writing the claim onto a non-conformant file produces a document that lies about itself, which a validator flags as a failure. The metadata must be the last step after the file already satisfies the constraints.

How do I handle a font that refuses to embed?

A Type3 font or a commercial font with no-embed licensing flags cannot be fixed by the container rewrite. The resolution is upstream: re-render the component that uses it with an embeddable typeface — the approved agency font set is always safe — then re-run the merge and normalization. Trying to force the embed produces either an error or a subset missing glyphs, both of which fail validation.

Do I still run the page-limit and font checks after PDF/A normalization?

Yes. Normalization can change bytes — flattening transparency, re-embedding fonts, rewriting the object stream — so the compliance engine must re-validate the final archival file, not the pre-normalization package. Run the enforcement checks on exactly the bytes that will be submitted and retained, so the archived copy is provably the one that passed.

Up one level: PDF Package Generation