LaTeX & Typst (papers, theses, resumes and slides) Module 11 – Accessible, checked and safe PDFs
Tagged, accessible PDFs from LaTeX
Switch on tagging with \DocumentMetadata, build with LuaLaTeX, add alt text, header rows, title and language, check packages and pass PDF/UA-2.
What you will learn
- Switch on tagging with \DocumentMetadata and target PDF/UA-2
- Choose LuaLaTeX for tagged documents and explain what pdfLaTeX still misses
- Add alternative text, a table header row, a title and the document language
- Check every package against the tagging status list before you start
Before you start
What you’ll learn · Before you start
What you will learn
- Switch on tagging with \DocumentMetadata and target PDF/UA-2
- Choose LuaLaTeX for tagged documents and explain what pdfLaTeX still misses
- Add alternative text, a table header row, a title and the document language
- Check every package against the tagging status list before you start
Before you start
On this page
A screen reader finds the headings, the table cells and the description of a figure in a PDF only through its tags. In this lesson’s builds, one LaTeX report passed all 1,727 rules of veraPDF’s PDF/UA-2 profile after a single LuaLaTeX run, while pdfLaTeX’s first run of the same file left content in 133 places neither tagged nor marked as decoration.
You will switch tagging on with one line above \documentclass, choose the engine, add the alternative text, the
header row, the title and the language that only you can give, and check your packages before you start. Every build
was recorded with TeX Live 2026, read back with poppler and validated with veraPDF 1.30.
One line above \documentclass switches tagging on
The whole switch is the first line of report.tex: \DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on}.
LaTeX’s tagging instructions place \DocumentMetadata before \documentclass: it sets up the document’s metadata
and the standards it should meet before the class and the packages are read. Its keys say what to build: lang the document’s main language, pdfstandard the standard the file will claim, here
PDF/UA: ISO 14289, the standard for accessible PDF files: tagged content, a title and a structure that assistive software can follow. PDF/UA-1 builds on PDF 1.7 and PDF/UA-2 on PDF 2.0. More in “A one-page resume in LaTeX that parses”-2, and tagging=on that every paragraph, heading, figure and table is tagged as it is
typeset. The rest of the report is ordinary LaTeX, with one line for the table that the third section explains:
% report.tex: a one-page report with a figure, a table, a formula and a link, tagged for PDF/UA-2.
% Build it twice with LuaLaTeX (the second run completes the MathML of the formula); pdfLaTeX builds it too.
\DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on}
\documentclass{article}
\ifdefined\directlua \usepackage{unicode-math}\fi % LuaLaTeX writes MathML for formulas with unicode-math
\usepackage{graphicx}
\usepackage{hyperref}
\title{Monsoon rainfall at Station A}
\author{Asha Verghese}
\date{}
\begin{document}
\maketitle
\section{Summary}
Most of the rain at Station A, an invented station, falls from June to September.
\begin{figure}[ht]
\centering
\includegraphics[width=0.6\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}
\section{The wettest months}
\tagpdfsetup{table/header-rows=1}
\begin{table}[ht]
\centering
\caption{The four monsoon months.}
\begin{tabular}{lrr}
Month & Rain (mm) & Rainy days \\
June & 418 & 21 \\
July & 655 & 27 \\
August & 541 & 25 \\
September & 306 & 17 \\
\end{tabular}
\end{table}
The total for the four months is $418 + 655 + 541 + 306 = 1920$~mm. The data are described at
\url{https://example.com/station-a}.
\end{document} from session import files, read, run, write
# report.tex built twice by LuaLaTeX: what pdfinfo reads back, the language and the PDF/UA claim inside the file, and
# veraPDF's verdict against PDF/UA-2. Last, a copy with a second \DocumentMetadata line above the first.
files("report/report.tex", "report/make_chart.py", "vera.py")
write("twice.tex", "\\DocumentMetadata{tagging=on}\n" + read("report.tex"))
run(r"""
python3 make_chart.py
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
pdfinfo report.pdf | grep -E "^(Title|Tagged|PDF version)"
qpdf --qdf --object-streams=disable report.pdf uncompressed.pdf
grep -a "/Lang" uncompressed.pdf
pdfinfo -meta report.pdf | grep -E "pdfuaid:part|<rdf:li>en-IN"
python3 vera.py ua2 report.pdf
!lualatex -interaction=nonstopmode -halt-on-error twice.tex > /dev/null
grep -E "^! " twice.log
""") Output
$ python3 make_chart.py
$ lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ pdfinfo report.pdf | grep -E "^(Title|Tagged|PDF version)"
Title: Monsoon rainfall at Station A
Tagged: yes
PDF version: 2.0
$ qpdf --qdf --object-streams=disable report.pdf uncompressed.pdf
$ grep -a "/Lang" uncompressed.pdf
/Lang (en-IN)
$ pdfinfo -meta report.pdf | grep -E "pdfuaid:part|<rdf:li>en-IN"
<pdfuaid:part>2</pdfuaid:part>
<rdf:li>en-IN</rdf:li>
$ python3 vera.py ua2 report.pdf
report.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
$ lualatex -interaction=nonstopmode -halt-on-error twice.tex > /dev/null
[exit status 1]
$ grep -E "^! " twice.log
! LaTeX Error: Two \DocumentMetadata declarations.
! ==> Fatal error occurred, no output PDF file produced!
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 metadata_session.py
The figure is a 360-pixel bar chart that make_chart.py draws as pixels only, so the PDF can say what it shows only
through its alternative text. pdfinfo read the result back: a tagged PDF: A PDF that also stores its structure, such as headings, paragraphs and reading order, so screen readers and text extraction can follow it. Typst writes tagged PDF by default. More in “LaTeX and Typst: what they are and when to use each” of PDF version
2.0, with the title from \title. The catalog holds /Lang (en-IN), 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”
holds pdfuaid:part 2 and dc:language, and veraPDF found every rule of the profile met. Step through where each key
went:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Before the class. The line comes first, above \documentclass, as LaTeX's tagging instructions place it. It loads the PDF management code before the class and the packages, so they all write into the same file.
- A newer kind of PDF. pdfinfo read PDF version 2.0 from report.pdf. Overleaf's notes on TeX Live 2026 add that the line also switches on T1 font encoding.
- The language. lang=en-IN became the catalog entry /Lang (en-IN), which a screen reader uses to choose its voice, and dc:language in the metadata.
- The claim. pdfstandard=ua-2 wrote pdfuaid:part 2 into the XMP metadata. That is a claim, which veraPDF then checks against the PDF/UA-2 profile.
- The tags. tagging=on made LaTeX record what each piece of the page is while it typeset it, and write it out as the structure tree at the end of the run.
- The verdict. veraPDF passed all 1,727 rules of the PDF/UA-2 profile. The rules can only see what the file holds, so the next sections add what only you know.
The values of lang are BCP 47 language tag: A code for a language, with its region or script when needed, such as en-IN for English as written in India, hi for Hindi or ta for Tamil. PDFs, web pages and the language settings of LaTeX and Typst use these codes. language tags, the codes that also name regional forms, such
as en-IN for English as written in India or hi for Hindi. If you leave the language out, LaTeX2e News 43 says the
code silently takes the main language of babel or polyglossia, or en when neither is loaded, so a document in Hindi
that loads neither package would claim to be English. The W3C’s technique for the catalog’s /Lang gives the reason it
matters: screen readers can load the correct pronunciation rules.
language and other-languages need an up-to-date TeX Live 2026
LaTeX2e News 43 adds a language key, which takes a BCP 47 tag and also sets the PDF’s language, and
other-languages={hi,ta} for the other languages a document uses. They come with the LaTeX release that News 43
describes, which reaches TeX Live 2026 as an update; the pinned TinyTeX 2026.10 has it. lang works in earlier
releases too. An Overleaf project keeps the TeX Live version it was created with until you change it in its
settings, so an older project has older tagging code.
Because the line comes first and only once, it belongs to whoever owns the top of the file. A template that ships the line leaves it out of the documents built on it, and a document that needs another key changes the template’s line instead of adding a second one:
A second \DocumentMetadata, or one below the class
Wrong: a template that starts with \DocumentMetadata{tagging=on} and a document that adds its own line above it.
The session’s copy stopped with “Two \DocumentMetadata declarations” and wrote no PDF: LaTeX2e News 43 made a second
use an error, as a second \documentclass always was. Right: one line, first in the file, with every key in it.
Checkpoint
Check: Switching tagging on with \DocumentMetadata
LuaLaTeX tags in one run; pdfLaTeX needs two and drops spaces
The tagging instructions name both pdfLaTeX and LuaLaTeX, and call LuaLaTeX the preferred engine for new documents.
The tagpdf manual gives the reasons: LuaLaTeX requires fewer compilations, while pdfLaTeX needs more of them to resolve
the cross-references that tagging creates. The first session builds the same report with each engine and checks the
PDF of each run; the second builds two copies, one with a bare \hrule under the title and one without unicode-math:
from session import files, run
# The same report built by pdfLaTeX (report.tex) and by LuaLaTeX (lua.tex, a copy, so each engine starts in a folder
# of its own files), with a copy of each engine's first PDF kept before its second run; after LuaLaTeX's first run, the
# MathML file it wrote is listed. veraPDF checks all four files against PDF/UA-2; then how each final structure tree
# reads the words around the line break after "The", and how many formulas carry MathML in pdfLaTeX's final file and
# in LuaLaTeX's first and final files.
files("report/report.tex", "report/make_chart.py", "vera.py")
run(r"""
python3 make_chart.py
cp report.tex lua.tex
pdflatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
cp report.pdf pdflatex-run1.pdf
pdflatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error lua.tex > /dev/null
cp lua.pdf lualatex-run1.pdf
ls *mathml.html
lualatex -interaction=nonstopmode -halt-on-error lua.tex > /dev/null
python3 vera.py ua2 pdflatex-run1.pdf report.pdf lualatex-run1.pdf lua.pdf
pdfinfo -struct-text report.pdf | grep -a "data are"
pdfinfo -struct-text lua.pdf | grep -a "data are"
qpdf --qdf --object-streams=disable report.pdf report-qdf.pdf
qpdf --qdf --object-streams=disable lualatex-run1.pdf lua-run1-qdf.pdf
qpdf --qdf --object-streams=disable lua.pdf lua-qdf.pdf
!grep -a -c "application#2fmathml+xml" report-qdf.pdf
!grep -a -c "application#2fmathml+xml" lua-run1-qdf.pdf
grep -a -c "application#2fmathml+xml" lua-qdf.pdf
""") Output
$ python3 make_chart.py
$ cp report.tex lua.tex
$ pdflatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ cp report.pdf pdflatex-run1.pdf
$ pdflatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error lua.tex > /dev/null
$ cp lua.pdf lualatex-run1.pdf
$ ls *mathml.html
lua-luamml-mathml.html
$ lualatex -interaction=nonstopmode -halt-on-error lua.tex > /dev/null
$ python3 vera.py ua2 pdflatex-run1.pdf report.pdf lualatex-run1.pdf lua.pdf
pdflatex-run1.pdf: PDF/UA-2 + Tagged PDF validation profile: NOT compliant, 1726 rules passed, 1 failed
8.2.2-1 (133 checks): Content is neither marked as Artifact nor tagged as real content
report.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
lualatex-run1.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
lua.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
$ pdfinfo -struct-text report.pdf | grep -a "data are"
"mm. Thedata are described at"
$ pdfinfo -struct-text lua.pdf | grep -a "data are"
" mm. The data are described at "
$ qpdf --qdf --object-streams=disable report.pdf report-qdf.pdf
$ qpdf --qdf --object-streams=disable lualatex-run1.pdf lua-run1-qdf.pdf
$ qpdf --qdf --object-streams=disable lua.pdf lua-qdf.pdf
$ grep -a -c "application#2fmathml+xml" report-qdf.pdf
0
[exit status 1]
$ grep -a -c "application#2fmathml+xml" lua-run1-qdf.pdf
0
[exit status 1]
$ grep -a -c "application#2fmathml+xml" lua-qdf.pdf
1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 engines_session.py
from session import files, read, run, write
# Two more copies of report.tex: one with a bare \hrule under the title, built twice by pdfLaTeX and once by LuaLaTeX
# (rule-lua.tex), and one without unicode-math, built twice by LuaLaTeX, with its warning and its count of formulas
# that carry MathML.
files("report/report.tex", "report/make_chart.py", "vera.py")
write("rule.tex", read("report.tex").replace("\\maketitle\n", "\\maketitle\n\\hrule\n"))
run(r"""
python3 make_chart.py
cp rule.tex rule-lua.tex
pdflatex -interaction=nonstopmode -halt-on-error rule.tex > /dev/null
pdflatex -interaction=nonstopmode -halt-on-error rule.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error rule-lua.tex > /dev/null
python3 vera.py ua2 rule.pdf rule-lua.pdf
sed '/unicode-math/d' report.tex > plain-maths.tex
lualatex -interaction=nonstopmode -halt-on-error plain-maths.tex | grep -A1 "Package tagpdf Warning"
lualatex -interaction=nonstopmode -halt-on-error plain-maths.tex > /dev/null
qpdf --qdf --object-streams=disable plain-maths.pdf plain-maths-qdf.pdf
!grep -a -c "application#2fmathml+xml" plain-maths-qdf.pdf
""") Output
$ python3 make_chart.py $ cp rule.tex rule-lua.tex $ pdflatex -interaction=nonstopmode -halt-on-error rule.tex > /dev/null $ pdflatex -interaction=nonstopmode -halt-on-error rule.tex > /dev/null $ lualatex -interaction=nonstopmode -halt-on-error rule-lua.tex > /dev/null $ python3 vera.py ua2 rule.pdf rule-lua.pdf rule.pdf: PDF/UA-2 + Tagged PDF validation profile: NOT compliant, 1726 rules passed, 1 failed 8.2.2-1 (1 check): Content is neither marked as Artifact nor tagged as real content rule-lua.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed $ sed '/unicode-math/d' report.tex > plain-maths.tex $ lualatex -interaction=nonstopmode -halt-on-error plain-maths.tex | grep -A1 "Package tagpdf Warning" Package tagpdf Warning: Neither unicode-math nor lua-unicode-math has been (tagpdf) loaded. $ lualatex -interaction=nonstopmode -halt-on-error plain-maths.tex > /dev/null $ qpdf --qdf --object-streams=disable plain-maths.pdf plain-maths-qdf.pdf $ grep -a -c "application#2fmathml+xml" plain-maths-qdf.pdf 0 [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rule_session.py
| What the session measured | pdfLaTeX | LuaLaTeX |
|---|---|---|
| PDF/UA-2 after the first run | 1 rule failed, in 133 places | all 1,727 rules passed |
| PDF/UA-2 after the second run | all 1,727 rules passed | all 1,727 rules passed |
| The words around the line break after “The”, in the tags | Thedata |
The data |
| Formulas carrying MathML | 0 | 0 after the first run, 1 after the second, with unicode-math loaded |
A bare \hrule under the title |
rule 8.2.2-1 failed, after two runs | all 1,727 rules passed, after one run |
Which engine
Build new tagged documents with LuaLaTeX, at least twice. Keep pdfLaTeX only for a document that cannot move yet, build it at least twice too, and read its structure text for joined words and unmarked decoration.
Three of those rows are differences you would never see on the page. Tagging makes pdfTeX put a space glyph between
words, a real space glyph: A space character drawn from a font between two words, which a PDF stores so that copying, search and screen readers can find where one word ends. TeX itself only moves on between words; with tagging on, pdfTeX and LuaTeX add these glyphs. that text extraction and screen readers can find, but the
space at the end of a line is not written, so poppler read the tags as Thedata. LuaLaTeX wrote the space. LuaLaTeX
with unicode-math also attached MathML: The W3C's markup language for mathematics, which records a formula's structure, such as which part is a fraction or a power, so that browsers can lay it out and assistive technology can read it. More in “Maths fonts, symbol lookup and accessible equations” to the formula, which a screen reader can read as maths,
but only on its second run: the first wrote the MathML to lua-luamml-mathml.html, and its PDF carried none. The
pdfLaTeX file carried none at all. And the tagpdf manual says LuaLaTeX’s Lua mode marks everything left unmarked as an
artifact: Content of a tagged PDF that is not part of the document's text, such as a page number, a decorative rule or a background, marked so that screen readers and text extraction can skip it. More in “A one-page resume in LaTeX that parses”, so the bare rule passed, while pdfLaTeX left it as content that is neither
tagged nor decoration: rule 8.2.2-1.
LuaLaTeX without unicode-math
Wrong: switching to LuaLaTeX and keeping the old maths setup. The copy without unicode-math printed “Neither unicode-math nor lua-unicode-math has been loaded” and attached no MathML at all. Right: load unicode-math (or lua-unicode-math) and build twice; the accessible maths lesson shows the MathML it writes.
Checkpoint
Check: LuaLaTeX and pdfLaTeX for tagged PDFs
Only you know the description, the header row, the title and the language
The tagging code tags what LaTeX’s commands tell it: a \section becomes an H1, a figure a Note with a Caption. It
cannot know what a picture shows, which row of a table holds the headings, what the document is called or which
language it is in. tags.py, from the alternative text lesson,
prints 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” that poppler reads; the session then builds two copies that
each leave one of those out:
"""Prints the structure tree that `pdfinfo -struct-text` reads from a tagged PDF, given on standard input, in short:
one line per tag, indented as in the tree, with the alternative text of a Figure in square brackets and the text of
the tag in quotes. The numbers poppler gives each tag, its attributes, its references to link annotations and its empty
text runs are left out.
pdfinfo -struct-text report.pdf | python3 tags.py
"""
import re
import sys
tags = [] # [depth, tag, alt, text]
# pdfinfo prints some titles as raw UTF-16 bytes; they are not text a reader hears, so they are dropped.
for raw in sys.stdin.buffer.read().decode("utf-8", errors="replace").splitlines():
body = raw.lstrip(" ")
depth = (len(raw) - len(body)) // 2
if not body or body.startswith("/") or body.startswith("Syntax") or body.startswith("Object "):
continue
if body.startswith('"'):
text = body.strip().strip('"').strip()
# A run of text belongs to the nearest tag above it that is less indented.
owner = next((t for t in reversed(tags) if t[0] < depth), None)
if text and owner:
owner[3] = (owner[3] + " " + text).strip()
continue
match = re.match(r"(\w+)(?: <[^>]*>)?(?: \[\"(.*)\"\])?", body)
if match:
tags.append([depth, match.group(1), (match.group(2) or "").strip(), ""])
if not tags:
print("no structure tree: the PDF is not tagged")
for depth, tag, alt, text in tags:
line = " " * depth + tag
if tag == "Figure":
line += f" [{alt}]" if alt else " (no alternative text)"
if text:
line += f' "{text}"'
print(line) from session import files, read, run, write
# The structure tree of the LuaLaTeX build, shortened by tags.py; then two copies of report.tex that leave out what
# only the author can give: the table's header row, and the title.
files("report/report.tex", "report/make_chart.py", "vera.py", "tags.py")
text = read("report.tex")
write("no-header.tex", text.replace("\\tagpdfsetup{table/header-rows=1}\n", ""))
write("no-title.tex", text.replace("\\title{Monsoon rainfall at Station A}\n", "").replace("\\maketitle\n", ""))
run(r"""
python3 make_chart.py
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
pdfinfo -struct-text report.pdf | python3 tags.py
lualatex -interaction=nonstopmode -halt-on-error no-header.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error no-header.tex > /dev/null
pdfinfo -struct-text no-header.pdf | python3 tags.py | grep -a -E "T[HD] " | head -4
lualatex -interaction=nonstopmode -halt-on-error no-title.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error no-title.tex > /dev/null
!pdfinfo no-title.pdf | grep "^Title"
python3 vera.py ua2 no-header.pdf no-title.pdf
""") Output
$ python3 make_chart.py
$ lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error report.tex > /dev/null
$ pdfinfo -struct-text report.pdf | python3 tags.py
Document
Part
P
P "Monsoon rainfall at Station A"
P "Asha Verghese"
Sect
H1 "Summary"
Span "1"
Part
P "Most of the rain at Station A, an invented station, falls from June to September."
Sect
H1 "The wettest months"
Span "2"
Part
P "The total for the four months is mm. The data are described at ."
Formula "418+655+541+306=1920"
Link "https://example.com/station-a"
Sect
Note
Caption
Lbl "Figure 1:"
P "Monthly rainfall at Station A."
Part
P
Figure [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.]
Sect
Note
Caption
Lbl "Table 1:"
P "The four monsoon months."
Part
P
Table
TR
TH "Month"
TH "Rain (mm)"
TH "Rainy days"
TR
TD "June"
TD "418"
TD "21"
TR
TD "July"
TD "655"
TD "27"
TR
TD "August"
TD "541"
TD "25"
TR
TD "September"
TD "306"
TD "17"
P
$ lualatex -interaction=nonstopmode -halt-on-error no-header.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error no-header.tex > /dev/null
$ pdfinfo -struct-text no-header.pdf | python3 tags.py | grep -a -E "T[HD] " | head -4
TD "Month"
TD "Rain (mm)"
TD "Rainy days"
TD "June"
$ lualatex -interaction=nonstopmode -halt-on-error no-title.tex > /dev/null
$ lualatex -interaction=nonstopmode -halt-on-error no-title.tex > /dev/null
$ pdfinfo no-title.pdf | grep "^Title"
[exit status 1]
$ python3 vera.py ua2 no-header.pdf no-title.pdf
no-header.pdf: PDF/UA-2 + Tagged PDF validation profile: compliant, 1727 rules passed, 0 failed
no-title.pdf: PDF/UA-2 + Tagged PDF validation profile: NOT compliant, 1726 rules passed, 1 failed
8.11.1-1 (1 check): Metadata stream does not contain dc:title
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 authors_session.py
The tree holds everything the author gave. The Figure carries the alt text in square brackets, the formula is a
Formula tag, the URL a Link, and the first row of the table is three TH cells because of the line before the table,
\tagpdfsetup{table/header-rows=1}. Without that line every cell was a TD, so a screen reader could not name the
column of 655, and veraPDF still passed all 1,727 rules: no rule can know that the first row was meant to head the
columns. Without \title, pdfinfo found no title and veraPDF failed rule 8.11.1-1, “Metadata stream does not contain
dc:title”.
| What only you know | How you give it in LaTeX | What the session saw without it |
|---|---|---|
| What a picture shows | alt={…} on \includegraphics, or artifact for decoration |
in the images lesson, the file name became the text |
| Which row heads the table | \tagpdfsetup{table/header-rows=1} before the table |
every cell a TD, and veraPDF passed |
| The document’s title | \title{…} (or hyperref’s pdftitle) |
no title, and rule 8.11.1-1 failed |
| The language | lang= or language= in \DocumentMetadata |
en, without a word in the log |
The tagging instructions put the first row plainly: every graphic must be marked either as an artifact or get an alternative text. The table key works the same way for a whole document when it sits in the preamble, or for the next tables when it sits before them, as here; by default, the instructions say, all cells are data cells.
Key idea
A validator checks what the file holds, not what you meant: give every figure its text, every table its header row: The row of column headings at the top of a table. In a tagged PDF its cells are TH cells, so a screen reader can name the heading of each data cell; Typst takes them from table.header. More in “Table basics in LaTeX and Typst”, the document its title and language, and then read the structure tree.
Checkpoint
Check: What only the author can add
Check every package against the tagging status list first
Tagging changes how LaTeX builds headings, floats and tables, so a package that patches those parts may break the build or the tags. The LaTeX Project’s tagging status list: The LaTeX Project's list of how each package and class works with tagging switched on: compatible, partially compatible, currently incompatible, no support or unchecked, with comments on what to do instead. list gives each of more than 2,200 packages and classes one of five words: compatible, partially compatible, currently incompatible, no support (the project expects never to support it), or unchecked. These three copies of the report load a package each:
from session import files, read, run, write
# Three copies of report.tex: one that loads titlesec, one that loads pdfx, and one that loads multirow and asks for
# LaTeX's tagging status report with the check-tagging-status key. Last, tlmgr says whether this TeX Live has the
# latex-tagging-status package, which holds the data the report is filled from (--only-installed: no network).
files("report/report.tex", "report/make_chart.py")
text = read("report.tex")
write("titlesec.tex", text.replace("\\usepackage{hyperref}\n", "\\usepackage{hyperref}\n\\usepackage{titlesec}\n"))
write("pdfx.tex", text.replace("\\usepackage{hyperref}\n", "\\usepackage{pdfx}\n\\usepackage{hyperref}\n"))
write("status.tex", text.replace("tagging=on}", "tagging=on, check-tagging-status}")
.replace("\\usepackage{hyperref}\n", "\\usepackage{hyperref}\n\\usepackage{multirow}\n"))
run(r"""
python3 make_chart.py
!lualatex -interaction=nonstopmode -halt-on-error titlesec.tex > /dev/null
grep -E -A1 "sectioning command .section$|^! " titlesec.log
lualatex -interaction=nonstopmode -halt-on-error pdfx.tex > /dev/null
grep -E -A1 "loading of package pdfx" pdfx.log
lualatex -interaction=nonstopmode -halt-on-error status.tex > /dev/null
lualatex -interaction=nonstopmode -halt-on-error status.tex > status.txt
sed -n '/Status report/,/6. Unclassified/p' status.txt
sed -n '/6. Unclassified/,/=====/p' status.txt | grep -c "^ [a-z0-9-]*\.sty$"
tlmgr info --only-installed latex-tagging-status | grep -E "^(package|installed):"
""") Output
$ python3 make_chart.py $ lualatex -interaction=nonstopmode -halt-on-error titlesec.tex > /dev/null [exit status 1] $ grep -E -A1 "sectioning command .section$|^! " titlesec.log Package titlesec Warning: Non standard sectioning command \section (titlesec) detected. Using default spacing and no format. -- ! Package titlesec Error: No format for this command. -- ! ==> Fatal error occurred, no output PDF file produced! $ lualatex -interaction=nonstopmode -halt-on-error pdfx.tex > /dev/null $ grep -E -A1 "loading of package pdfx" pdfx.log Package pdfmanagement Warning: The loading of package pdfx is disabled. (pdfmanagement) It is not compatible with the PDF management. $ lualatex -interaction=nonstopmode -halt-on-error status.tex > /dev/null $ lualatex -interaction=nonstopmode -halt-on-error status.tex > status.txt $ sed -n '/Status report/,/6. Unclassified/p' status.txt Status report of the tagging support ====================================== Details at https://latex3.github.io/tagging-project/tagging-status Report erroneous entries at https://github.com/latex3/tagging-project A. CLASS ******** article.cls is not in the status list B. PACKAGES, LIBRARIES, etc *********** 1. Unsupported -------------------------- NONE 2. Currently incompatible -------------------------- NONE 3. Partially compatible -------------------------- NONE 4. Compatible -------------------------- NONE 5. Unknown -------------------------- NONE 6. Unclassified files with extension .sty $ sed -n '/6. Unclassified/,/=====/p' status.txt | grep -c "^ [a-z0-9-]*\.sty$" 43 $ tlmgr info --only-installed latex-tagging-status | grep -E "^(package|installed):" package: latex-tagging-status installed: No
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 packages_session.py
titlesec stopped the build: it warned that \section was a non-standard sectioning command, then failed with “No format
for this command”, because headings are now built from templates, as LaTeX2e News 43 describes. The PDF management
code refused to load pdfx at all; the status list says to set standards with \DocumentMetadata keys instead. The
check-tagging-status key printed a status report at the end of the run, but it classified nothing: article.cls “is
not in the status list”, and all 43 package files came out unclassified. The last command shows why: CTAN describes
the latex-tagging-status package as the data behind such checks, and this TeX Live did not have it. Install it with
tlmgr where you use the key, and look packages up on the online list as well.
| Status on the list | Examples of packages and classes | What to do |
|---|---|---|
| compatible | article, report, book, graphicx, booktabs, longtable, tabularx, geometry | use it |
| partially compatible | hyperref, amsmath, amsthm, biblatex, enumitem, tikz | read the comment on the list |
| currently incompatible | titlesec, multirow, caption, subcaption, listings, IEEEtran | replace it, or keep the document untagged for now |
| no support | beamer, pdfx, hyperxmp | replace it: ltx-talk for slides, \DocumentMetadata keys for standards |
The order of work follows from the table. Step through it, with what this lesson’s sessions met at each stage:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- List what loads. The check-tagging-status key printed a report at the end of the run. This TeX Live lacked the latex-tagging-status package that holds its data, so all 43 package files were unclassified, and article.cls was not in the list.
- Look them up. The online status list covers more than 2,200 packages and classes. graphicx, booktabs and longtable are compatible; hyperref and amsmath partially compatible; titlesec and multirow currently incompatible.
- Replace the blockers. titlesec stopped the build with No format for this command, and the loading of pdfx was disabled. Use LaTeX's own headings and \DocumentMetadata keys instead.
- Build twice. LuaLaTeX passed after one run here, and the second run completed the MathML of the formula. pdfLaTeX failed after its first run and needs at least two.
- Validate. veraPDF checks the file against the profile you name. A failed rule names its clause, such as 8.2.2-1 for content that is neither tagged nor an artifact.
- Back to the source. A failed rule points at the source, not at the PDF. Fix it there, for example with a different package or a marked decoration, and build again.
- Read the tree. A pass says nothing about a table without its header row, which passed too. Count the TH cells and look for the alternative text of every figure yourself.
The list changes
LaTeX2e News 43 says the team plans emulations of titlesec’s interfaces on the new heading templates. A package’s status can change with a release, so look it up again after you update TeX Live.
Checkpoint
Check: Checking packages before tagging
Practise: check a preamble and a table before you tag
Two checks that a script can run before a build and after it. The first reads a .tex file and lists what keeps it
from being tagged, the slips of this lesson among them; the second finds the tables whose first row has no TH cell in
the structure tree that tags.py prints. Each step has sample tests you can run in the browser and a first hint:
Code lab · 2 steps
Check a document for tagging
List what keeps a LaTeX file from being tagged, then find the tables in a structure tree whose first row has no header cell.
- Find what keeps a LaTeX file from being tagged To do
- Find the tables whose first row has no header cells To do
Exercise · Medium · Python
Find what keeps a LaTeX file from being tagged
Before a build, a script can read a .tex file and say what will stop its PDF from being tagged. Write preamble_problems(source, status) in preamble.py. source is the text of the file; status maps package names to the status the tagging status list gives them, such as "compatible", "partially-compatible", "currently-incompatible" or "no-support". Return a list of problems, in this order:
"\DocumentMetadata must come before \documentclass"when the file has no\DocumentMetadata, or its first one comes after\documentclass;"\DocumentMetadata is used twice"when it appears more than once;"tagging is not switched on"when the keys of the first\DocumentMetadatahave notagging=on;"no document language"when those keys have neitherlang=norlanguage=;- then, for every package the file loads with
\usepackage, in the order they are loaded,"<name>: <status>"when its status is"currently-incompatible"or"no-support".\usepackage[opts]{a,b}loadsa, thenb.
Checks 3 and 4 apply only to a file that has a \DocumentMetadata. Its keys are the text after it on the same line; spaces around = do not count (tagging = on switches tagging on). Ignore everything from a % to the end of its line, as TeX does, except an escaped \%. The lesson's report.tex gives []; the same file loading titlesec gives ["titlesec: currently-incompatible"].
Starter code · preamble.py
def preamble_problems(source, status):
"""Return what keeps a LaTeX file from being tagged, in the order of the checks."""
# Replace this line: read the file's lines, its \DocumentMetadata and its packages.
return [] The sample tests · test_preamble.py
from preamble import preamble_problems
STATUS = {
"graphicx": "compatible",
"hyperref": "partially-compatible",
"unicode-math": "compatible",
"titlesec": "currently-incompatible",
"multirow": "currently-incompatible",
"pdfx": "no-support",
"booktabs": "compatible",
}
REPORT = r"""% report.tex
\DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on}
\documentclass{article}
\usepackage{graphicx}
\usepackage{hyperref}
\begin{document}
Text.
\end{document}
"""
def test_report():
"""finds nothing to fix in the lesson's report.tex"""
assert preamble_problems(REPORT, STATUS) == []
def test_titlesec():
"""names titlesec with its status when the report loads it"""
source = REPORT.replace(r"\usepackage{hyperref}", "\\usepackage{hyperref}\n\\usepackage{titlesec}")
assert preamble_problems(source, STATUS) == ["titlesec: currently-incompatible"]
def test_list_of_packages():
"""reads every name of \\usepackage[opts]{a,b} and keeps their order"""
source = REPORT.replace(r"\usepackage{graphicx}", r"\usepackage[final]{pdfx,booktabs,multirow}")
assert preamble_problems(source, STATUS) == ["pdfx: no-support", "multirow: currently-incompatible"]
def test_missing_metadata():
"""reports a file without \\DocumentMetadata once, without checks 3 and 4"""
source = REPORT.replace("\\DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on}\n", "")
assert preamble_problems(source, STATUS) == [r"\DocumentMetadata must come before \documentclass"]
def test_metadata_after_class():
"""reports \\DocumentMetadata after \\documentclass, and still reads its keys"""
source = "\\documentclass{article}\n\\DocumentMetadata{pdfstandard=ua-2}\n\\begin{document}\nx\n\\end{document}\n"
assert preamble_problems(source, STATUS) == [
r"\DocumentMetadata must come before \documentclass",
"tagging is not switched on",
"no document language",
]
def test_twice():
"""reports a second \\DocumentMetadata"""
source = "\\DocumentMetadata{tagging=on}\n" + REPORT
assert preamble_problems(source, STATUS) == [r"\DocumentMetadata is used twice", "no document language"]
def test_spaces_and_language_key():
"""accepts tagging = on with spaces, and the language key instead of lang"""
source = REPORT.replace("lang=en-IN, pdfstandard=ua-2, tagging=on", "language = en-IN, tagging = on")
assert preamble_problems(source, STATUS) == []
def test_comments():
"""ignores a commented-out package and a commented-out key, but not an escaped \\%"""
source = REPORT.replace("tagging=on}", "} % tagging=on").replace(
r"\usepackage{hyperref}", "\\usepackage{hyperref}\n% \\usepackage{titlesec}\nRain fell by 40\\% \\usepackage{multirow}"
)
assert preamble_problems(source, STATUS) == ["tagging is not switched on", "multirow: currently-incompatible"]
def test_unknown_package():
"""says nothing about a package missing from the status map"""
source = REPORT.replace(r"\usepackage{graphicx}", r"\usepackage{rainfallplot}")
assert preamble_problems(source, STATUS) == [] A hint
Strip comments first, line by line, with re.sub(r"(?<!\\)%.*", "", line). Then find the line numbers of \DocumentMetadata and \documentclass with "\\DocumentMetadata" in line, read the first one's keys with line.split("\\DocumentMetadata", 1)[1].replace(" ", ""), and the packages with re.findall(r"\\usepackage(?:\[[^\]]*\])?\{([^}]*)\}", text), splitting each match on commas.
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
Find the tables whose first row has no header cells
veraPDF passed the lesson's report with and without its header row, so a check of its own has to count the header cells. Write tables_without_headers(tree) in headers.py. tree is the structure tree as tags.py prints it: one tag a line, indented by two spaces for each level, with the tag's text in quotes after its name, for example
Table
TR
TH "Month"
Number the tables 1, 2, 3 … in the order their Table lines appear, and return the numbers of the tables whose first row has no TH cell, in that order. A table's first row is the first TR below it, whether it sits directly under the Table or inside a THead or TBody (as Typst writes it); its cells are the tags one level below that TR. A table with no row at all has no header cells either. The lesson's report gives []; the copy without \tagpdfsetup{table/header-rows=1} gives [1].
Starter code · headers.py
def tables_without_headers(tree):
"""Return the numbers of the tables (1, 2, …) whose first row has no TH cell."""
# Replace this line: walk the lines, find each Table and its first TR.
return [] The sample tests · test_headers.py
from headers import tables_without_headers
WITH_HEADER = """Document
Sect
Note
Part
Table
TR
TH "Month"
TH "Rain (mm)"
TH "Rainy days"
TR
TD "June"
TD "418"
TD "21"
P
"""
WITHOUT_HEADER = WITH_HEADER.replace('TH "', 'TD "')
TYPST = """Document
H1 "2. The wettest months"
Table
Caption
Span "Table 1: The four monsoon months."
THead
TR
TH "Month"
TH "Rain (mm)"
TBody
TR
TD "June"
TD "418"
"""
def test_header_row():
"""finds no problem in the lesson's report, whose first row is three TH cells"""
assert tables_without_headers(WITH_HEADER) == []
def test_no_header_row():
"""reports table 1 when every cell is a TD, as in the copy without header-rows"""
assert tables_without_headers(WITHOUT_HEADER) == [1]
def test_typst_thead():
"""reads the first row inside a THead, as Typst writes tables"""
assert tables_without_headers(TYPST) == []
def test_later_header_does_not_count():
"""looks at the first row only, even when a later row has a TH"""
tree = WITHOUT_HEADER.replace('TD "June"', 'TH "June"')
assert tables_without_headers(tree) == [1]
def test_two_tables():
"""numbers tables in document order and reports only the second"""
tree = WITH_HEADER + WITHOUT_HEADER.replace("Document\n", "")
assert tables_without_headers(tree) == [2]
def test_table_without_rows():
"""reports a table that has no TR at all"""
tree = "Document\n Table\n Caption\n Span \"Empty\"\n P \"After the table.\"\n"
assert tables_without_headers(tree) == [1]
def test_no_tables():
"""returns an empty list for a tree without tables"""
assert tables_without_headers('Document\n H1 "Summary"\n P "Text."\n') == [] A hint
For each line, depth = (len(line) - len(line.lstrip(" "))) // 2 and tag = line.split()[0]. When you meet a Table, remember its depth; the table ends at the first later line that is not deeper. Inside it, the first TR you meet is the first row: note its depth, and look for a TH at that depth plus one until the next TR at the row's own depth.
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 practise the four ideas of the lesson, from the line above the class to the status list. Each answer you give is explained on the spot, wrong ones included, and the ideas you miss come round again:
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 does \DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on} change in a
LaTeX PDF?
It must be the first line, above \documentclass, and it loads LaTeX’s PDF management and tagging code before the
class. tagging=on makes LaTeX record a structure tree while it typesets: headings, paragraphs, figures, table cells
and links, in reading order. lang=en-IN writes the catalog’s /Lang and the metadata’s dc:language, which screen
readers use to pick a voice. pdfstandard=ua-2 makes the file claim PDF/UA-2 in its XMP metadata, which validators
check. The line also produces PDF 2.0 and, Overleaf’s notes on TeX Live 2026 add, T1 font encoding. In this lesson’s
run the report passed all
1,727 rules of veraPDF’s PDF/UA-2 profile, but the author still had to add alternative text, the table’s header row
and a title.
Two harder questions follow: moving a department’s thesis template to tagged PDFs, and a report that passes veraPDF while a screen-reader user cannot read its tables. 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.
-
Mid to senior
Your department's LaTeX thesis template loads titlesec, multirow and caption, and students build it with pdfLaTeX. The library now asks for theses that meet PDF/UA. Plan how you move the template to tagged PDFs, and how you show that it works.
-
Fresher to mid
A tagged LaTeX report passes veraPDF's PDF/UA-2 profile, yet a colleague who uses a screen reader says its results tables are just a stream of numbers. Explain what probably happened, how you fix it, and how you check the fix.
Key takeaways
\DocumentMetadata{lang=…, pdfstandard=ua-2, tagging=on}goes once, above\documentclass, and gives the file its structure tree, its language and its PDF/UA-2 claim; a second one is an error.- LuaLaTeX passed PDF/UA-2 after one run, wrote the space at a line break, attached MathML with unicode-math on its second run and marked a bare rule as an artifact; pdfLaTeX needed two runs and still missed those three.
- The tagging code cannot know what a picture shows, which row heads a table, the title or the language, and veraPDF passed a table without its header row, so add them yourself and read the structure tree.
- Look up every package on the tagging status list before you switch tagging on: titlesec stopped the build, pdfx was refused, and without its data package this TeX Live’s own status report could not classify a single package.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Where does \DocumentMetadata go, and what does it switch on?
First in the file, above \documentclass. Its keys do three jobs: tagging=on switches tagging on, lang=en-IN sets the language, and pdfstandard=ua-2 makes the file claim PDF/UA-2.
-
What happens when a document has two \DocumentMetadata lines?
The build stops with Two \DocumentMetadata declarations and writes no PDF; keep one line with every key, owned by whoever owns the top of the file.
-
Which language does a tagged LaTeX document claim without lang?
The main language of babel or polyglossia, or en when neither is loaded, with no word in the log.
-
Why build tagged documents with LuaLaTeX?
It passed PDF/UA-2 after one run, wrote the space at line breaks, marked unmarked content such as a bare rule as an artifact, and attached MathML with unicode-math on its second run; pdfLaTeX missed those three after two runs.
-
How do you mark a table's header row for LaTeX's tagging?
\tagpdfsetup{table/header-rows=1}, before the table or in the preamble. Without it every cell is a TD, and veraPDF still passes.
-
What did leaving out \title cost the tagged report?
pdfinfo found no title, and veraPDF failed rule 8.11.1-1: Metadata stream does not contain dc:title.
-
Which four things must the author give a tagged LaTeX document?
Alternative text, or artifact, for every graphic; the table's header rows; the document's title; and its language.
-
Why look packages up on the tagging status list before switching tagging on?
titlesec stopped the tagged build and pdfx was refused. The list marks each package compatible, partially compatible, currently incompatible, no support or unchecked.
Terms in this lesson
- BCP 47 language tag
- A code for a language, with its region or script when needed, such as en-IN for English as written in India, hi for Hindi or ta for Tamil. PDFs, web pages and the language settings of LaTeX and Typst use these codes.
- real space glyph
- A space character drawn from a font between two words, which a PDF stores so that copying, search and screen readers can find where one word ends. TeX itself only moves on between words; with tagging on, pdfTeX and LuaTeX add these glyphs.
- tagging status list
- The LaTeX Project's list of how each package and class works with tagging switched on: compatible, partially compatible, currently incompatible, no support or unchecked, with comments on what to do instead.
References
- LaTeX tagging project: usage instructions (The LaTeX Project)
- Tagging status of LaTeX packages and classes (The LaTeX Project)
- LaTeX2e News 43 (The LaTeX Project)
- latex-tagging-status: the LaTeX tagging status data (CTAN)
- TagPDF: code for PDF tagging using pdfLaTeX and LuaLaTeX (CTAN)
- TeX Live 2026 is now available (Overleaf)
- veraPDF: validation (veraPDF Consortium)
- PDF/UA-2 validation rules (veraPDF Consortium)
- Poppler (freedesktop.org)
- 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