LaTeX & Typst (papers, theses, resumes and slides) Module 11 – Accessible, checked and safe PDFs
Checking PDFs: extraction, tags and fonts
Inspect a PDF's metadata, fonts and tags, compare what poppler, MuPDF and pdf.js extract, run veraPDF, and compute colour contrast before you send it.
What you will learn
- Inspect a PDF's title, language, fonts and structure tree from the command line
- Compare the text that poppler, MuPDF and pdf.js extract before you send a PDF
- Run veraPDF and list what still needs a person to judge
- Calculate colour contrast and find meaning carried by colour alone
Before you start
What you’ll learn · Before you start
What you will learn
- Inspect a PDF's title, language, fonts and structure tree from the command line
- Compare the text that poppler, MuPDF and pdf.js extract before you send a PDF
- Run veraPDF and list what still needs a person to judge
- Calculate colour contrast and find meaning carried by colour alone
Before you start
On this page
A portal, a search box and a screen reader each read a PDF through a different program, and none of them sees the page
you see. In this lesson’s audit, one report built four ways gave four different files: one without a title or a
language, which failed 8 rules of PDF/UA-2, and tagged ones in which a search for the formula’s d = V found nothing
until the text was normalised.
You will inspect a PDF’s metadata, fonts and tags with four commands, compare the text that three extractors read, run veraPDF and list what it cannot judge, and compute the contrast of the colours a file really uses. Every check was recorded on the report’s four builds, made with TeX Live 2026 and Typst 0.15.1.
Four commands describe a PDF before anyone opens it
The report is the one the two lessons before this one tagged, built four ways by build.sh: untagged with pdfLaTeX,
tagged with LuaLaTeX, and from its Typst version with the default export and with --pdf-standard ua-1. The audit
starts with what each file says about itself:
build.sh
# build.sh: the four PDFs of one report that this lesson audits, built quietly.
# pdflatex.pdf pdfLaTeX, untagged (report.tex without its \DocumentMetadata and \tagpdfsetup lines)
# lualatex.pdf LuaLaTeX, tagged for PDF/UA-2 (two runs)
# typst.pdf Typst's default export
# typst-ua1.pdf Typst with --pdf-standard ua-1
set -e
python3 make_chart.py
sed -e '/DocumentMetadata/d' -e '/tagpdfsetup/d' report.tex > plain.tex
pdflatex -interaction=nonstopmode -halt-on-error plain.tex > /dev/null
mv plain.pdf pdflatex.pdf
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
mv report.pdf lualatex.pdf
typst compile report.typ typst.pdf
typst compile --pdf-standard ua-1 report.typ typst-ua1.pdf audit/report.tex
% report.tex: the report this lesson audits, tagged for PDF/UA-2. build.sh builds it with LuaLaTeX (twice) and an
% untagged copy, without the \DocumentMetadata and \tagpdfsetup lines, with pdfLaTeX.
\DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on}
\documentclass{article}
\ifdefined\directlua \usepackage{unicode-math}\fi
\usepackage{graphicx}
\usepackage{xcolor}
\usepackage{hyperref}
\title{Monsoon rainfall at Station A}
\author{Asha Verghese}
\date{}
\begin{document}
\maketitle
\section{Summary}
The official figures were checked by an efficient field team. Rainfall depth is $d = V/A$, the volume collected over
the area of the funnel.
\begin{figure}[ht]
\centering
\includegraphics[width=0.5\linewidth, alt={Bar chart of monthly rainfall: under 160 mm a month from January
to May, 418 mm in June, a peak of 655 mm in July, then falling to 306 mm in September.}]{chart.png}
\caption{Monthly rainfall at Station A.}
\end{figure}
\tagpdfsetup{table/header-rows=1}
\begin{table}[ht]
\centering
\caption{Two monsoon months.}
\begin{tabular}{lrr}
Month & Rain (mm) & Rainy days \\
June & 418 & 21 \\
July & 655 & 27 \\
\end{tabular}
\end{table}
More at \url{https://example.com/station-a/monsoon-rainfall-records}.
\textcolor[HTML]{999999}{Source: field notebooks of an invented station.}
\end{document} audit/report.typ
// report.typ: the same report in Typst. build.sh exports it as Typst does by default and with --pdf-standard ua-1.
#set document(title: [Monsoon rainfall at Station A], author: "Asha Verghese")
#set text(lang: "en", region: "IN")
#set heading(numbering: "1.")
#title()
= Summary
The official figures were checked by an efficient field team. Rainfall depth is
#math.equation(alt: "d equals V over A", $d = V \/ A$), the volume collected over the area of the funnel.
#figure(
image("chart.png", width: 50%, alt: "Bar chart of monthly rainfall: under 160 mm a month from January to May, 418 mm in June, a peak of 655 mm in July, then falling to 306 mm in September."),
caption: [Monthly rainfall at Station A.],
)
#figure(
table(
columns: 3,
align: (left, right, right),
table.header[Month][Rain (mm)][Rainy days],
[June], [418], [21],
[July], [655], [27],
),
caption: [Two monsoon months.],
)
More at #link("https://example.com/station-a/monsoon-rainfall-records").
#text(fill: rgb("#999999"))[Source: field notebooks of an invented station.] describe.py
"""Describes PDFs the way an audit starts: for each file, whether it is tagged, its title and the language in its
catalog (/Lang), its PDF version, page size and pages, its fonts with how many are embedded and how many carry a
Unicode map, and the standards its XMP metadata claims. It reads what pdfinfo, pdffonts, pdfinfo -meta and
qpdf --json print.
python3 describe.py report.pdf other.pdf
"""
import json
import re
import subprocess
import sys
def output(*command):
return subprocess.run(command, capture_output=True, text=True).stdout
def language(pdf):
"""The /Lang of the document catalog, or None."""
objects = {}
for item in json.loads(output("qpdf", "--json", "--json-key=qpdf", pdf))["qpdf"][1:]:
objects.update(item)
root = objects["trailer"]["value"]["/Root"]
lang = objects[f"obj:{root}"]["value"].get("/Lang")
return lang[2:] if lang else None # qpdf writes a PDF string as "u:en-IN"
def describe(pdf):
info = dict(re.findall(r"^([A-Za-z ]+):[ \t]*(.*)$", output("pdfinfo", pdf), re.M))
fonts = [line.split() for line in output("pdffonts", pdf).splitlines()[2:] if line.strip()]
embedded = sum(1 for font in fonts if font[-5] == "yes")
unicode_map = sum(1 for font in fonts if font[-3] == "yes")
meta = output("pdfinfo", "-meta", pdf)
claims = []
if part := re.search(r"<pdfaid:part>(\d)</pdfaid:part>", meta):
level = re.search(r"<pdfaid:conformance>(\w)</pdfaid:conformance>", meta)
claims.append(f"PDF/A-{part[1]}{level[1].lower() if level else ''}")
if part := re.search(r"<pdfuaid:part>(\d)</pdfuaid:part>", meta):
claims.append(f"PDF/UA-{part[1]}")
size = re.search(r"\((\w+)\)", info.get("Page size", ""))
print(pdf)
print(f" tagged: {info.get('Tagged', '?')} · title: {info.get('Title') or '(none)'}"
f" · language: {language(pdf) or '(none)'}")
pages = info.get("Pages")
print(f" PDF {info.get('PDF version')}, {size[1] if size else info.get('Page size')}, {pages} page{'' if pages == '1' else 's'}")
print(f" fonts: {len(fonts)}, embedded {embedded}, with a Unicode map {unicode_map}")
print(f" claims: {', '.join(claims) or '(none)'}")
for name in sys.argv[1:]:
describe(name) from session import files, run
# build.sh builds the four PDFs of the report; then the commands of an audit, one at a time, on Typst's exports, and
# describe.py's summary of all four files. (2>/dev/null hides a warning poppler prints about an attribute of Typst's
# table tags.)
files("audit/report.tex", "audit/report.typ", "audit/make_chart.py", "build.sh", "describe.py", "tags.py")
run(r"""
sh build.sh
pdfinfo typst.pdf | grep -v -E "Date|File size"
pdffonts typst.pdf
pdfinfo -meta typst-ua1.pdf | grep -o -E "<pdfuaid:part>[^<]*|<dc:language><rdf:Bag><rdf:li>[^<]*"
pdfinfo -struct-text typst.pdf 2>/dev/null | python3 tags.py | head -6
python3 describe.py pdflatex.pdf lualatex.pdf typst.pdf typst-ua1.pdf
""") Output
$ sh build.sh
$ pdfinfo typst.pdf | grep -v -E "Date|File size"
Title: Monsoon rainfall at Station A
Author: Asha Verghese
Creator: Typst 0.15.1
Custom Metadata: no
Metadata Stream: yes
Tagged: yes
UserProperties: no
Suspects: no
Form: none
JavaScript: no
Pages: 1
Encrypted: no
Page size: 595.276 x 841.89 pts (A4)
Page rot: 0
Optimized: no
PDF version: 1.7
$ pdffonts typst.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AKGFGW+LibertinusSerif-Bold-Identity-H CID Type 0C Identity-H yes yes yes 40 0
UXIVUO+LibertinusSerif-Regular-Identity-H CID Type 0C Identity-H yes yes yes 43 0
JOHRZL+NewCMMath-Book-Identity-H CID Type 0C Identity-H yes yes yes 46 0
$ pdfinfo -meta typst-ua1.pdf | grep -o -E "<pdfuaid:part>[^<]*|<dc:language><rdf:Bag><rdf:li>[^<]*"
<dc:language><rdf:Bag><rdf:li>en-IN
<pdfuaid:part>1
$ pdfinfo -struct-text typst.pdf 2>/dev/null | python3 tags.py | head -6
Document
P "Monsoon rainfall at Station A"
H1 "1. Summary"
P "The official figures were checked by an efficient field team. Rainfall depth is , the volume collected over the area of the funnel."
Formula "𝑑 = 𝑉 / 𝐴"
Div
$ python3 describe.py pdflatex.pdf lualatex.pdf typst.pdf typst-ua1.pdf
pdflatex.pdf
tagged: no · title: (none) · language: (none)
PDF 1.7, letter, 1 page
fonts: 6, embedded 6, with a Unicode map 6
claims: (none)
lualatex.pdf
tagged: yes · title: Monsoon rainfall at Station A · language: en-IN
PDF 2.0, letter, 1 page
fonts: 6, embedded 6, with a Unicode map 6
claims: PDF/UA-2
typst.pdf
tagged: yes · title: Monsoon rainfall at Station A · language: en-IN
PDF 1.7, A4, 1 page
fonts: 3, embedded 3, with a Unicode map 3
claims: (none)
typst-ua1.pdf
tagged: yes · title: Monsoon rainfall at Station A · language: en-IN
PDF 1.7, A4, 1 page
fonts: 3, embedded 3, with a Unicode map 3
claims: PDF/UA-1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe_session.py
pdfinfo read the document information: the title, whether the file is tagged, the pages, the page size and the PDF
version. pdffonts listed each font with three columns that matter: emb, whether the font is embedded, sub,
whether only the glyphs used were kept, and uni, whether the font has a ToUnicode map: The table in a PDF that says which characters each glyph of a font stands for, so that its text can be copied, searched and read aloud. pdffonts prints yes in its uni column when a font has one. More in “Fonts, engines and Unicode”
that turns its glyphs back into text. pdfinfo -meta printed the XMP metadata: The block of XML inside a PDF that describes the file, such as its title, its author and, in a PDF/A file, the part and conformance level it claims. pdfinfo -meta prints it. More in “Archival PDF/A and final submission checks”, where a
file claims standards such as PDF/UA-1 and records its language. pdfinfo -struct-text printed the
structure tree: The tree of tags in a tagged PDF, such as headings, paragraphs, lists and links, in reading order, which screen readers follow. pdfinfo -struct-text prints it with the text of each tag. More in “A one-page resume in LaTeX that parses”, shortened here by tags.py. Step through where each command reads:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The basics. pdfinfo read Typst's export as tagged, one A4 page, PDF version 1.7, with the title Monsoon rainfall at Station A. That is the file's document information and its page boxes.
- The claims. pdfinfo -meta printed the XMP metadata of the ua-1 export, where the file records its language, en-IN, and claims a standard, pdfuaid:part 1.
- The fonts. pdffonts listed three fonts, each with yes under emb, sub and uni. A no under uni would mean glyphs that cannot be turned back into text.
- The tags. pdfinfo -struct-text printed the structure tree, which tags.py shortened to its tags and their text, a P for the title and an H1 for each section.
- The language. The language a screen reader uses is the catalog's /Lang. describe.py asked qpdf for the catalog and found en-IN in three files, and nothing in the untagged one.
- The whole file. Five readings, five parts of one file. An audit starts here, before any text is extracted, because a missing title or language is cheaper to fix than anything else.
describe.py puts the four answers side by side. The pdfLaTeX file is untagged and has no title and no language,
because nothing asked for them. The LuaLaTeX file claims PDF/UA-2 and is PDF 2.0 on US letter paper, the article
class’s default size; the Typst files are A4, and only the ua-1 export claims PDF/UA-1. All the fonts were embedded
with a Unicode map, so none of them would look different on another computer, and their text can be read back.
Checkpoint
Check: What a PDF says about itself
Three extractors read three different texts
A portal that parses applications, a search engine and a screen reader do not share one reader. poppler’s
pdftotext, MuPDF’s mutool and pdf.js, the reader of Firefox and of MySmartCoPilot’s PDF to Text tool, are three
such text extractor: A program that reads the text out of a PDF by rebuilding words and lines from the positions of the glyphs, such as poppler's pdftotext, MuPDF's mutool or pdf.js. Two extractors can read the same page in a different order or with different spaces., and each rebuilds the text from the glyphs’ positions in its
own way. This session runs all three on three of the builds and compares
them line by line:
"""Searches extracted text the way a reader's search box might, and the way a careful one should: for each file, says
whether the phrase is found as typed, found only after Unicode's NFKC normalisation (which turns mathematical italic
letters such as U+1D451 back into plain d), or not found at all. Spaces are compared as single spaces.
python3 search.py "d = V" poppler.txt other.txt
"""
import sys
import unicodedata
def squeeze(text):
return " ".join(text.split())
phrase = squeeze(sys.argv[1])
for name in sys.argv[2:]:
with open(name, encoding="utf-8") as file:
text = squeeze(file.read())
if phrase in text:
verdict = "found as typed"
elif unicodedata.normalize("NFKC", phrase) in unicodedata.normalize("NFKC", text):
verdict = "found only after NFKC"
else:
verdict = "not found"
print(f"{name}: {verdict}") from session import files, run
# The text that poppler (pdftotext), MuPDF (mutool) and pdf.js (pdfjs-text) read from three of the four PDFs, each kept
# to the lines that hold a letter or a digit: the number of lines in each, everything pdf.js read from the LuaLaTeX
# file and where poppler read it differently, where MuPDF read the LuaLaTeX file and the Typst file differently from
# poppler, the last line each extractor read from the LuaLaTeX file, the formula's line in each poppler text, and a
# search for "d = V".
files("audit/report.tex", "audit/report.typ", "audit/make_chart.py", "build.sh", "search.py")
run(r"""
sh build.sh
for f in pdflatex lualatex typst; do pdftotext $f.pdf - | grep '[[:alnum:]]' > $f-poppler.txt; done
for f in pdflatex lualatex typst; do mutool draw -q -F txt -o - $f.pdf | grep '[[:alnum:]]' > $f-mupdf.txt; done
for f in pdflatex lualatex typst; do pdfjs-text $f.pdf | grep '[[:alnum:]]' > $f-pdfjs.txt; done
wc -l *-poppler.txt *-mupdf.txt *-pdfjs.txt
cat lualatex-pdfjs.txt
!diff lualatex-poppler.txt lualatex-pdfjs.txt
!diff lualatex-poppler.txt lualatex-mupdf.txt
!diff typst-poppler.txt typst-mupdf.txt
tail -n 1 lualatex-poppler.txt lualatex-mupdf.txt lualatex-pdfjs.txt
grep -h "the volume" pdflatex-poppler.txt lualatex-poppler.txt typst-poppler.txt
python3 search.py "d = V" pdflatex-poppler.txt lualatex-poppler.txt typst-poppler.txt
""") Output
$ sh build.sh
$ for f in pdflatex lualatex typst; do pdftotext $f.pdf - | grep '[[:alnum:]]' > $f-poppler.txt; done
$ for f in pdflatex lualatex typst; do mutool draw -q -F txt -o - $f.pdf | grep '[[:alnum:]]' > $f-mupdf.txt; done
$ for f in pdflatex lualatex typst; do pdfjs-text $f.pdf | grep '[[:alnum:]]' > $f-pdfjs.txt; done
$ wc -l *-poppler.txt *-mupdf.txt *-pdfjs.txt
17 lualatex-poppler.txt
18 pdflatex-poppler.txt
15 typst-poppler.txt
19 lualatex-mupdf.txt
20 pdflatex-mupdf.txt
17 typst-mupdf.txt
13 lualatex-pdfjs.txt
13 pdflatex-pdfjs.txt
11 typst-pdfjs.txt
143 total
$ cat lualatex-pdfjs.txt
Monsoon rainfall at Station A
Asha Verghese
1 Summary
The official figures were checked by an efficient field team. Rainfall depth is
𝑑 = 𝑉 /𝐴, the volume collected over the area of the funnel.
Figure 1: Monthly rainfall at Station A.
Table 1: Two monsoon months.
Month Rain (mm) Rainy days
June 418 21
July 655 27
More at https://example.com/station-a/monsoon-rainfall-records.
Source: field notebooks of an invented station.
1
$ diff lualatex-poppler.txt lualatex-pdfjs.txt
9,14c9,10
< June
< 418
< 21
< July
< 655
< 27
---
> June 418 21
> July 655 27
[exit status 1]
$ diff lualatex-poppler.txt lualatex-mupdf.txt
2c2
< Asha Verghese
---
> Asha Verghese
4,5c4,5
< The official figures were checked by an efficient field team. Rainfall depth is
< 𝑑 = 𝑉 /𝐴, the volume collected over the area of the funnel.
---
> The official figures were checked by an efficient field team. Rainfall depth is
> 𝑑= 𝑉/𝐴, the volume collected over the area of the funnel.
8,14c8,16
< Month Rain (mm) Rainy days
< June
< 418
< 21
< July
< 655
< 27
---
> Month
> Rain (mm)
> Rainy days
> June
> 418
> 21
> July
> 655
> 27
[exit status 1]
$ diff typst-poppler.txt typst-mupdf.txt
3c3
< The official figures were checked by an efficient field team. Rainfall depth is 𝑑 = 𝑉 /𝐴, the volume
---
> The official figures were checked by an efficient field team. Rainfall depth is 𝑑= 𝑉/𝐴, the volume
6c6,8
< Month Rain (mm) Rainy days
---
> Month
> Rain (mm)
> Rainy days
[exit status 1]
$ tail -n 1 lualatex-poppler.txt lualatex-mupdf.txt lualatex-pdfjs.txt
==> lualatex-poppler.txt <==
1
==> lualatex-mupdf.txt <==
1
==> lualatex-pdfjs.txt <==
1
$ grep -h "the volume" pdflatex-poppler.txt lualatex-poppler.txt typst-poppler.txt
d = V /A, the volume collected over the area of the funnel.
𝑑 = 𝑉 /𝐴, the volume collected over the area of the funnel.
The official figures were checked by an efficient field team. Rainfall depth is 𝑑 = 𝑉 /𝐴, the volume
$ python3 search.py "d = V" pdflatex-poppler.txt lualatex-poppler.txt typst-poppler.txt
pdflatex-poppler.txt: found as typed
lualatex-poppler.txt: found only after NFKC
typst-poppler.txt: found only after NFKC
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extract_session.py
The same LuaLaTeX page came out as 17 lines from poppler, 19 from MuPDF and 13 from pdf.js. pdf.js put each table row
on one line, June 418 21; poppler gave every cell a line of its own; MuPDF also kept the spaces at the start of
lines and wrote the formula as 𝑑= 𝑉/𝐴. The formula is the difference a reader meets first. pdfLaTeX’s file holds
the plain letters d, V and A, while LuaLaTeX with unicode-math and Typst write mathematical italic letters, such
as U+1D451 for 𝑑, so a search for d = V found it in the pdfLaTeX text only. Unicode’s
NFKC: A Unicode normalisation form that replaces compatibility variants, such as mathematical italic letters, superscript digits and ligatures, with their plain characters, so that text compares and searches as people type it. More in “Inline and display maths” normalisation maps those letters back to plain ones, and search.py then found the
formula in the other two as well.
| What differed | poppler | MuPDF | pdf.js |
|---|---|---|---|
| Lines read from the LuaLaTeX page | 17 | 19 | 13 |
| A table row | one cell a line | one cell a line, with leading spaces | one row a line |
| The formula from LuaLaTeX | 𝑑 = 𝑉 /𝐴 |
𝑑= 𝑉/𝐴 |
𝑑 = 𝑉 /𝐴 |
| The page number of the LuaLaTeX page | read as text | read as text | read as text |
None of the three is wrong; they answer the question of reading order differently. The reader that matters is the one your reader uses, so extract with it, or with all three when you do not know. The Indian scripts lesson found far larger differences for Devanagari and Tamil.
Search for what a reader would type
Before sending a report to a portal, extract it and search for three things a reader would type: a name, a number from a table and a formula. A miss that only NFKC turns into a hit tells you to offer the formula in words as well.
Checkpoint
Check: Comparing text extractors
veraPDF checks what a machine can decide, and a person checks the rest
veraPDF validates a file against one profile at a time, and its documentation says that for PDF/UA only machine verifiable checks are performed. The session runs it on the four builds, then on two copies of the Typst report that a person would fail: one whose chart is described only as “chart”, and one that declares German for an English text:
from session import files, read, run, write
# veraPDF on the four PDFs, each against the PDF/UA part its system targets; then two copies of report.typ that pass
# the PDF/UA-1 export and veraPDF although a person would fail them: one whose chart is described only as "chart",
# and one that declares German for an English text.
files("audit/report.tex", "audit/report.typ", "audit/make_chart.py", "build.sh", "vera.py", "tags.py", "describe.py")
text = read("report.typ")
ALT = 'alt: "Bar chart of monthly rainfall: under 160 mm a month from January to May, 418 mm in June, a peak of 655 mm in July, then falling to 306 mm in September."'
LANG = '#set text(lang: "en", region: "IN")'
assert ALT in text and LANG in text
write("vague-alt.typ", text.replace(ALT, 'alt: "chart"'))
write("wrong-lang.typ", text.replace(LANG, '#set text(lang: "de")'))
run(r"""
sh build.sh
python3 vera.py ua2 pdflatex.pdf lualatex.pdf
python3 vera.py ua1 typst.pdf typst-ua1.pdf
typst compile --pdf-standard ua-1 vague-alt.typ vague-alt.pdf
typst compile --pdf-standard ua-1 wrong-lang.typ wrong-lang.pdf
python3 vera.py ua1 vague-alt.pdf wrong-lang.pdf
pdfinfo -struct-text vague-alt.pdf 2>/dev/null | python3 tags.py | grep -a Figure
python3 describe.py wrong-lang.pdf
""") Output
$ sh build.sh
$ python3 vera.py ua2 pdflatex.pdf lualatex.pdf
pdflatex.pdf: PDF/UA-2 + Tagged PDF validation profile: NOT compliant, 1719 rules passed, 8 failed
6.2-1 (1 check): MarkInfo dictionary is not present in the document catalog, or Marked entry is set to false or is not present in the MarkInfo dictionary (MarkInfo = null, Marked = null)
8.2.1-1 (1 check): StructTreeRoot entry is not present in the document catalog
8.2.2-1 (19 checks): Content is neither marked as Artifact nor tagged as real content
8.4.4-1 (1 check): Catalog dictionary does not contain Lang entry
8.8-2 (1 check): Destination in GoTo action is not a structure destination
8.9.3.3-1 (1 check): A page with annotation(s) contains Tabs key with value null instead of A, W or S
8.11.1-2 (1 check): The document catalog dictionary doesn't contain metadata key or metadata stream dictionary does not contain either entry Type with value /Metadata or entry Subtype with value /XML
8.11.2-1 (1 check): ViewerPreferences dictionary is not present in the document Catalog, or DisplayDocTitle key is set to false or is not present in the ViewerPreferences dictionary (ViewerPreferences = null, DisplayDocTitle = null)
lualatex.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
$ python3 vera.py ua1 typst.pdf typst-ua1.pdf
typst.pdf: PDF/UA-1 validation profile: NOT compliant, 104 rules passed, 2 failed
5-1 (1 check): The document metadata stream doesn't contain PDF/UA Identification Schema
7.1-10 (1 check): ViewerPreferences dictionary is not present in the document Catalog, or DisplayDocTitle key is set to false or is not present in the ViewerPreferences dictionary (ViewerPreferences = dictionary(size = 1), DisplayDocTitle = null)
typst-ua1.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
$ typst compile --pdf-standard ua-1 vague-alt.typ vague-alt.pdf
$ typst compile --pdf-standard ua-1 wrong-lang.typ wrong-lang.pdf
$ python3 vera.py ua1 vague-alt.pdf wrong-lang.pdf
vague-alt.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
wrong-lang.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
$ pdfinfo -struct-text vague-alt.pdf 2>/dev/null | python3 tags.py | grep -a Figure
Span "Figure 1: Monthly rainfall at Station A."
Figure [chart ]
$ python3 describe.py wrong-lang.pdf
wrong-lang.pdf
tagged: yes · title: Monsoon rainfall at Station A · language: de
PDF 1.7, A4, 1 page
fonts: 3, embedded 3, with a Unicode map 3
claims: PDF/UA-1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 validate_session.py
The untagged pdfLaTeX file failed 8 rules of PDF/UA-2: no structure tree, content in 19 places neither tagged nor an
artifact, no /Lang, no metadata stream and four more. The LuaLaTeX file passed all 1,727 rules, Typst’s default
export failed the two settings that ua-1 adds, and the ua-1 export passed all 106. Then both bad copies passed all
106 rules too. A rule can see that alternative text exists, not that “chart” says nothing; it can see that a language
is declared, not that the text is English. Typst’s guide says it does not check whether the configured language
matches the text, nor colour contrast.
What remains is a person’s work, in this order:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Describe. pdfinfo, pdffonts and the catalog first. The untagged pdfLaTeX build had no title and no language, which no later stage needs to discover.
- Extract. Read the text with the extractor your reader uses. pdf.js read 13 lines where poppler read 17, and the formula needed NFKC before a search found it.
- Validate. veraPDF against the profile the file claims. The untagged build failed 8 rules of PDF/UA-2; the LuaLaTeX build passed 1,727 and the ua-1 export 106.
- Contrast. colours.py found the grey source line,
- A person judges. The copy described only as chart and the copy that declared German both passed every rule. A person reads the descriptions, the language and the reading order.
- Back to the source. Every finding goes back to the source and the next build, never into the PDF by hand, so the next export does not bring it back.
- Listen. Typst's guide found Acrobat with NVDA on Windows, or with VoiceOver on a Mac, the most capable pairs. Listen to one page of every kind of content.
Key idea
A validator answers “is this well formed?”, not “can someone use this?”: run it on every file, then have a person judge the descriptions, the language, the reading order and the contrast, and listen to the file with a screen reader.
Checkpoint
Check: Machine checks and a person's checks
Contrast is a ratio you can compute
WCAG 2.2 sets the numbers. Success criterion 1.4.3 asks for a contrast ratio: WCAG's measure of how far apart two colours are in luminance, from 1:1 to 21:1, worked out as (L1 + 0.05) / (L2 + 0.05). Text needs at least 4.5:1, large text and graphics 3:1. More in “Accessible slides, posters and handouts” of at
least 4.5:1 for text, and 3:1 for large text, which it defines as 18 point, or 14 point bold. Criterion 1.4.11 asks
3:1 for the parts of graphics people need to see, and 1.4.1 forbids leaving colour as the one way a reader can tell
two things apart. First find the colours
the file really uses: mutool trace describes every drawing operation, and colours.py lists the colours of text:
"""Lists the colours a PDF's text is drawn in, from the trace that `mutool trace` prints on standard input: each colour
as a hex code, how many characters it draws, and the first characters it draws, so that each colour can be checked
against its background.
mutool trace report.pdf | python3 colours.py
"""
import re
import sys
import xml.etree.ElementTree as ElementTree
def hex_colour(space, values):
numbers = [float(value) for value in values.split()]
if "Gray" in space and len(numbers) == 1:
numbers = numbers * 3
return "#" + "".join(f"{round(number * 255):02x}" for number in numbers[:3])
trace = sys.stdin.read()
trace = re.sub(r"<\?xml[^>]*\?>", "", trace)
root = ElementTree.fromstring(f"<trace>{trace}</trace>")
colours = {}
for fill in root.iter("fill_text"):
colour = hex_colour(fill.get("colorspace", ""), fill.get("color", "0"))
text = "".join(glyph.get("unicode", "") for glyph in fill.iter("g"))
count, first = colours.get(colour, (0, ""))
colours[colour] = (count + len(text), first if len(first) >= 40 else (first + text)[:40])
for colour, (count, first) in colours.items():
print(f"{colour} {count:4} characters {first.strip()}") from session import files, run
# The colours that the text of two of the PDFs is drawn in, listed by colours.py from what mutool trace prints.
files("audit/report.tex", "audit/report.typ", "audit/make_chart.py", "build.sh", "colours.py")
run(r"""
sh build.sh
mutool trace typst.pdf | python3 colours.py
mutool trace lualatex.pdf | python3 colours.py
""") Output
$ sh build.sh $ mutool trace typst.pdf | python3 colours.py #000000 347 characters Monsoon rainfall at Station A1.SummaryTh #999999 47 characters Source: field notebooks of an invented s $ mutool trace lualatex.pdf | python3 colours.py #000000 377 characters Monsoon rainfall at Station A Asha Vergh #999999 47 characters Source: field notebooks of an invented s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 colour_session.py
Both files draw 47 characters in #999999: the grey source line at the foot of the report. The ratio is
(L1 + 0.05) / (L2 + 0.05), the relative luminance: The brightness of a colour as WCAG computes it, from 0 for black to 1 for white: each sRGB channel is made linear and the three are weighted 0.2126, 0.7152 and 0.0722 for red, green and blue. of the lighter colour over that
of the darker one. Here it is in code for the report’s pairs; press Edit, change a colour, and run it again:
"""WCAG 2.2's contrast ratio for the colour pairs of this lesson's report, with the verdict for each kind of content:
normal text needs 4.5:1, large text (18 pt, or 14 pt bold) and the parts of graphics people need to see need 3:1.
Change a colour and run it again."""
def luminance(hex_colour):
"""WCAG's relative luminance of an sRGB colour written as #rrggbb."""
channels = []
for i in (1, 3, 5):
value = int(hex_colour[i:i + 2], 16) / 255
channels.append(value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4)
red, green, blue = channels
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
def ratio(foreground, background):
"""(L1 + 0.05) / (L2 + 0.05), the lighter colour's luminance over the darker one's."""
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
return (lighter + 0.05) / (darker + 0.05)
PAIRS = [
("body text", "#000000", "#ffffff", 4.5),
("the source line", "#999999", "#ffffff", 4.5),
("the source line, darker", "#767676", "#ffffff", 4.5),
("a large heading in grey", "#999999", "#ffffff", 3.0),
("the chart's bars", "#1f5fa8", "#ffffff", 3.0),
("the chart's axis", "#787878", "#ffffff", 3.0),
]
for name, foreground, background, needed in PAIRS:
value = ratio(foreground, background)
verdict = "passes" if value >= needed else "fails"
print(f"{name}: {foreground} on {background} is {value:.2f}:1, {verdict} {needed}:1") Output
body text: #000000 on #ffffff is 21.00:1, passes 4.5:1 the source line: #999999 on #ffffff is 2.85:1, fails 4.5:1 the source line, darker: #767676 on #ffffff is 4.54:1, passes 4.5:1 a large heading in grey: #999999 on #ffffff is 2.85:1, fails 3.0:1 the chart's bars: #1f5fa8 on #ffffff is 6.44:1, passes 3.0:1 the chart's axis: #787878 on #ffffff is 4.42:1, passes 3.0:1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 contrast.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The grey line reached 2.85:1, far below 4.5:1. #767676 reached 4.54:1, the lightest grey of equal red, green and blue that passes on white,
because its luminance is 0.181 and (1.05) / (0.181 + 0.05) is 4.54. The chart’s bars, at 6.44:1, pass the 3:1 of
1.4.11 with room to spare, and so does its axis at 4.42:1. A grey that fails as small text fails as a large heading
too: 2.85:1 is below even 3:1.
A legend told by colour alone
Wrong: two stations drawn as a blue line and an orange line, told apart only by a coloured key. A reader who cannot tell the colours apart, or who prints in grey, loses the comparison, which is what 1.4.1 rules out. Right: a different marker or dash pattern for each line, and the station’s name next to its line; the Typst guide suggests patterns or high-contrast borders.
Checkpoint
Check: Colour contrast and colour alone
Practise: compare two extractions and check a colour pair
Two small checks for an audit script. The first finds the first line where two extractions of one PDF differ, once spacing is set aside; the second computes WCAG’s contrast ratio for a pair of colours and says whether it meets the level for normal or large text. Their sample tests use this lesson’s outputs and run right here; a first hint waits under each step:
Code lab · 2 steps
An audit in two checks
Find where two extractions of a PDF first differ, then check a colour pair against WCAG 2.2's contrast levels.
- Find where two extractions of a PDF first differ To do
- Check a colour pair against WCAG's contrast levels To do
Exercise · Easy · Python
Find where two extractions of a PDF first differ
An audit compares the text that two extractors read from one PDF, and the first line where they disagree is where to look. Write first_difference(a, b) in difference.py. a and b are two extracted texts. Compare them line by line after two clean-ups, so that only differences a reader would notice remain:
- leave out every line that holds nothing but spaces;
- in each line, turn every run of spaces into one space and drop the spaces at both ends.
Return None when the two cleaned texts are the same. Otherwise return a tuple (n, line_a, line_b): n is the number of the first cleaned line that differs, counting from 1, and the two cleaned lines themselves. When one text runs out first, its line is "". For the lesson's LuaLaTeX page, poppler and pdf.js first differ at line 9: (9, "June", "June 418 21").
Starter code · difference.py
def first_difference(a, b):
"""Return (line number, line of a, line of b) for the first cleaned line that differs, or None."""
# Replace this line: clean both texts, then compare them line by line.
return None The sample tests · test_difference.py
from difference import first_difference
POPPLER = """Monsoon rainfall at Station A
Asha Verghese
1 Summary
The official figures were checked by an efficient field team. Rainfall depth is
𝑑 = 𝑉 /𝐴, the volume collected over the area of the funnel.
Figure 1: Monthly rainfall at Station A.
Table 1: Two monsoon months.
Month Rain (mm) Rainy days
June
418
21
"""
PDFJS = """Monsoon rainfall at Station A
Asha Verghese
1 Summary
The official figures were checked by an efficient field team. Rainfall depth is
𝑑 = 𝑉 /𝐴, the volume collected over the area of the funnel.
Figure 1: Monthly rainfall at Station A.
Table 1: Two monsoon months.
Month Rain (mm) Rainy days
June 418 21
"""
def test_lesson_pages():
"""finds the table row of the lesson's LuaLaTeX page at line 9"""
assert first_difference(POPPLER, PDFJS) == (9, "June", "June 418 21")
def test_same_text():
"""returns None for the same text"""
assert first_difference(POPPLER, POPPLER) is None
def test_spaces_do_not_count():
"""ignores leading spaces and runs of spaces, as MuPDF writes them"""
mupdf = POPPLER.replace("Asha Verghese", " Asha Verghese ").replace("Month Rain", " Month Rain")
assert first_difference(POPPLER, mupdf) is None
def test_blank_lines_do_not_count():
"""ignores lines of spaces only, so the numbers count text lines"""
assert first_difference("Title\n\n \nText\n", "Title\nText\nMore\n") == (3, "", "More")
def test_formula_spacing():
"""keeps a difference inside a line, such as MuPDF's formula"""
a = "Rainfall depth is 𝑑 = 𝑉 /𝐴, the volume\n"
b = "Rainfall depth is 𝑑= 𝑉/𝐴, the volume\n"
assert first_difference(a, b) == (1, "Rainfall depth is 𝑑 = 𝑉 /𝐴, the volume", "Rainfall depth is 𝑑= 𝑉/𝐴, the volume")
def test_first_text_longer():
"""returns an empty line for the text that ran out"""
assert first_difference("one\ntwo\n", "one\n") == (2, "two", "") A hint
" ".join(line.split()) cleans a line, and a list comprehension keeps the lines that are not empty after it. Then walk both lists with an index up to the longer length, using "" for a line past the end of the shorter one.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Check a colour pair against WCAG's contrast levels
Write check_pair(foreground, background, large=False) in pairs.py. The colours are written #rrggbb, in either case. Return a tuple (ratio, verdict): ratio is WCAG 2.2's contrast ratio rounded to two decimal places, and verdict is "passes" when the ratio meets the level for the text, 4.5 for normal text or 3 when large is True, and "fails" otherwise. Compare the unrounded ratio with the level.
The ratio is (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter colour and L2 that of the darker. A colour's luminance is 0.2126 * R + 0.7152 * G + 0.0722 * B, where each of R, G and B comes from its channel's value c from 0 to 1 (the two hex digits divided by 255): c / 12.92 when c <= 0.04045, else ((c + 0.055) / 1.055) ** 2.4. The order of the two colours does not matter.
The lesson's grey source line gives check_pair("#999999", "#ffffff") == (2.85, "fails").
Starter code · pairs.py
def check_pair(foreground, background, large=False):
"""Return (WCAG contrast ratio to two decimals, "passes" or "fails") for normal or large text."""
# Replace this line: compute both luminances, the ratio, then compare it with 4.5 or 3.
return (0.0, "fails") The sample tests · test_pairs.py
from pairs import check_pair
def test_grey_source_line():
"""fails the lesson's #999999 source line on white, at 2.85:1"""
assert check_pair("#999999", "#ffffff") == (2.85, "fails")
def test_darker_grey():
"""passes #767676 on white, at 4.54:1"""
assert check_pair("#767676", "#ffffff") == (4.54, "passes")
def test_just_below():
"""fails #777777, which rounds to 4.48:1, just under 4.5"""
assert check_pair("#777777", "#ffffff") == (4.48, "fails")
def test_black_on_white():
"""gives the largest ratio there is, 21:1"""
assert check_pair("#000000", "#ffffff") == (21.0, "passes")
def test_order_and_case():
"""gives the same answer with the colours swapped and in capitals"""
assert check_pair("#FFFFFF", "#1F5FA8") == (6.44, "passes")
def test_large_text():
"""uses 3:1 for large text, where #949494 passes and #999999 still fails"""
assert check_pair("#949494", "#ffffff", large=True) == (3.03, "passes")
assert check_pair("#999999", "#ffffff", large=True) == (2.85, "fails")
def test_dark_channel():
"""uses c / 12.92 for a channel at or below 0.04045, as in #0a0a0a"""
assert check_pair("#0a0a0a", "#000000") == (1.06, "fails") A hint
Read a channel with int(colour[1:3], 16) / 255 (then [3:5] and [5:7]). Compute both luminances, put the larger one first with max and min, and round only the value you return: round(ratio, 2).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the four checks of an audit, from the metadata to the colours. A wrong choice tells you which slip it was, and the session keeps returning to the check you are least sure of:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what can veraPDF tell you about a PDF’s accessibility, and what can it not?
veraPDF validates a file against one profile, such as PDF/UA-1 or PDF/UA-2, and reports every rule it breaks with the clause of the standard: a missing structure tree, a missing language, content neither tagged nor an artifact, a missing title. Its documentation says that for PDF/UA it performs only machine verifiable checks. So it cannot tell whether the alternative text describes the picture, whether the declared language is the text’s language, whether the reading order makes sense or whether the colours have enough contrast. In this lesson, a copy described only as “chart” and a copy that declared German for English text both passed all 106 rules of PDF/UA-1. A pass is where an audit starts, followed by a person’s checks and a screen reader.
Two harder questions follow: an audit plan for an office that publishes hundreds of PDFs a year, and a formula that a library’s search cannot find. Decide your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
A public office publishes about 400 PDFs a year, made in LaTeX, Typst and word processors, and must show that they meet PDF/UA. Design the audit: what a machine checks on every file, what a person checks, how much of the output a person sees, and what happens when a file fails.
-
Fresher to mid
Readers say the library's search cannot find the formula d = V/A in your report's PDF, although they can see it on the page. Work out why, and fix it so that readers and the search both find it.
Key takeaways
pdfinfo,pdfinfo -meta,pdffontsandpdfinfo -struct-textdescribe a PDF’s title, tags, standards, fonts and structure before anyone opens it; the untagged pdfLaTeX build had no title and no language.- poppler, MuPDF and pdf.js read 17, 19 and 13 lines from the same page, and mathematical italic letters kept a search
for
d = Vfrom finding the formula until NFKC normalised the text. - veraPDF passed a chart described only as “chart” and a document declaring the wrong language: it checks what a machine can decide, and a person has to judge the rest.
- Contrast is (L1 + 0.05) / (L2 + 0.05): the report’s grey source line reached 2.85:1 against the 4.5:1 that text needs, and meaning must never be carried by colour alone.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which four commands describe a PDF before anyone opens it?
pdfinfo for the title, tags, pages and version; pdffonts for the fonts; pdfinfo -meta for the XMP metadata; and pdfinfo -struct-text for the structure tree.
-
What do pdffonts' emb, sub and uni columns say?
Whether a font is embedded, whether only the glyphs used were kept, and whether it has a ToUnicode map that turns its glyphs back into text.
-
Why read a PDF with more than one extractor?
poppler, MuPDF and pdf.js rebuild the reading order each in their own way: the same page gave 17, 19 and 13 lines, and only pdf.js put a table row on one line.
-
Why did a search for d = V miss the formula in the LuaLaTeX and Typst PDFs?
Their text holds mathematical italic characters, U+1D451 for the d for example. Once NFKC normalisation had turned them into plain letters, the search found the formula.
-
What can veraPDF not judge?
Whether alt text describes the picture, whether the declared language is the text's, whether the reading order makes sense, and colour contrast: a chart described only as "chart" passed.
-
How is a contrast ratio computed?
(L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter colour and L2 that of the darker one.
-
Why did the report's grey source line fail even as large text?
Its #999999 on white reached 2.85:1, below the 4.5:1 that text needs and below the 3:1 that WCAG 2.2 allows for large text, 18 point or 14 point bold.
-
Which equal-RGB grey is the lightest that passes 4.5:1 on white?
#767676, at 4.54:1. The report's #999999 source line reached only 2.85:1.
Terms in this lesson
- relative luminance
- The brightness of a colour as WCAG computes it, from 0 for black to 1 for white: each sRGB channel is made linear and the three are weighted 0.2126, 0.7152 and 0.0722 for red, green and blue.
- text extractor
- A program that reads the text out of a PDF by rebuilding words and lines from the positions of the glyphs, such as poppler's pdftotext, MuPDF's mutool or pdf.js. Two extractors can read the same page in a different order or with different spaces.
References
- Poppler (freedesktop.org)
- MuPDF: mutool (Artifex)
- PDF.js (Mozilla)
- qpdf manual: JSON output (qpdf)
- veraPDF: validation (veraPDF Consortium)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Typst guide: accessibility (Typst)
- Unicode Standard Annex #15: Unicode normalization forms (Unicode Consortium)
- PDF16: setting the default language using the /Lang entry in the document catalog of a PDF document (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress