Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with TeX Live 2026, TinyTeX 2026.10, Typst 0.15.1, Python 3.14.8, Pyodide 314.0.7, poppler 26.09.0, qpdf 12.4.1 and verapdf 1.30
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 figure, a table, a formula and a link, tagged TEX · report/report.tex
% 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}
What the line put into report.pdf, and a second \DocumentMetadata Python · metadata_session.py
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:

Interactive · step by step What one line put into report.pdf Step through the keys of the line above \documentclass, and see where each one shows up in the file it built.
What one line put into report.pdf, step 6 of 6. Showing \DocumentMetadata{…}, lang=en-IN, pdfstandard=ua-2, tagging=on, PDF 2.0, /Lang (en-IN), pdfuaid:part 2, Structure tree.\DocumentMetadata{…}read before the classlang=en-INthe main languagepdfstandard=ua-2the standard to claimtagging=ontag while typesettingPDF 2.0and T1 font encoding/Lang (en-IN)and dc:language en-INpdfuaid:part 2in the XMP metadataStructure treeH1, P, Figure, Table …1,727 rules passed, 0 failed

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Where does \DocumentMetadata{lang=en-IN, pdfstandard=ua-2, tagging=on} go, and how often?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Once, as the first line of the file, above \documentclass

    One line, first in the file, with every key in it. A template that has the line leaves it out of the documents that use the template.

  2. Question 2 of 2 A report starts with \DocumentMetadata{pdfstandard=ua-2, tagging=on} and loads neither babel nor polyglossia. Which language does its PDF claim?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: English, en, without a word in the log

    Give the language yourself with lang= or, in an up-to-date TeX Live 2026, language=, so the catalog's /Lang matches the text a screen reader will pronounce.

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:

One report, two engines, checked after each run Python · engines_session.py
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

A bare rule under the title, and LuaLaTeX without unicode-math Python · rule_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

  1. Question 1 of 2 You built report.tex once with pdfLaTeX, and veraPDF says rule 8.2.2-1 failed in 133 places. What do you try first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Run pdfLaTeX again, then check the new file

    Tagging writes data in one run that the next run reads. LuaLaTeX passed after one run here; pdfLaTeX needed two, and a validator should judge the file of the last run.

  2. Question 2 of 2 In the pdfLaTeX file, poppler read the tags as "mm. Thedata are described at", where the line broke after "The". Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: pdfTeX writes space glyphs between words, but not the space at the end of a line

    Copying, search and screen readers that read the tags meet the same joined word. Build with LuaLaTeX, or read the structure text of a pdfLaTeX file for words joined at line breaks.

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:

