← Back to blog

We ran an accessibility validator against our own PDFs. Everything failed.

What it actually took to make generated PDFs pass PDF/UA-1: a circular RoleMap, untagged links, a fake ICC profile, and the CI gate that keeps all of it fixed.

Most writing about PDF accessibility is either a summary of the standard or a vendor page claiming support for it. Almost nobody publishes what happens when you point a real validator at real output. So here is what happened when we pointed veraPDF at ours: all nine documents in our test corpus failed, one of them with 447 individual check failures, and one of the causes had been shipping silently for six months.

This post is the account of fixing that. The specifics are pulled from the commits, not from memory.

Why generated PDFs fail by default

A PDF is drawing instructions. "Place these glyphs at these coordinates, in this font, at this size." That is all a page content stream contains. There is no heading in it, no paragraph, no table. Those concepts existed in your template and were compiled away.

A screen reader needs exactly the things that got compiled away: which text is a heading and at what level, what order the content reads in, which cell belongs to which column header, what an image depicts. The PDF format can carry all of this, but in a separate parallel structure called the tag tree, and the generator has to build it deliberately at generation time. Most generators don't. Once the document ships without tags, the structure is gone; tools that "remediate" PDFs afterward are guessing at what the generator knew for certain and discarded.

This is the practical argument for generating structure rather than repairing it. The generator has the heading levels, the table relationships, and the reading order in hand. Recording them costs a tag tree. Reconstructing them later costs a human.

What PDF/UA-1 actually requires

PDF/UA-1 is ISO 14289-1. The parts that matter for generated documents, with the clause-derived rule IDs veraPDF reports:

  • A structure tree (StructTreeRoot) covering the document, with MarkInfo /Marked true.
  • Every content item either tagged into that tree or explicitly marked as an artifact (page furniture like running headers).
  • Standard structure roles, or a RoleMap that maps custom roles onto standard ones (7.1).
  • Table headers marked TH with a Scope (7.5-1); cells spanning columns declaring ColSpan (7.2-43).
  • List items containing only Lbl and LBody (7.2-20).
  • Figures with alternate text (7.3-1).
  • Link annotations tagged as Link structure elements (7.18.5-1) with alternate descriptions (7.18.1-2, 7.18.5-2).
  • A document language, a title that viewers display, and every font embedded with a Unicode mapping (7.21).

None of this is exotic. All of it is checkable by machine, which is the point of the next section.

The audit

In late August we rendered the five shipped templates and four HTML fixtures with tagging enabled and ran veraPDF 1.30.2 against them. Every file failed. The commit messages from that week read like a defect log, because they are one.

The circular RoleMap. The structure tree emitted a "comprehensive" RoleMap that mapped every standard role to itself: /Div /Div, /P /P, and so on. A RoleMap exists to map non-standard roles onto standard ones; mapping a standard type to itself is circular, and PDF/UA rejects it (7.1-6). Because the RoleMap sits at the root, this one wrong dictionary invalidated the entire tree: 447 failed checks on the invoice template, 334 on the report, from a single mapping. Forme only emits standard PDF 1.7 roles, so the correct RoleMap was no RoleMap at all. Deleting it took the invoice from 447 failures to the handful of real ones underneath.

Table headers without Scope. TH cells carried no Scope attribute, so a screen reader can't know whether a header labels its row or its column. Forme's header rows label the columns beneath them, so every TH now carries /Scope /Column (7.5-1).

Untagged links. Link annotations existed but weren't connected to the structure tree. The fix needs coordination across two passes over the document: the content pass tags each linked element as a Link structure element, and the annotation pass matches each annotation to its element by page and target, then wires them together both ways. An OBJR kid on the element points at the annotation, and the annotation gets a StructParent number that the parent tree maps back to the element. The annotation numbers start above the page range so the two keyspaces can't collide. Alternate descriptions (/Contents) went on every link at the same time.

List items without LBody. A tagged LI may contain only Lbl and LBody, and our list items put content directly inside LI (7.2-20). The tag builder now opens a synthetic LBody around a list item's first non-label child and closes it with the item.

