Guides · accessibility
HTML to PDF accessibility checklist — what to change in the HTML, what changes nothing, and where HTML stops being able to help
Published 2026-08-17 · every "does / does not" on this page comes from a run of veraPDF 1.30.2 against twelve variants of one document rendered by Chromium 151.0.7922.34, not from an accessibility habit. Where a recommendation is not backed by one of those runs, it says so and names the success criterion it comes from instead.
If you generate PDFs from HTML, the accessibility of the result is decided in two places and you only control one of them. You control the markup and the render options. Everything else — the structure tree, the annotation dictionaries, the metadata packet — is written by the renderer, and a good deal of what accessibility guidance tells you to do in HTML turns out to have no effect on any of it.
So this is a checklist sorted by measured effect, into three groups: the changes that move a PDF/UA-1 validator, the changes that are invisible to it but decide whether a person can read the document, and the four popular fixes that measurably do nothing at all.
The sixty-second version
| In the HTML | What it does to the PDF | WCAG |
|---|---|---|
Render with tagged: true (a render option, not markup — but nothing
below matters without it) |
9 failed rules → 4. Measured. An untagged PDF has no structure for any of the rest of this to attach to | 1.3.1 |
A non-empty <title> |
Clears clause 7.1 test 10. Removing it is the only single change measured that adds a title failure | 2.4.2 |
alt on every <img> |
Clears 7.3-1. alt="" does not mean "decorative" here — it fails a
different rule instead |
1.1.1 |
<th scope>, heading levels without gaps, real
<ul>/<ol> |
Nothing the validator can see — a header-less data table passes. It is still the difference between a usable table and an unusable one | 1.3.1, 2.4.6 |
lang="…" on <html> |
Nothing. Omit it and the document still passes, because Chromium
writes /Lang from its own locale. That is a false negative, not a pass |
3.1.1, 3.1.2 |
<a title="…"> or <a aria-label="…"> |
Nothing at all. Byte-for-byte the same two failures as a bare link.
The key it needs, /Contents, has no HTML expression |
2.4.4 |
| Colour and contrast in the CSS | Nothing a PDF/UA validator will ever mention — PDF/UA-1 has no contrast requirement. It is still a Level AA criterion | 1.4.1, 1.4.3 |
| — not expressible in HTML — | XMP packet, link /Contents, role map. About thirty lines of post-processing;
section 6 |
n/a |
1. Start by not doing the work Chromium already did
The most common way to waste a week on this is to start from a generic PDF accessibility
checklist and re-implement things the renderer writes for you. Measured on a realistic
government-style notice — headings, a paragraph, an image with alt, a table with
<caption> and <th scope>, a list — a Chromium PDF printed with
tagged: true passes 102 of the 106 PDF/UA-1 rules out of the box.
Already written for you, without any special effort in the HTML: the structure tree itself,
/Alt on figures from your alt attributes, table header cells and their scope,
list structure, heading levels, the document title, /Lang, and
/ViewerPreferences /DisplayDocTitle true.
The measurement behind that number —
which four rules fail and what each costs — is the companion article to this one.
2. The switch that is not markup at all
Before any of the markup advice below is worth anything:
page.pdf({ format: 'A4', tagged: true, outline: true, printBackground: true })
Without tagged: true the output has no structure tree, and the same document fails
9 rules and 78 checks instead of 4 and 4. Every recommendation on this page is
about what goes into that structure tree; with the switch off there is nothing to put
anything into. It is one word, it is not the default in Puppeteer or Playwright, and it is the
single highest-value change available to you.
3. The twelve variants, sorted by verdict
This is the evidence the rest of the page is built on. Twelve copies of the same document,
one attribute or tag changed in each, each run asserting its own h1 before printing so
that a mis-served fixture cannot masquerade as a finding:
twelve variants of one page, one thing changed at a time, sorted by what
the change did. All rendered page.pdf({tagged:true, outline:true}) and
validated against the same 106 PDF/UA-1 rules.
MOVES THE VALIDATOR
L no <title> element 2 rules 7.1-10 + 7.1-8
H <img> with no alt 2 rules 7.3-1 + 7.1-8
I <img alt=""> 2 rules 7.1-3 + 7.1-8
B a <strong> anywhere in the body 2 rules 7.1-5 + 7.1-8
C an <em> anywhere in the body 2 rules 7.1-5 + 7.1-8
A an <a href> anywhere in the body 3 rules 7.18.1-2 + 7.18.5-2 + 7.1-8
CHANGES NOTHING THE VALIDATOR CAN SEE
E the same link, with title="..." 3 rules identical to A
F the same link, with aria-label="..." 3 rules identical to A
G no lang attribute at all 1 rule 7.1-8 only -> "passes"
J data table whose header row is <td> 1 rule 7.1-8 only -> "passes"
D <b> <i> <code> <sub> <small> 1 rule 7.1-8 only -> "passes"
THE FLOOR
K lang + title + alt + th, no link 1 rule 7.1-8
Every row fails 7.1-8. There is no HTML that removes it: 7.1-8 is the
missing XMP metadata packet, and Chromium never writes one.
Read the middle group again. Five of the twelve changes — including three that appear on every accessibility checklist on the web — are invisible to the validator, and two of them produce a better validator result than doing the right thing.
4. The changes that move the validator
Four HTML-side faults, each with the clause it produces.
A missing document title — clause 7.1 test 10
Variant L: delete the <title> element and the PDF gains a failure. Chromium
writes the title into the document information dictionary and sets
/ViewerPreferences /DisplayDocTitle true; with no <title> there is
nothing to write. WCAG 2.0 2.4.2 Page Titled (Level A) asks for titles "that
describe topic or purpose", which is the part the validator cannot check — it can see that a
title exists, not that it means anything. "Invoice" passes both and helps nobody;
"Invoice 2026-0417, Riverside Clinic" is the same one line of HTML.
A missing alt — clause 7.3 test 1
Variant H: an <img> with no alt produces a /Figure structure
element with no /Alt entry, which is the one PDF/UA rule most people already know
about. WCAG 1.1.1 Non-text Content (A) wants a text alternative that "serves
the equivalent purpose" — for a chart that is the finding, not the word "chart".
An empty alt is not what you think — clause 7.1 test 3
Variant I is the surprise. alt="" is the standard HTML way to say "this image is
decorative, skip it", and in a PDF it does not produce a decorative artifact. It produces
untagged real page content, which fails a different rule, 7.1-3.
So the two ways of handling an image both fail, in different places, and only a real
description passes.
alt="" does not become a PDF artifact. We did not
measure whether a CSS background-image, a role="presentation" or an
aria-hidden image does — so this page does not tell you that any of them works. If
genuinely decorative images matter to your document, marking them as artifacts is a
post-processing step until somebody measures otherwise.<strong> and <em> — clause 7.1 test 5, and the one to ignore
Variants B and C. <strong> and <em> produce
Strong and Em structure elements, which are not standard PDF structure types,
and Chromium does not add them to the role map. Variant D — <b>,
<i>, <code>, <sub> and <small> all on
one page — produces plain Span and passes. The presentational tags are clean and the
semantic ones are not, which is exactly backwards from what fifteen years of guidance predicts.
Do not act on this one in the HTML. It is the only row in the table where
the right response is to leave the markup alone: two lines in a role map fix it after the fact
(section 6), and rewriting your templates to use <b> everywhere would trade a
cosmetic validator failure for worse HTML on the web page the PDF came from.
5. The changes the validator cannot see — which is most of them
This section is the reason a checklist that only chases validator output produces documents nobody can use. Every item here was measured to have no effect on the PDF/UA-1 result, and every one of them is a WCAG success criterion that a reviewer or a reader will notice.
| Do it anyway | Why the validator is silent | WCAG |
|---|---|---|
lang="…" on <html>, and on any passage in another
language |
Measured, variant G. Omitting it passes — Chromium fills
/Lang from its own locale, and the rule asks only that /Lang be present
and well formed, not that it be true. A Chinese notice rendered on an
en-US machine validates clean and tells a screen reader to pronounce it as
American English |
3.1.1 (A), 3.1.2 (AA) |
<th scope="col"> on real header cells |
Measured, variant J. A data table whose header row is plain
<td> passes. PDF/UA-1's machine-checkable rules do not require header cells
to exist. A screen-reader user moving cell by cell is simply told nothing about which
column they are in |
1.3.1 (A) |
| Heading levels that descend without gaps, and say something | Derived from the criterion, not measured. An h1 followed by an
h3 is legal PDF and a broken outline — and the same tree feeds the bookmarks
panel. 2.4.6 asks that headings "describe topic or purpose" |
1.3.1 (A), 2.4.6 (AA) |
Real <ul>, <ol>, <dl> rather
than paragraphs starting with a bullet character |
Derived from the criterion, not measured. 1.3.1 asks that structure "can be
programmatically determined"; a • in a text run cannot |
1.3.1 (A) |
| Link text that says where it goes | PDF/UA's link rules are about the annotation's /Contents key — a different
question from whether the words make sense. "Click here" satisfies the standard once
/Contents exists, and fails the reader |
2.4.4 (A) |
| Contrast, and never colour as the only cue | PDF/UA-1 contains no contrast requirement at all — that absence is the Access Board's stated reason for refusing it as a sole standard for 508. Your print stylesheet is where this is decided | 1.4.1 (A), 1.4.3 (AA) |
| A reading order that matches the reading order | Not measured — check it yourself. The tag tree's order is what assistive technology follows, and multi-column layouts, floats and absolutely positioned elements are where visual order and document order come apart. We did not measure which one Chromium tags from; 1.3.2 is about the one a person needs | 1.3.2 (A) |
6. The four fixes that measurably do nothing
Negative results are worth more than positive ones here, because every one of these is what a search result or an autocomplete will suggest.
<a title="Read the plan">— variant E. Three failed rules, the same three as a bare link, on the same objects.<a aria-label="Read the plan">— variant F. Identical. ARIA is a mapping into an accessibility tree; a link annotation's/Contentsis a key in a PDF dictionary, and Chromium does not carry one into the other.- Leaving
langoff — variant G. This one does not merely fail to help, it actively hides a defect: the document passes while declaring the wrong language. - A data table with no
<th>— variant J. Also passes.
title and
aria-label on link elements. That is not a finding about ARIA in general, and this
page does not make one — aria-* attributes elsewhere in a document were not part of
the run. What is safe to conclude is narrower and more useful: an HTML attribute reaches
the PDF only if the renderer has been written to carry it there, and for link
annotations neither of the two obvious candidates is.7. Where HTML stops, exactly
Take the skeleton below — every recommendation from sections 4 and 5 applied, nothing left to fix in the markup:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notice of Public Hearing — 14 March 2026</title>
</head>
<body>
<h1>Notice of Public Hearing</h1>
<p>The board will meet on <b>14 March</b> to hear comment on the
draft transit plan. <a href="/plan.pdf">Read the draft plan</a>.</p>
<h2>Attending in person</h2>
<ul><li>Doors open 18:00</li><li>Comment cards close 18:45</li></ul>
<h2>Comment periods</h2>
<table>
<caption>Comment periods by district</caption>
<thead><tr><th scope="col">District</th>
<th scope="col">Closes</th></tr></thead>
<tbody><tr><td>North</td><td>28 February</td></tr>
<tr><td>South</td><td>7 March</td></tr></tbody>
</table>
<h2>Map</h2>
<img src="map.png" alt="The hearing is at 40 Market Street, on the
corner of Market and Third, two blocks north of Central station">
</body>
</html>
Render it tagged and validate it:
$ node render.mjs notice.html notice.pdf # page.pdf({tagged:true, outline:true})
$ verapdf --flavour ua1 --format text -v notice.pdf
FAIL notice.pdf ua1
FAIL 7.18.1-2 <- the <a href>: link annotation has no /Contents
FAIL 7.18.5-2 <- the same link, the other clause about it
FAIL 7.1-8 <- no XMP metadata packet in the catalog
Three rules, and not one of them has an HTML expression. That is the
whole point of this page: good HTML gets you here and stops.
Three failures, and no amount of further HTML removes any of them. Two are the link annotation, which needs a key that has no HTML expression. The third is in every one of the twelve variants: Chromium never writes an XMP metadata packet, so there is nothing in the file that claims PDF/UA-1 conformance, and a document that makes no claim is not conformant no matter how it was authored.
The remedy is a post-processing step, and it is smaller than it sounds. Three edits, in about thirty lines of pikepdf:
- Write an XMP metadata stream into the catalog carrying
pdfuaid:part = 1and adc:title. Clears 7.1-8. - Set
/Contentson every/Linkannotation to a real description of the destination — this is the text that gets read aloud. Clears 7.18.1-2 and 7.18.5-2. - Add
/Strongand/Emto the structure tree's/RoleMap, mapped to/Span. Clears 7.1-5, and lets you keep writing semantic HTML.
The working code — transcribed from the run, not reconstructed — is in the companion article, along with the XMP packet in full. Re-validated afterwards:
$ verapdf --flavour ua1 --format text notice.pdf
PASS notice.pdf ua1
measured on the three fixtures, before and after:
v-K-clean.pdf FAIL, 1 rule -> compliant, 0 failed, 646 checks passed
v-A-full.pdf FAIL, 3 rules -> compliant, 0 failed, 793 checks passed
v-B-strong.pdf FAIL, 2 rules -> compliant, 0 failed, 769 checks passed
All three fixtures come back compliant = true with zero failed checks. So the honest
shape of the whole job is: markup gets you from nine failed rules to one or three, and
about thirty lines of PDF-layer work closes the rest. There is no arrangement of HTML
that skips the second half.
pdfuaid:part is a claim, not a fix. It takes three lines and
every downstream tool and procurement checklist will believe it. A file carrying that
identifier while failing the criteria in section 5 is worse than a file with no claim
at all, because it stops the next person from looking. Stamp it when the work is finished —
including the part no machine checks.8. The two lines, side by side
| HTML can decide this | Only the PDF layer can |
|---|---|
| The document title, and whether it means anything | The XMP metadata packet and the pdfuaid:part identifier — the difference
between "validates" and "makes no claim" |
| Text alternatives on images | A link annotation's /Contents description — title and
aria-label both measured as no-ops |
| Heading structure, list structure, table header cells and scope | The role map entry that makes Strong and Em standard structure
types |
| The declared language of the document and of individual passages | Marking a decorative image as an artifact — no HTML we measured produces one |
| Contrast, and whether colour is the only cue | Anything else your reviewer's tooling asks for that the renderer does not emit |
The left column is a code review. The right column is a build step. Teams that treat the whole problem as the left column ship documents that fail on the identifier; teams that treat it as the right column ship documents that validate and cannot be read.
9. What no checklist on this page can check
Six of the seven rows in section 5 are things a person has to look at, and a validator run is where that work starts rather than where it ends. The validator article carries the full list — nine checks, roughly twenty minutes for a short document — along with the install, the flag that decides whether you measured accessibility or archiving, and the exit codes for a CI gate. If what brought you here is a contract clause rather than a bug, Section 508 asks for WCAG 2.0 Level A and AA, not for PDF/UA — which changes what the checklist above is for.
10. Where snapdok.io stands, plainly
Measured the same day as everything else on this page: our render path calls
page.pdf({ format:'A4', printBackground:true, … }) with no tagged
option, and our API exposes no parameter for it. So the PDFs we return today are
untagged — the nine-failed-rules row — and we write no XMP packet and make no conformance claim
of any kind.
If accessible PDF is the requirement in front of you, driving Chromium yourself with
tagged: true plus the post-processing in section 7 is the shorter path today, and a
vendor with documented PDF/UA output is a fair thing to shop for. What
snapdok.io is good at is the neighbouring problem: rendering a page faithfully,
fonts and layout intact, without running a browser in your own infrastructure.
The short version
Turn on tagged: true — that alone takes a document from nine failed PDF/UA-1 rules
to four. Write a real <title> and a real alt on every image, and know
that alt="" fails a different rule rather than meaning "decorative". Keep writing
<strong> and <em>; fix them in a role map instead of in your
templates. Then accept that lang, <th scope>, heading levels, list
structure, link wording and contrast are all invisible to the validator and are most of what
decides whether the document is usable — do them because WCAG 2.0 asks for them, not because a
tool will complain. Stop expecting title= or aria-label= to describe a link
in the PDF; neither does, measurably. And budget for the thirty lines of post-processing,
because the XMP packet that turns a good document into a conformant one cannot be written in
HTML at all.
The rest of this line:
what tagged: true actually
produces — 102 of 106 rules, and the four that fail —
whether Section 508 requires PDF/UA, which it
does not, and
how to run the validator and read its report.
From the same measurements next door:
why outline: true can return a
byte-identical PDF.
Sources and versions. Success criteria are quoted from WCAG 2.0 (W3C Recommendation) and re-read there on 2026-08-17; the levels shown are WCAG's own. Clause numbers are ISO 14289-1 (PDF/UA-1) as veraPDF 1.30.2's validation profile states them. The twelve-variant matrix, the check counts and the before/after stamping results were measured 2026-08-17 against Chromium 151.0.7922.34 via Playwright 1.62.1 and post-processed with pikepdf 10.11.0 — the harness is described in the companion article. The statement that PDF/UA-1 carries no contrast requirement is the U.S. Access Board's, in the preamble to the final ICT Accessibility 508 Standards; it is quoted and cited in the Section 508 article. Nothing here is legal advice.