Guides · accessibility
How to check whether a PDF is accessible with veraPDF — the install, the flags, the exit codes, and the four things a green run does not tell you
Published 2026-08-17 · every command, flag, exit code and rule count below was run against veraPDF 1.30.2 on the day of writing and transcribed from the terminal. veraPDF's CLI surface moves between versions; the version is stated so you can tell whether this still applies to yours.
This is the shape of the answer everybody is looking for:
$ verapdf --flavour ua1 --format text notice.pdf
PASS /srv/build/notice.pdf ua1
$ echo $?
0
Getting there takes about ten minutes, and this page is the ten minutes. But the more useful half is the part after it, because that terminal output does not mean the document is accessible, and it might not even mean what you think about the standard you were checking. We have three of our own documents that produce exactly those four lines and are wrong anyway — one of them tells a screen reader to read Chinese aloud as American English.
The sixty-second version
| What you want | The command, and the catch |
|---|---|
| Is this PDF PDF/UA-1 conformant? | verapdf --flavour ua1 --format text f.pdf. Leave
--flavour off and you may silently validate against PDF/A-1b instead —
section 2 |
| Which rules failed, and on which object? | --format xml, and read the context string. It names the object
number — section 3 |
| To fail a build on it | Exit 0 pass, 1 fail, 2 bad argument, 4 no such file, 7 unparseable. All five measured in section 4; the gate script is section 5 |
| To validate a whole folder | -r build/pdfs in one invocation. Six files cost 0.03s more than
one — the rest is JVM start-up |
| To know what the PASS did not cover | Sections 7 and 8. This is the half that decides whether the document is usable |
1. Installing it without a GUI
The published distribution is an IzPack graphical installer, which is awkward on a build server. It also accepts an automated-installation descriptor, which is not in the documentation in runnable form:
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
/opt/verapdf/verapdf --version
veraPDF 1.30.2
pack index="1" is the one people miss. Without veraPDF Mac
and *nix Scripts the installer reports success, writes a tree under your install path,
and there is no verapdf shell script anywhere in it. The failure looks like a
PATH problem and is not one. There is no apt package to fall back on —
we checked the Debian package index on 2026-08-17 and there is no verapdf package
in any suite — so the installer, a container image, or building from the GitHub source are
the options.You need a JRE. This was run on OpenJDK 17; the installer and CLI are Java 8 era and are happy on anything modern.
2. The one flag that decides whether you asked the right question
This is the most consequential thing on the page, and it is not in any tutorial we could
find. --flavour defaults to 0, which means detect the flavour from the
file's own metadata. And a file with no PDF/UA identifier in it has no flavour to detect,
so veraPDF falls back to --defaultflavour, whose default is 1b:
the same file, twice. The only difference is one flag.
$ verapdf --format text out-tagged.pdf # no --flavour
FAIL /private/tmp/uaprobe/out-tagged.pdf 1b <- 1b. PDF/A-1b.
$ verapdf --flavour ua1 --format text out-tagged.pdf
FAIL /private/tmp/uaprobe/out-tagged.pdf ua1 <- the one you meant
and what the first run was actually complaining about:
profileName="PDF/A-1b validation profile"
The embedded ICC profile (Device Class = mntr, color space = RGB,
version = 4.3) is either invalid or does not satisfy PDF 1.4
requirements
An archiving rule about colour management. Nothing to do with
accessibility, on a run you started to check accessibility.
Both runs say FAIL, so nothing looks wrong. But the first one validated an
accessibility question against PDF/A-1b — the archiving standard — and
reported an ICC colour-profile problem, which is a real finding about a different concern
entirely. The flavour is printed at the end of the FAIL line, and that two-character
token is the only thing on screen that tells you which standard you just measured.
Now the sharp edge. Run the same auto-detection on a file that does carry the identifier:
$ verapdf --format text s-K.pdf # again, no --flavour
PASS /private/tmp/uaprobe/s-K.pdf ua1 <- ua1 this time
s-K.pdf is the same document, post-processed to carry an XMP packet
with pdfuaid:part = 1. That packet is what auto-detection reads.
So auto-detection works by believing the file's own claim. A document that
asserts PDF/UA-1 gets checked against PDF/UA-1. A document that asserts nothing gets checked
against an archiving standard. Which means the file you have most reason to be suspicious of —
the one with no conformance claim, freshly out of a renderer — is exactly the file that
auto-detection sends down the wrong path. Always pass --flavour ua1
explicitly. It costs nothing and it removes the failure mode.
3. Reading the report
Four output formats matter. --format text for a human, with -v to get the
clause and test numbers:
$ verapdf --flavour ua1 --format text -v out-tagged.pdf
FAIL /private/tmp/uaprobe/out-tagged.pdf ua1
FAIL 7.1-5
FAIL 7.18.1-2
FAIL 7.1-8
FAIL 7.18.5-2
--format xml for the detail. This is the one to keep as a build artefact:
$ verapdf --flavour ua1 --format xml out-tagged.pdf
<rule specification="ISO 14289-1:2014" clause="7.18.1" testNumber="2"
status="failed" failedChecks="1">
<description>An annotation ... shall have a Contents key ...</description>
<check status="failed">
<context>root/document[0]/pages[0](2 0 obj PDPage)
/annots[0](10 0 obj PDLinkAnnot)</context>
</check>
</rule>
The context string is the reason to use this format. It names the
object: 10 0 obj, the annotation Chromium made for one <a href>.
You can open it with any PDF library and look.
--format mrr in an older recipe, it still works.
It is not in the --help list on 1.30.2 — which reads
[raw, xml, text, html, json] — but it is accepted, and the output is
byte-identical to --format xml apart from the timing figures. It is not being
ignored, either: --format bogus exits 2 with
Illegal format option value. "mrr" is simply the historical name for the
machine-readable report, and veraPDF still calls it that in its own messages — the
--addlogs help text reads "Add logs to xml (mrr), json or html report". Write
xml in new scripts; that is the spelling the tool documents.--format json for machines, which is what the gate in section 5 parses:
$ verapdf --flavour ua1 --format json out-tagged.pdf | jq '
.report.jobs[0].validationResult[0].details
| {passedRules, failedRules, passedChecks, failedChecks, tags}'
{
"passedRules": 102,
"failedRules": 4,
"passedChecks": 1501,
"failedChecks": 4,
"tags": ["annotation", "metadata", "alt-text", "structure"]
}
Note the tags array. Every rule carries a category — structure,
metadata, annotation, alt-text, lang, artifact,
syntax, text — and the summary rolls up the tags of everything that failed.
On a large document that is how you route a failure without reading the whole report: an
alt-text failure goes to whoever writes the content, a metadata failure goes
to whoever owns the pipeline.
The number that is not a score
One thing to get straight before anybody puts a percentage in a status report:
the same rule set (106 rules) against three versions of one document:
rules checks
page.pdf({}) 97 ok / 9 bad 439 ok / 78 bad 517 total
page.pdf({tagged}) 102 ok / 4 bad 1501 ok / 4 bad 1505 total
...+ outline 102 ok / 4 bad 1505 ok / 4 bad 1509 total
The worst document ran the FEWEST checks: 517 against 1509. A rule
only produces checks against objects that exist, and the untagged
file has almost no structure to check. "1,501 checks passed" is not
a score, and two files' check counts are not comparable.
The check count is a property of the document, not of the standard. Adding structure to a document increases the number of checks it is put through, because rules about structure elements only fire where structure elements exist. Report failed rules and failed checks; do not report a ratio, and never compare two files' totals.
4. Exit codes, all five
Needed for CI, not documented in the CLI quick-start guide, and not in --help.
Measured on 1.30.2:
$ verapdf --flavour ua1 --format text s-K.pdf ; echo $?
PASS /private/tmp/uaprobe/s-K.pdf ua1
0
$ verapdf --flavour ua1 --format text out-tagged.pdf ; echo $?
FAIL /private/tmp/uaprobe/out-tagged.pdf ua1
1
$ verapdf --flavour ua1 --format bogus out.pdf ; echo $?
Illegal format option value: bogus
2
$ verapdf --flavour ua1 --format text /tmp/nope.pdf ; echo $?
SEVERE: File /tmp/nope.pdf doesn't exist.
SEVERE: There are no files to process.
4
$ echo hello > notapdf.pdf
$ verapdf --flavour ua1 --format text notapdf.pdf ; echo $?
notapdf.pdf does not appear to be a valid PDF file and could not be parsed.
7
| Exit | Means | In CI, this is |
|---|---|---|
| 0 | Every file validated against the profile | Green |
| 1 | At least one file failed validation | Red — a content bug |
| 2 | Illegal argument value | Red — your script is wrong, not the PDF |
| 4 | No files to process (bad path) | Red — and the one to watch: a typo'd glob validates nothing and must never read as a pass |
| 7 | File is not a parseable PDF | Red — the renderer produced garbage |
Exit 4 is the one to design around. Pointed at an empty directory,
verapdf --flavour ua1 -r build/pdfs exits 4 — measured — so a gate that only asks
"was it zero?" does at least go red rather than passing a build that produced no PDF at all.
But it will report that as "your PDF failed accessibility validation", which sends somebody to
read a report about a file that does not exist. Distinguish the codes; the case
statement in the next section does, in six lines.
5. A gate that has actually been run
#!/usr/bin/env bash
# PDF/UA-1 gate. Run against the artefact, in the job that built it.
set -uo pipefail
VERAPDF=${VERAPDF:-/opt/verapdf/verapdf}
pdf=$1
"$VERAPDF" --flavour ua1 --format json "$pdf" > report.json
rc=$?
case $rc in
0) echo "PASS $pdf" ;;
1) echo "FAIL $pdf"
jq -r '.report.jobs[0].validationResult[0].details.ruleSummaries[]
| select(.ruleStatus=="FAILED")
| " \(.specification) clause \(.clause) test \(.testNumber)"
+ " x\(.failedChecks) [\(.tags|join(","))]"' report.json ;;
2) echo "ERROR bad veraPDF argument" ;;
4) echo "ERROR file not found: $pdf" ;;
7) echo "ERROR not a parseable PDF: $pdf" ;;
*) echo "ERROR veraPDF exited $rc" ;;
esac
exit $rc
Against the three cases:
$ ./gate.sh out-tagged.pdf
FAIL out-tagged.pdf
ISO 14289-1:2014 clause 7.1 test 5 x1 [structure]
ISO 14289-1:2014 clause 7.18.5 test 2 x1 [annotation,alt-text,structure]
ISO 14289-1:2014 clause 7.1 test 8 x1 [metadata]
ISO 14289-1:2014 clause 7.18.1 test 2 x1 [annotation,alt-text]
$ echo $?
1
$ ./gate.sh s-K.pdf -> PASS s-K.pdf exit 0
$ ./gate.sh nope.pdf -> ERROR file not found: nope.pdf exit 4
Drop it in a job step after the one that builds the PDF, with
report.json kept as an artefact so a failure two weeks from now still has evidence
attached. If you are on GitHub Actions, the whole setup is
apt-get install -y default-jre jq, the install block from section 1, and this script;
there is no action to install and nothing to authenticate.
Do not invoke it once per file
one file: real 0.62s 0.63s 0.64s
six files, one invocation: real 0.65s
Six files cost three hundredths of a second more than one. Almost the
whole run is JVM start-up, so a loop that invokes verapdf once per
artefact pays that start-up every time. Pass the directory instead:
$ verapdf --flavour ua1 --format text -r build/pdfs
PASS /tmp/batch/s-J.pdf ua1
PASS /tmp/batch/s-K.pdf ua1
PASS /tmp/batch/s-M.pdf ua1
FAIL /tmp/batch/out-baseline.pdf ua1
FAIL /tmp/batch/out-tagged.pdf ua1
FAIL /tmp/batch/sc-A.pdf ua1
Exit code is 1 if ANY file failed. -r only picks up a .pdf extension
unless you add --nonpdfext.
6. What "compliant = true" is true about
veraPDF validates against a named profile, and there is more than one accessibility profile:
$ verapdf --list
1a 1b 2a 2b 2u 3a 3b 3u 4 4f 4e PDF/A
ua1 PDF/UA-1 validation profile
ua2 PDF/UA-2 + Tagged PDF validation profile
wt1r WTPDF 1.0 Reuse validation profile
wt1a WTPDF 1.0 Accessibility validation profile
one file, s-K.pdf, three accessibility profiles:
--flavour ua1 106 rules 0 failed compliant = true
--flavour ua2 1727 rules 5 failed compliant = false
--flavour wt1a 1723 rules 4 failed compliant = false
The same file. One profile calls it compliant and two do not. That is not a contradiction — they are different standards — but it means "veraPDF says it is compliant" is an incomplete sentence. The profile name is in every report; quote it.
the five ua2 failures on a file that is ua1-compliant:
clause 5 test 2 pdfuaid:part is 1 instead of 2
clause 5 test 5 pdfuaid:rev (null) is not "2024"
clause 8.2.5.2 test 2 the single Document structure element is not in
the PDF 2.0 namespace (found http://iso.org/pdf/ssn)
clause 8.2.5.25 test 2 an LI element holds real content directly,
instead of enclosing it in Lbl or LBody
clause 8.8 test 1 a destination in an outline item, OpenAction or
link annotation is not a structure destination
Two are bookkeeping in the identifier. Three are real structure that
Chromium does not produce.
PDF/UA-2 is ISO 14289-2, built on PDF 2.0, and veraPDF's profile for it carries roughly
sixteen times as many rules as the PDF/UA-1 one. Two of the five failures above are just the
identifier declaring the wrong part number and a missing revision — trivially fixable, and a
good reminder that stamping pdfuaid:part is a claim you have to actually mean. The
other three are structure that a Chromium print job does not produce: the PDF 2.0 namespace on
the document element, list items that wrap their content in LBody, and structure
destinations rather than plain destinations.
Which profile should you run? If a contract names PDF/UA, it almost certainly means
PDF/UA-1 — that is the one incorporated by reference in the U.S. rules, as
the Section 508 article sets out. Run
ua1 as the gate. Run ua2 occasionally to see what is coming.
7. Three documents that pass, and are wrong
Here is the part that the install instructions exist to get you to.
The companion article measures what
Chromium's tagged: true produces against PDF/UA-1 — 102 of 106 rules pass out of the
box — and closes the last four with about thirty lines of pikepdf. Three of the documents that
came out of that run report compliant = true with zero failed checks,
and each one fails WCAG 2.0 at Level A — which is the standard Section 508
actually cites:
| The document | veraPDF ua1 | What is wrong with it |
|---|---|---|
A notice written in Chinese, rendered with no lang attribute |
compliant, 0 failed | Carries /Lang (en-US) — Chromium's own locale. A screen reader is instructed
to pronounce Chinese as American English. WCAG 3.1.1 Language of Page (A) |
A data table whose header row is <td> |
compliant, 0 failed | No header cells, so a user navigating cell by cell is never told which column they are in. WCAG 1.3.1 Info and Relationships (A) |
An image whose alt text describes a different chart |
compliant, 0 failed | Present, well formed, and false. No validator can look at a picture. WCAG 1.1.1 Non-text Content (A) |
The first one is worth sitting with. PDF/UA-1's machine-checkable rule for
/Lang asks that the key be present and well formed. It cannot ask that it be
true. So a renderer that helpfully fills in a default produces a file that satisfies
the rule perfectly and misinforms every assistive technology that opens it — and the validator,
correctly, says nothing.
This is not a defect in veraPDF, which is careful about what it claims. It is the shape 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 puts it, "while most failure conditions can be identified by software, others require human judgment". The judgment ones are the ones that decide whether the document can be used.
pdfuaid:part identifier into a file takes three lines of code, and every
downstream tool and procurement checklist will believe it. A file carrying that claim while
failing the human criteria is worse than a file with no claim, because it stops the
next person from looking. Stamp the identifier when the work is done — including the part a
machine cannot check.8. The list a person still has to work through
So: the validator is green. Here is what is left, in the order it is worth doing. None of these can be automated, all of them can be done by one careful person with the PDF open, and together they are perhaps twenty minutes for a document of a few pages.
| Check, by hand | How | WCAG |
|---|---|---|
| Is the declared language the actual language? The one at the top of this list because a renderer will invent it for you | strings f.pdf | grep /Lang, or any PDF inspector. Compare it to the words on
the page. Check per-passage language too, if the document changes language |
3.1.1, 3.1.2 |
| Is every alternative text true, and does it serve the same purpose? Presence is checked; truth is not | Read each one against the image it belongs to. A chart's alternative is its finding, not "chart". A decorative image should be an artifact, not an empty string | 1.1.1 |
| Is the reading order the order a person needs? Multi-column layouts, sidebars, pull quotes and floated figures are where this breaks | Read the document with the tag tree, or listen to it. Not the same thing as the visual order | 1.3.2 |
| Do the heading levels descend without gaps, and do they describe the content? | List the headings. An h1 followed by an h3 is legal PDF and a broken outline. The same tree feeds the bookmarks | 1.3.1, 2.4.6 |
| Do tables have real header cells, with the right scope? A header-less table passes PDF/UA-1 — measured | Check the header row is TH in the tag tree, and that a table used only for
layout is not tagged as a table at all |
1.3.1 |
Does every link's description say where it goes? The validator only
asks that /Contents exist |
Read them out of context. "Click here" satisfies the rule and fails the reader.
Neither title= nor aria-label= populates this — see the companion
article |
2.4.4 |
| Is the contrast sufficient, and is colour ever the only cue? PDF/UA-1 has no contrast requirement at all | Sample the actual rendered colours, including in charts and in table banding | 1.4.1, 1.4.3 |
Does the document title describe the document? Its
existence is machine-checked — clause 7.1 test 10 covers both the title and
/ViewerPreferences /DisplayDocTitle true, and Chromium passes it. Whether it says
anything is not |
Read it. "Microsoft Word - final_v3_REALLY_final.docx" clears the validator | 2.4.2 |
| Have you opened it in a screen reader? The one that replaces several of the above | NVDA is free; VoiceOver is already on the Mac. Ten minutes here finds things no checklist predicts | WCAG CR 4 |
That last row is doing more work than the rest combined, and it is the one people skip. WCAG 2.0's fourth conformance requirement — only accessibility-supported ways of using technologies — is the clause under which "it validates" stops being an argument and becomes a question about what a JAWS, NVDA or VoiceOver user actually hears. The Section 508 article covers that clause and the four success criteria that non-Web documents are exempt from.
9. Two mistakes we made measuring this, so you can skip them
An indicator that was never wired up. The first run of the fixture harness
served the test page on a port something else on the machine was already using. Chromium
printed a JSON error body, the resulting PDFs had no structure at all, and the validator output
read as a clean, quotable refutation of a result we had published a week earlier. It was an
artefact of the port. Every probe now asserts textContent('h1') before printing.
Before you trust an indicator, prove it responds in the positive case — run it
against a file you know is bad and check it goes red.
A pass that was about the wrong standard. Section 2, which is here because it happened. The output of an accessibility check and the output of an archiving check look identical apart from two characters at the end of one line.
10. Where snapdok.io stands, plainly
We measured our own output while writing this line, and it is worth being direct about the result:
- Our API does not expose
taggedoroutline. There is no parameter for either today. - So the PDFs we return are untagged — 9 failed PDF/UA-1 rules, 78 failed checks on the fixture above. That is the first row of the table in section 3.
- 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 output — DocRaptor exposes a
prince_options[profile] of PDF/UA-1, for one — is a fair thing to shop for.
What snapdok.io is good at is the part next door: getting a page to render
faithfully, fonts and layout intact, without a browser in your own infrastructure.
The short version
Install veraPDF headless with an IzPack auto.xml and remember pack index 1. Always
pass --flavour ua1, because auto-detection believes the file's own claim and sends an
unlabelled PDF to the PDF/A-1b profile. Read failures out of --format xml — the
context string names the object — or out of --format json in CI, where exit 0
is pass, 1 is fail, 2 is a bad argument, 4 is no such file and 7 is an unparseable PDF. Validate
a whole directory in one invocation; almost the entire runtime is JVM start-up. And then treat
compliant = true as what it is: a statement about one named profile's
machine-checkable rules, which the same file can pass under PDF/UA-1 and fail under PDF/UA-2,
and which three documents of ours satisfy with zero failed checks while declaring Chinese to be
English, shipping a header-less data table and describing the wrong chart. Run the validator,
then work section 8 by hand.
The rest of this line:
what Chromium's tagged: true
actually produces — 102 of 106 rules, which four fail and the thirty lines that close them —
whether Section 508 requires PDF/UA, which
it does not, on the record, in the Access Board's own words, and
the HTML-side checklist — what to
change in the markup before you run any of this, and the four popular fixes that measurably do
nothing. Related from the same measurements:
why outline: true returns a
byte-identical PDF.
Sources and versions.
Everything measured here used veraPDF
1.30.2 (built 2026-06-03), GPLv3 / MPLv2, on OpenJDK 17, against PDFs rendered
by 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. Clause numbers are
ISO 14289-1:2014 and ISO 14289-2 as veraPDF's own profiles state them ·
veraPDF installation guide and
CLI quick start — neither documents
the exit codes, which is why they are measured above ·
the
validation profiles wiki, which is where --profilesWiki points and where the rule
text lives ·
policy validation, if you need
institutional rules on top of a profile ·
PDFlib's
knowledge-base entry on the Matterhorn Protocol — "136 »failure conditions« which are
grouped in 31 checkpoints", most but not all software-detectable ·
WCAG 2.0, for the success criteria
cited in sections 7 and 8 and for Conformance Requirement 4. The measurements are ours, taken
2026-08-17 and repeatable with the commands on this page.
Nothing here is legal advice.