Note where this one came from. It surfaced through the HTML input path, because no JSX template happened to contain a list. The JSX door could have passed validation forever without finding it. A conformance gate is only as good as the corpus it renders, and a second independent input path widens that corpus in ways the first can't. The bug was in shared serializer code the whole time; it took a different door to walk through it.

Colspan cells without ColSpan. A cell spanning two columns rendered fine but declared nothing, so the validator counted unequal columns per row (7.2-43). Layout elements now carry the span, and any cell spanning more than one column declares it.

Fonts. PDF/UA requires every font embedded, and the base-14 fonts a PDF names by reference are precisely not embedded. The fix is metric-compatible substitution: in conformance mode the engine embeds Liberation fonts, but keeps the base-14 AFM widths and encoding that layout already used. The embedding happens at PDF-write time only, so the content stream is byte-identical to the non-embedded output. Text positions are exact by construction, not by luck. Turning accessibility on does not reflow your document.

The construction has a carve-out, because Liberation's glyph advances are not perfectly identical to the AFM tables. For the handful of glyphs where they diverge by more than one thousandth of an em (macron, grave, middle dot, the divide and plus-minus signs, the straight quote, micro), the true advance is declared per glyph instead. Liberation Mono has zero divergent glyphs, so there the carve-out does nothing at all. A test pins the exact widths, both the ones that match AFM and the ones carved out.

And the ICC profile. The PDF/A side of the same audit found the best bug of the batch. The embedded sRGB profile that colour-managed every PDF/A OutputIntent was not an ICC profile. It was a Cloudflare "Just a moment…" HTML challenge page that a curl had received instead of the binary, some six months earlier, and that got compiled into every build since via include_bytes!. Its ICC "device class" field read ml><. Colour management had never worked, every PDF/A file we'd produced was non-conformant, and nothing caught it because nothing validated the output. It's a real generated profile now, there's a unit test asserting the embedded bytes parse as ICC, and the full correction is written up in the PDF/A post. But as an argument for validation it doesn't need elaborating: the file was wrong for six months and the first validator run found it in seconds.

Where it landed, and what keeps it there

All nine corpus documents pass veraPDF's PDF/UA-1 profile. The same nine also pass PDF/A-2b and PDF/A-2a rendered as archival and accessible at once, because the two standards compose. Tagged output has been on by default since 0.15.0; tagging is layout-neutral, so the only difference in an untagged render is the missing structure objects.

The part we'd emphasize over the passing itself: the validation runs in CI on every commit. The gate renders the corpus through both input paths, fails hard if veraPDF is missing rather than skipping, fails if a font warning leaks, and validates every file. The per-file results are published from CI to parity.formepdf.com, generated from the same JSON the gate produces, never hand-written. A compliance claim you can't check is marketing. This one you can check, per document, per commit.

What you still have to supply

Structure, reading order, tagging, artifact marking, tab order, and font embedding are automatic. Three things are not, because only the author knows them:

  • Alternate text for images and figures. The validator confirms alt text exists. It cannot confirm it's any good, and neither can we.
  • A document title, which viewers display instead of the filename.
  • The document language, so screen readers pick the right voice.

If a required font isn't embeddable, the render warns by name in PDF/UA mode and refuses outright in PDF/A mode rather than emitting a file whose metadata claims conformance it doesn't have.

One boundary worth stating plainly: PDF/UA is the machine-checkable layer of PDF accessibility. It overlaps with WCAG substantially, and procurement requirements usually cite WCAG, but the two are not identical and passing a validator is not a legal determination of anything. What we claim is exactly what veraPDF validates.

Using it

import { Document, Page, Text } from '@formepdf/react';
import { renderDocument } from '@formepdf/core';
import { standardFonts } from '@formepdf/fonts-standard';

const pdf = await renderDocument(
  <Document pdfUa title="Quarterly Report" lang="en-US" fonts={standardFonts()}>
    <Page size="A4" margin={54}>
      <Text>Structured from birth.</Text>
    </Page>
  </Document>,
);

From the HTML path it's pdfUa in the renderHtml options, or --pdf-ua --lang en-US on the CLI, with fonts registered the same way.

And it composes with archival: <Document pdfa="2a" pdfUa lang="en-US" fonts={standardFonts()}> produces one file that is both PDF/A-2a and PDF/UA-1, which is the configuration long-term public archives actually ask for.

Docs