Guides · accessibility
Chromium's tagged PDF fails PDF/UA-1 on four rules out of 106 — here is exactly which four, and what each one costs
Published 2026-08-17 · every number below was measured on the day of writing: Chromium 151.0.7922.34 driven by Playwright 1.62.1, validated with veraPDF 1.30.2 against its PDF/UA-1 profile, post-processed with pikepdf 10.11.0. The commands are in the article.
Somebody has told you the PDF has to be accessible. You found tagged: true, you
turned it on, and now you would like to know whether that was the whole job. Here is the
answer, from a validator rather than from a blog post:
veraPDF 1.30.2, --flavour ua1, PDF/UA-1 profile: 106 rules
one A4 notice: h1/h2, paragraph, <img alt>, <table><caption><th scope>,
<ul>, <a href>, <html lang="en">, <title>
page.pdf({}) 9 rules failed 78 checks failed
page.pdf({ tagged: true }) 4 rules failed 4 checks failed
the same document with no <a href> 2 rules failed
...and no <strong>/<em> either 1 rule failed
That last line is the news: on a clean document, Chromium's tagged
output is ONE rule away from a PDF/UA-1 pass. And the one rule is not
something in your HTML.
Two things follow, and they pull in opposite directions, which is why this page exists.
Chromium is far better at this than its reputation suggests — it writes a
structure tree, /Alt on figures, table header cells, list structure, a language and a
document title, unasked. And the last rule cannot be closed from HTML at all,
so no amount of markup discipline will get you there.
The sixty-second version
| You want | Do this | Cost |
|---|---|---|
| A structured PDF instead of a page of loose text | page.pdf({ tagged: true }) — Puppeteer already defaults it on, Playwright
defaults it off |
About 3 KB on a 24 KB document, and 5 of 9 failing rules clear |
| Zero failed checks from a PDF/UA-1 validator | Tagging, plus roughly thirty lines of post-processing (section 4) | One more dependency in the pipeline; there is no HTML-only route |
| Fewer failures before you post-process | Prefer <b>/<i> to <strong>/<em>
in the print stylesheet, and keep <title> and alt present |
Measured in section 3 — and note what that trade costs the HTML |
| A document that is actually usable | Set lang correctly, write real alternative text, keep the heading levels in
sequence, mark table headers |
None of these four are enforced by the validator — section 5 |
| To know where you stand right now | verapdf --flavour ua1 --format text out.pdf |
Free, GPLv3, runs headless — install recipe in section 6 |
1. The four failures, named
The document is a plausible government notice: a heading, two subheadings, a paragraph, an
image with alternative text, a data table with a <caption> and
<th scope="col"> cells, a bullet list, one link, <html lang="en"> and a
<title>. Rendered with tagged: true, veraPDF reports:
$ verapdf --flavour ua1 --format xml tagged.pdf # 4 failed checks of 1509
ISO 14289-1:2014, clause 7.1 test 8
The document catalog dictionary doesn't contain metadata key ...
context: root/document[0]
ISO 14289-1:2014, clause 7.1 test 5
Non-standard structure type Strong is not mapped to a standard type
context: .../K[3](21 0 obj SEP P)/K[1](23 0 obj SENonStandard Strong)
ISO 14289-1:2014, clause 7.18.1 test 2
Link annotation ... has neither Contents key nor an Alt entry in the
enclosing structure element
context: .../pages[0](2 0 obj PDPage)/annots[0](10 0 obj PDLinkAnnot)
ISO 14289-1:2014, clause 7.18.5 test 2
A link annotation does not include an alternate description in the
Contents key
context: .../pages[0](2 0 obj PDPage)/annots[0](10 0 obj PDLinkAnnot)
Read those context strings — they are the most useful thing the tool produces. Two of the
four point at the same object, 10 0 obj, which is the annotation Chromium
created for one <a href>. One points at a Strong structure element,
which came from one <strong>. And one points at the document as a whole.
So the four failures are really three causes, and they are not equal:
- No XMP metadata (7.1-8). Chromium writes no XMP packet at all, ever. Nothing in your HTML changes this. Every Chromium-rendered PDF fails this rule.
- Links (7.18.1-2 and 7.18.5-2). PDF/UA wants a link annotation to carry
a human description in
/Contents. Chromium writes the destination and nothing else. - Role mapping (7.1-5).
<strong>becomes a structure element of typeStrong, which is not one of the standard types in ISO 32000-1 §14.8.4, and Chromium does not add it to the structure tree's role map.
2. What tagging bought, in failed checks
The same document, printed with no options at all, fails 9 rules and 78 checks: 7.1-3 forty-six times (real content that is not tagged), 7.2-34 twenty-five times, plus 6.2-1, 7.1-11, 7.2-30 and 7.18.5-1. Turning tagging on takes that to 4 rules and 4 checks.
That is the honest measure of what the option is worth: it does not make a document conformant, and it does about 95% of the mechanical work. It is the difference between a problem you can finish and a problem you cannot start.
outline: true appeared to do nothing, that is the
same mechanism from the other side —
Chromium will not write bookmarks into
an untagged file, and Playwright's tagged defaults to false while
Puppeteer's defaults to true. If you have been comparing your Playwright output
to a colleague's Puppeteer output, you have been comparing a tagged file to an untagged
one.3. Which HTML change moves which clause
Twelve variants of the same page, one thing changed at a time, each run asserting its own
h1 before printing so that a mis-served fixture cannot masquerade as a finding:
every row: Chromium 151.0.7922.34 via Playwright 1.62.1,
page.pdf({ format:'A4', tagged:true, outline:true, printBackground:true })
--------------------------------------------------------------------------
variant failed clauses
A the full page (link, strong, everything) 3 7.18.1-2 7.18.5-2 7.1-8
B + <strong>eligible</strong> 2 7.1-5 7.1-8
C + <em>eligible</em> 2 7.1-5 7.1-8
D + <b> <i> <code> <sub> <small> 1 7.1-8 <- clean!
E <a href title="Appeals procedure"> 3 7.18.1-2 7.18.5-2 7.1-8
F <a href aria-label="Appeals procedure"> 3 7.18.1-2 7.18.5-2 7.1-8
G no lang attribute at all 1 7.1-8 <- PASSES
H <img> with no alt 2 7.3-1 7.1-8
I <img alt=""> 2 7.1-3 7.1-8
J data table with no <th> 1 7.1-8 <- PASSES
K clean: lang + title + alt + th, no link 1 7.1-8
L no <title> element 2 7.1-10 7.1-8
Five results here are worth more than the table:
<strong> and <em> break role mapping.
<b>, <i>, <code>, <sub> and
<small> do not. Row D carries all five presentational tags and fails
only the metadata rule. This is exactly backwards from the habit every accessibility guide
teaches, and it is not an argument for writing worse HTML — it is an argument for knowing
that this particular validator failure is cosmetic and is fixed in one line of the role map
rather than by rewriting your templates.
Neither title="…" nor aria-label="…" reaches the link
annotation. Rows E and F are the same three failures as row A. Both are the obvious
thing to try, both are what a search will suggest, and both are measurably no-ops here:
/Contents is a PDF-level key with no HTML expression. This one has to be
post-processed.
Leaving lang off does not fail the validator. Row G passes. That
is not because the file is language-neutral — it is because Chromium writes
/Lang from its own locale when the attribute is missing, and the rule only
asks that /Lang be present and well formed. Section 5 is about what that means.
A missing alt and an empty alt fail differently. No
alt gives you 7.3-1 — a /Figure with no /Alt. alt="" gives
you 7.1-3 instead: the image stops being a figure and becomes untagged page content, which is
a different rule and a different fix. If you are using alt="" to mean "decorative",
Chromium is not turning that into a PDF artifact for you.
A data table with no <th> passes. Row J is a table whose
header row is plain <td> cells, and PDF/UA-1's machine-checkable rules have
nothing to say about it. Keep writing <th scope>; just do not expect a validator
to tell you when you forgot.
4. Closing the gap — thirty lines, measured
Three edits, none of which HTML can express, applied after the render:
# pikepdf 10.11.0. Three edits, none of which HTML can express.
import pikepdf
from pikepdf import Name, String, Dictionary
pdf = pikepdf.open('tagged.pdf')
title = str(pdf.docinfo.get('/Title', ''))
# 1. clause 7.1-8: an XMP packet, carrying the PDF/UA-1 identifier.
# Only claim part 1 if you have actually done the work below.
xmp = f'''<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">{title}</rdf:li>
</rdf:Alt></dc:title>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/">
<pdfuaid:part>1</pdfuaid:part>
</rdf:Description>
</rdf:RDF></x:xmpmeta><?xpacket end="w"?>'''.encode()
st = pdf.make_stream(xmp)
st[Name.Type], st[Name.Subtype] = Name.Metadata, Name.XML
pdf.Root[Name.Metadata] = pdf.make_indirect(st)
# 2. clauses 7.18.1-2 and 7.18.5-2: a description on every link.
# Write the real destination. This text is read aloud.
for page in pdf.pages:
for a in page.get('/Annots', []):
if a.get('/Subtype') == Name.Link:
a[Name.Contents] = String('Appeals procedure on example.gov')
# 3. clause 7.1-5: role-map the types Chromium invents.
root = pdf.Root['/StructTreeRoot']
rm = root.get('/RoleMap')
if rm is None:
rm = pdf.make_indirect(Dictionary()); root[Name.RoleMap] = rm
for k in ('/Strong', '/Em'):
rm.setdefault(Name(k), Name.Span)
pdf.save('stamped.pdf')
Re-validated:
before after
clean document FAIL, 1 rule compliant, 646 checks passed
+ <strong> FAIL, 2 rules compliant, 769 checks passed
+ <strong> + <a href> FAIL, 3 rules compliant, 793 checks passed
verapdf --flavour ua1 stamped.pdf
PASS stamped.pdf ua1
All three documents come back compliant = true with zero failed checks. So the
distance from page.pdf({ tagged: true }) to a clean PDF/UA-1 validator run is a
post-processing step you can read in one sitting. If you are wiring this into a pipeline, run
the validator on the artefact in CI and fail the build on a non-zero exit — the report is
machine-readable with --format xml, and the clause plus test number is enough to
route the failure to whoever owns it.
pdfuaid:part. That element is a claim of
conformance, and writing it is trivial, which is the danger. A file carrying the identifier
and failing the human checks is worse than a file with no claim at all: it tells every
downstream tool and every procurement checklist that the work was done. Add the identifier
when the work is done, not when the validator goes quiet.5. Three documents that pass and are still wrong
This is the part that decides whether this page was worth writing. All three were produced by the pipeline above and all three report zero failed checks.
a notice written in Chinese, no lang attribute, tagged, then stamped:
$ verapdf --flavour ua1 s-M.pdf
PASS s-M.pdf ua1 <- claims PDF/UA-1 conformance
$ strings s-M.pdf | grep '/Lang'
/Lang (en-US) <- Chromium's own locale
document body: 本通知说明资格认定的结果。申请人须在三十日内答复。
A screen reader is now instructed to pronounce that as American English.
Zero failed checks.
The second: a data table whose header row is <td> — row J above, stamped.
Compliant. A screen-reader user navigating it cell by cell is told nothing about which column
they are in. The third: an image whose alt text is present, well-formed and
describes the wrong chart. Compliant, because no validator can read a picture.
None of this is a defect in veraPDF, which is careful about what it claims. It is the structure of the problem. The PDF Association's Matterhorn Protocol, the reference test model for PDF/UA, defines 136 failure conditions grouped into 31 checkpoints, and as PDFlib's summary of it puts it, "while most failure conditions can be identified by software, others require human judgment". The judgment ones are the ones that decide whether a document can actually be used.
Practical consequence: a green validator is the moment the accessibility work
starts being worth doing, not the moment it finishes. Set lang on the
html element even though nothing enforces it. Write alternative text a person would
want read to them. Keep heading levels in sequence — the same accessibility tree that feeds
the PDF's structure also
builds its bookmarks, so you get a
navigable outline out of the same effort.
6. Running the validator, including the install nobody documents
veraPDF is free software (GPLv3 / MPLv2) from the veraPDF Consortium and it is the reference implementation for PDF/UA and PDF/A validation. The published installer is an IzPack GUI, which is awkward on a build server; it also takes an auto-install descriptor:
curl -sSLo vp.zip https://software.verapdf.org/releases/verapdf-installer.zip
unzip -q vp.zip && cd verapdf-greenfield-*
cat > auto.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<AutomatedInstallation langpack="eng">
<com.izforge.izpack.panels.htmlhello.HTMLHelloPanel id="welcome"/>
<com.izforge.izpack.panels.target.TargetPanel id="install_dir">
<installpath>/opt/verapdf</installpath>
</com.izforge.izpack.panels.target.TargetPanel>
<com.izforge.izpack.panels.packs.PacksPanel id="sdk_pack_select">
<pack index="0" name="veraPDF GUI" selected="true"/>
<pack index="1" name="veraPDF Mac and *nix Scripts" selected="true"/>
<pack index="3" name="veraPDF Validation model" selected="true"/>
</com.izforge.izpack.panels.packs.PacksPanel>
<com.izforge.izpack.panels.install.InstallPanel id="install"/>
<com.izforge.izpack.panels.finish.FinishPanel id="finish"/>
</AutomatedInstallation>
EOF
java -jar verapdf-izpack-installer-*.jar auto.xml # needs a JRE 8+
/opt/verapdf/verapdf --flavour ua1 --format text out.pdf
# pack index 1 is the one people miss. Without it the install reports
# success and there is no `verapdf` script anywhere.
Then --format xml gives you a report with one <rule> element per
requirement, each carrying clause, testNumber, status and a
context string that names the offending object — which is how the failures in
section 1 were traced to 10 0 obj rather than guessed at.
The veraPDF page covers the rest of the tool:
the --flavour default that will validate this question against PDF/A-1b if you let it,
all five exit codes, and a CI gate that has been run.
textContent('h1') before printing. Before you trust an indicator, prove it responds
in the positive case.7. What Section 508 and WCAG actually ask for
Worth being precise, because the paraphrases are not. Under the U.S. Access Board's ICT standards, E205.4 says electronic content "shall conform to Level A and Level AA Success Criteria and Conformance Requirements in WCAG 2.0" — not PDF/UA — with an Exception releasing non-Web documents from four criteria (2.4.1 Bypass Blocks, 2.4.5 Multiple Ways, 3.2.3 Consistent Navigation, 3.2.4 Consistent Identification). PDF/UA appears in a different chapter and applies to a different thing: 504.2.2, on authoring tools — "Authoring tools capable of exporting PDF files that conform to ISO 32000-1:2008 (PDF 1.7) shall also be capable of exporting PDF files that conform to ANSI/AIIM/ISO 14289-1:2016 (PDF/UA-1)".
In practice PDF/UA is how most organisations demonstrate that a PDF meets the relevant WCAG criteria, and plenty of contracts name it directly. But if yours names Section 508 and you have been told that means PDF/UA, the clause you are working to is E205.4, the standard it points at is WCAG 2.0 AA, and it is worth reading both before you buy tooling. This is a reading of a public standard, not legal advice; the wording is linked below so you can check it against your own obligation.
That question has its own page, because the answer turns out to be sharper than "PDF/UA is not mentioned": the Access Board proposed incorporating PDF/UA-1 as an alternative to WCAG 2.0 for content, and removed it from the final rule — with the reasoning on the record, the two places PDF/UA-1 does appear in the normative text, and which of the documents above fail WCAG at Level A while passing a validator.
8. If you would rather not own this
Owning it is a real but bounded cost: Playwright or Puppeteer with tagged: true,
the thirty lines in section 4, veraPDF in CI, and a human review that no tool replaces. That
is a week of work once and then a checklist.
If handing the render to a service is more attractive, some vendors expose PDF/UA output
directly — DocRaptor, for one, takes a prince_options[profile] of
PDF/UA-1 (and combined PDF/A + PDF/UA profiles) as a documented API option. On this
subject we should be straight with you about where snapdok.io stands:
- Our API does not expose
taggedoroutlinetoday. There is no parameter for either. - So the PDFs we return are untagged, which means they are the first row of section 2 — 9 failed rules, 78 failed checks against PDF/UA-1. We measured our own output to write this page.
- We write no XMP packet and no PDF/UA identifier, and we make no conformance claim of any kind.
If accessible PDF is the requirement that brought you here, driving Chromium yourself is the shorter path today, and a vendor with documented PDF/UA support is a fair thing to shop for. What snapdok.io is good at is the part around this — getting a page to render faithfully, fonts and layout intact, without a browser in your own infrastructure. See the flag map for what the various tools expose.
The short version
page.pdf({ tagged: true }) takes a Chromium PDF from 9 failed PDF/UA-1 rules to 4,
and on a document with no links and no <strong> to 1. The remaining failures are
a missing XMP metadata packet (which no HTML can fix), link annotations with no
/Contents (which neither title nor aria-label supplies), and
Strong/Em structure types missing from the role map. Roughly thirty lines of
pikepdf closes all three, and a validator then reports zero failed checks — on documents that
can still declare a Chinese notice to be American English, ship a header-less data table and
carry alternative text describing the wrong picture. Validate the artefact, then have a person
read it.
The rest of this line: the same twelve variants turned into an HTML checklist — sorted by what each change measurably did, including the four popular fixes that did nothing — whether Section 508 requires PDF/UA, which it does not, and how to run the validator that produced these numbers.
Related, from the same measurements:
why outline: true returns a
byte-identical PDF — the tagging dependency from the other direction, plus what the
outline is actually built from — and
why your images are missing from the
PDF, and
how to measure whether a CJK font can spell
the names in your document, which applies the same method — measure the artefact, not the
call — to fonts.
Sources, all read 2026-08-17:
U.S. Access Board, ICT
Accessibility 508 Standards — E205.2, E205.4 and its Exception, 504.2.2 and 702.3.1,
quoted above verbatim ·
veraPDF — the validator used throughout,
version 1.30.2, PDF/UA-1 profile; the clause numbers are ISO 14289-1:2014 as its profile
states them ·
PDFlib's
knowledge-base entry on the Matterhorn Protocol — "136 »failure conditions« which are
grouped in 31 checkpoints", and that most but not all are software-detectable ·
Playwright's
page.pdf() reference — tagged defaults to false ·
Puppeteer's
PDFOptions — tagged defaults to true ·
pikepdf, used for the
post-processing in section 4. Every measurement is ours, taken on 2026-08-17 with Chromium
151.0.7922.34 via Playwright 1.62.1, veraPDF 1.30.2 and pikepdf 10.11.0, and repeatable with
the commands above. Nothing on this page is legal advice.