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 boundary this page will not cross. A validator pass is not a conformance claim and neither is this article. PDF/UA-1 is ISO 14289-1; a real claim means the machine-checkable rules and the human ones — reading order, meaningful alternative text, correct heading sequence, colour contrast. Section 5 shows three of our own documents that pass with zero failed checks and are wrong anyway. Nothing here is legal advice, and if a contract names PDF/UA, the artefact gets validated and reviewed, not the option name.

The sixty-second version

You wantDo thisCost
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:

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.

If you are on Playwright and 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.

On stamping 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.

A measurement mistake worth repeating so you do not make it. The first run of this experiment served the fixture on a port something else on the machine was already using. Chromium printed a JSON error body, the PDFs came back with no outline and no structure, and that read as a clean, quotable refutation of a result we had published earlier. It was an artefact of the port. Every probe here now asserts 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:

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() referencetagged defaults to false · Puppeteer's PDFOptionstagged 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.