tags.py: the structure tree, in short Python · tags.py
"""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)
The report's structure tree, and two copies that leave something out Python · authors_session.py
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

  1. Question 1 of 2 veraPDF passed both the report and its copy without \tagpdfsetup{table/header-rows=1}. What did the copy lose?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its header cells; every cell was a TD, so the columns had no heading a screen reader could name

    The tagging instructions say that by default all cells are data cells. Name the header rows with the key, in the preamble for the whole document or before a table, and count the TH cells in the tree.

  2. Question 2 of 2 The copy without \title failed one rule of PDF/UA-2. Which?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 8.11.1-1: the metadata stream does not contain dc:title

    With \DocumentMetadata, \title gave the PDF its title in this build; hyperref's pdftitle sets one too. A reader's title bar and a library catalogue show it.

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:

titlesec, pdfx and the check-tagging-status report Python · packages_session.py
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:

Interactive · stage by stage Before you switch tagging on Step through the checks in order; a package that cannot be tagged sends you back to replace it before the build.
Before you switch tagging on, step 7 of 7. Showing List the packages, Look each one up, Replace the blockers, Build with LuaLaTeX, veraPDF against PDF/UA-2, Read the structure tree. In focus: Read the structure tree.a rule failsList the packagescheck-tagging-statusLook each one upthe tagging status listReplace the blockerscurrently-incompatibleBuild with LuaLaTeXtwiceveraPDF against PDF/UA-2one profileRead the structure treeheadings and TH cells

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 A thesis template loads titlesec. What happened when the session built such a copy of the report with tagging on?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It warned that \section was a non-standard sectioning command, then stopped with No format for this command

    Replace titlesec's formatting before you switch tagging on, or keep the template untagged until the list changes; LaTeX2e News 43 says emulations of titlesec's interfaces are planned.

  2. Question 2 of 2 What does "no-support" mean on the tagging status list?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The package does not work with tagging, and the project does not expect ever to support it

    A package with no support has to be replaced: ltx-talk instead of beamer for slides, and \DocumentMetadata keys instead of pdfx or hyperxmp for standards and metadata.

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.

  1. Find what keeps a LaTeX file from being tagged To do
  2. 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:

  1. "\DocumentMetadata must come before \documentclass" when the file has no \DocumentMetadata, or its first one comes after \documentclass;
  2. "\DocumentMetadata is used twice" when it appears more than once;
  3. "tagging is not switched on" when the keys of the first \DocumentMetadata have no tagging=on;
  4. "no document language" when those keys have neither lang= nor language=;
  5. 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} loads a, then b.

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.

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.

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.

  1. Question 1 of 12 What did pdfstandard=ua-2 put into report.pdf?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: The claim pdfuaid:part 2 in the XMP metadata, which veraPDF checked against the PDF/UA-2 profile

    lang gives the language, tagging the tree and pdfstandard the claim; a validator then tests the claim against its profile.

  2. Question 2 of 12 A report is written in Hindi with English quotations and is built on an up-to-date TeX Live 2026. Which first line fits it best?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: \DocumentMetadata{language=hi, other-languages={en}, pdfstandard=ua-2, tagging=on}

    The language keys only set the PDF's metadata; hyphenation and fonts still come from polyglossia or babel, as the Indian scripts lesson shows.

  3. Question 3 of 12 An Overleaf project created on an older TeX Live does not accept the language key that your TeX Live 2026 build accepts. What is the likely cause?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The project still compiles with the TeX Live version it was created with, whose tagging code is older

    Either move the project to TeX Live 2026 in its settings, or use lang=, which earlier releases accept too, and compare both PDFs.

  4. Question 4 of 12 Which engine do LaTeX's tagging instructions recommend for new tagged documents?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: LuaLaTeX

    LuaLaTeX needs fewer compilations, writes the space at a line break, marks unmarked content as artifacts and can attach MathML with unicode-math.

  5. Question 5 of 12 A template draws a bare \hrule under the title. pdfLaTeX fails rule 8.2.2-1 even after two runs, and LuaLaTeX passes. Why?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: LuaLaTeX's Lua mode marks everything left unmarked as an artifact; pdfLaTeX leaves the rule as untagged content

    Under pdfLaTeX, mark the decoration yourself as an artifact, as the resume lesson does with tagpdf's commands, or build with LuaLaTeX.

  6. Question 6 of 12 The session built the report with LuaLaTeX twice, in a copy without unicode-math. How many formulas carried MathML?

    Type a number.

    Show the answer: Question 6 of 12

    Answer: 0 formulas

    tagpdf warned that neither unicode-math nor lua-unicode-math had been loaded, and the copy carried no MathML. With unicode-math, the same report carried MathML for its one formula.

  7. Question 7 of 12 What must every graphic of a tagged LaTeX document have, according to the tagging instructions?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Either alternative text or the artifact mark

    alt={…} on \includegraphics for a picture that carries meaning, artifact for decoration.

  8. Question 8 of 12 Which of these problems would a tagged LaTeX report pass veraPDF's PDF/UA-2 profile with? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 8 of 12

    Answer:

    • lang=en-IN on a report written in Tamil
    • Alternative text that only says chart
    • A table whose first row is TD cells instead of TH

    veraPDF's documentation says that for PDF/UA only machine verifiable checks are performed. Whether the tags are right for the content is for a person to judge, with the structure tree and a screen reader.

  9. Question 9 of 12 A thesis has forty tables, each with one header row. Where does \tagpdfsetup{table/header-rows=1} go?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Once in the preamble, so it applies to every table

    Set it before a single table when only that table differs, for example one with two header rows.

  10. Question 10 of 12 Which status on the list means "it does not work with tagging yet, but an update is expected"?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: currently-incompatible

    Replace a currently incompatible package, or keep the document untagged and look the package up again after an update of TeX Live.

  11. Question 11 of 12 A department's lecture slides are written with beamer and must now be tagged PDFs. What do you plan?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Move them to the ltx-talk class, which the status list marks compatible and built for tagging

    Look up every package the decks load too: a slide class that is compatible still needs packages that are.

  12. Question 12 of 12 The check-tagging-status report lists all 43 package files as unclassified and says article.cls is not in the status list. What do you conclude?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: This TeX Live has no status data for the report; look the packages up on the online list

    Install latex-tagging-status with tlmgr where you use the key, and check the online list as well: it is the source the report itself names.

PDF Accessibility Checker (PDF/UA) Open your tagged PDF to see its language, title, figures' alternative text and table header cells, checked in your browser. PDF Object Inspector (Developer Tool) Look inside the PDF for the catalog's /Lang entry, the structure tree and the XMP metadata.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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.

  1. 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.

  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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

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

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.