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 boundary this page will not cross. A PDF with no XMP metadata packet and no PDF/UA identifier is not conformant, however good the HTML was — and that packet cannot be written from HTML. Nothing on this page will tell you that markup alone got you through an audit, because it did not. Nothing here is legal advice either.

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.

We measured that 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.

  1. <a title="Read the plan"> — variant E. Three failed rules, the same three as a bare link, on the same objects.
  2. <a aria-label="Read the plan"> — variant F. Identical. ARIA is a mapping into an accessibility tree; a link annotation's /Contents is a key in a PDF dictionary, and Chromium does not carry one into the other.
  3. Leaving lang off — variant G. This one does not merely fail to help, it actively hides a defect: the document passes while declaring the wrong language.
  4. A data table with no <th> — variant J. Also passes.
The scope of that second result. We measured 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:

  1. Write an XMP metadata stream into the catalog carrying pdfuaid:part = 1 and a dc:title. Clears 7.1-8.
  2. Set /Contents on every /Link annotation 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.
  3. Add /Strong and /Em to 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.

Writing 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 thisOnly 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.