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.

What veraPDF is. Free software, GPLv3 / MPLv2, from the veraPDF Consortium, developed with PREFORMA funding. It is the reference implementation for PDF/A and PDF/UA validation and it is what an auditor is most likely to run. Nothing on this page is a pitch for a product — ours does not do this, and section 10 says so plainly. Nothing here is legal advice either; if a contract names a standard, the artefact gets validated and reviewed by a person.

The sixty-second version

What you wantThe 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.
If you have seen --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
ExitMeansIn CI, this is
0Every file validated against the profileGreen
1At least one file failed validationRed — a content bug
2Illegal argument valueRed — your script is wrong, not the PDF
4No files to process (bad path)Red — and the one to watch: a typo'd glob validates nothing and must never read as a pass
7File is not a parseable PDFRed — 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 documentveraPDF 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.

Which is why we will not tell you that a green run is compliance. Writing the 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 handHowWCAG
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:

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.