LaTeX & Typst (papers, theses, resumes and slides) Module 11 – Accessible, checked and safe PDFs
Accessible PDFs from Typst
Add the title, language and alt text Typst cannot know, export with PDF/UA-1, build real tables, add PDF/A for archives and export HTML that reflows.
What you will learn
- Use Typst's default tagging and add the title, language and alternative text it cannot know
- Export with --pdf-standard ua-1 and fix each error it reports
- Build data tables with table.header instead of grids
- Combine PDF/UA-1 with PDF/A-2a or PDF/A-3a for archiving
- Export an HTML version for readers who need the text to reflow
Before you start
What you’ll learn · Before you start
What you will learn
- Use Typst's default tagging and add the title, language and alternative text it cannot know
- Export with --pdf-standard ua-1 and fix each error it reports
- Build data tables with table.header instead of grids
- Combine PDF/UA-1 with PDF/A-2a or PDF/A-3a for archiving
- Export an HTML version for readers who need the text to reflow
Before you start
On this page
Typst writes a tagged PDF without being asked, yet the default export of this lesson’s report still failed 2 of the 106 rules of veraPDF’s PDF/UA-1 profile. A PDF/UA-1 export of a copy that set its table as a grid passed all 106, though a screen reader would meet its fifteen cells as one flat run of text.
You will see what Typst tags by itself and add what only you can give, export with --pdf-standard ua-1 and fix each
error it reports, build tables a screen reader can move through, add PDF/A for archiving, and export HTML for readers
who need the text to reflow. Every export was recorded with Typst 0.15.1 and validated with veraPDF 1.30.
Typst tags every PDF; you add the title, the language and the descriptions
Since version 0.14, the changelog says, Typst PDFs are tagged by default: every heading, paragraph, list, figure and table that Typst recognises becomes part of the file’s 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”. The report below gives the three things only its author can know: the document’s title and its language at the top, and the alternative text of its picture and its formula where they appear:
// report.typ: a one-page report with a figure, a table, a formula and a link, ready for PDF/UA-1.
#set document(title: [Monsoon rainfall at Station A], author: "Asha Verghese")
#set text(lang: "en", region: "IN")
#set heading(numbering: "1.")
#title()
= Summary
Most of the rain at Station A, an invented station, falls from June to September.
#figure(
image("chart.png", width: 60%, alt: "Bar chart of monthly rainfall: under 160 mm a month from January to May, 418 mm in June, a peak of 655 mm in July, then falling to 306 mm in September."),
caption: [Monthly rainfall at Station A.],
)
= The wettest months
#figure(
table(
columns: 3,
align: (left, right, right),
table.header[Month][Rain (mm)][Rainy days],
[June], [418], [21],
[July], [655], [27],
[August], [541], [25],
[September], [306], [17],
),
caption: [The four monsoon months.],
)
The total for the four months is
#math.equation(alt: "418 plus 655 plus 541 plus 306 equals 1920", $418 + 655 + 541 + 306 = 1920$)~mm.
The data are described at #link("https://example.com/station-a"). from session import files, read, run, write
# report.typ exported as Typst does by default: what pdfinfo reads back, veraPDF's verdict against PDF/UA-1, the
# structure tree shortened by tags.py and the language in the catalog; then a copy without the text language.
# (2>/dev/null hides a warning poppler prints about an attribute of Typst's table tags.)
files("report/report.typ", "report/make_chart.py", "vera.py", "tags.py")
write("no-lang.typ", read("report.typ").replace('#set text(lang: "en", region: "IN")\n', ""))
run(r"""
python3 make_chart.py
typst compile report.typ default.pdf
pdfinfo default.pdf | grep -E "^(Title|Tagged|PDF version)"
python3 vera.py ua1 default.pdf
pdfinfo -struct-text default.pdf 2>/dev/null | python3 tags.py
qpdf --qdf --object-streams=disable default.pdf default-qdf.pdf
grep -a "/Lang" default-qdf.pdf
typst compile no-lang.typ no-lang.pdf
qpdf --qdf --object-streams=disable no-lang.pdf no-lang-qdf.pdf
grep -a "/Lang" no-lang-qdf.pdf
""") Output
$ python3 make_chart.py
$ typst compile report.typ default.pdf
$ pdfinfo default.pdf | grep -E "^(Title|Tagged|PDF version)"
Title: Monsoon rainfall at Station A
Tagged: yes
PDF version: 1.7
$ python3 vera.py ua1 default.pdf
default.pdf: PDF/UA-1 validation profile: NOT compliant, 104 rules passed, 2 failed
5-1 (1 check): The document metadata stream doesn't contain PDF/UA Identification Schema
7.1-10 (1 check): ViewerPreferences dictionary is not present in the document Catalog, or DisplayDocTitle key is set to false or is not present in the ViewerPreferences dictionary (ViewerPreferences = dictionary(size = 1), DisplayDocTitle = null)
$ pdfinfo -struct-text default.pdf 2>/dev/null | python3 tags.py
Document
P "Monsoon rainfall at Station A"
H1 "1. Summary"
P "Most of the rain at Station A, an invented station, falls from June to September."
Div
Caption
Span "Figure 1: Monthly rainfall at Station A."
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. ]
H1 "2. The wettest months"
Table
Caption
Span "Table 1: The four monsoon months."
THead
TR
TH "Month"
TH "Rain (mm)"
TH "Rainy days"
TBody
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 "The total for the four months is mm. The data are described at ."
Formula "4 1 8 + 6 5 5 + 5 4 1 + 3 0 6 = 1 9 2 0"
Link "https://example.com/station-a"
$ qpdf --qdf --object-streams=disable default.pdf default-qdf.pdf
$ grep -a "/Lang" default-qdf.pdf
/Lang (en-IN)
$ typst compile no-lang.typ no-lang.pdf
$ qpdf --qdf --object-streams=disable no-lang.pdf no-lang-qdf.pdf
$ grep -a "/Lang" no-lang-qdf.pdf
/Lang (en)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 defaults_session.py
pdfinfo found 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 1.7, Typst’s default, with the title from
set document. The tree holds the headings as H1, the image’s description in square brackets, a Table with a THead
of TH cells, the Formula and the Link. The title that #title() prints is a P, not a heading, which is what Typst’s
guide wants: it warns against making the document’s title a heading. set text(lang: "en", region: "IN") wrote /Lang (en-IN);
without it the copy said /Lang (en), because, the guide says, Typst assumes English.
veraPDF still failed two rules of 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”-1: 5-1, because the metadata does not say that the file claims PDF/UA, and 7.1-10, because the file does not ask viewers to show its title instead of its file name. Both are settings of the export, which the next section switches on.
| Typst does it by itself | You give it in the source |
|---|---|
| tags headings, paragraphs, lists, figures, tables and links | set document(title: …) before any content |
| marks headers, footers, page backgrounds and automatic hyphens as artifacts | set text(lang: …, region: …) at the start |
| marks shapes as artifacts, keeping any text inside them | alt: on every image and every equation |
writes PDF 1.7, with /Lang (en) when no language is set |
pdf.artifact around any other decoration |
The left column comes from the guide’s list of what Typst handles automatically and from what the session read back;
the right column is what the guide asks of the author. The guide adds one more duty that no export can check: semantic elements such as emph, strong, lists and
quote instead of text styled to look like them.
Checkpoint
Check: What Typst tags and what you add
PDF/UA-1 export turns each gap into an error
typst compile --pdf-standard ua-1 adds the PDF/UA identification and the display-title setting, and it refuses to
write a file that breaks the rules it can check. The PDF reference names its extra checks, such as alternative text
for images; the accessibility guide adds equations. The session exports the report, then three drafts that each lack
one thing, and the report with tags switched off:
from session import files, read, run, write
# report.typ exported with --pdf-standard ua-1 and checked by veraPDF; then what the export refuses: a draft without
# the document's title, one with an image without alternative text, one with an inline formula without it, and the
# report with PDF tags switched off. Last, the data a script can read before any export: the alternative text of every
# image and every formula, as typst eval lists them.
files("report/report.typ", "report/make_chart.py", "vera.py")
text = read("report.typ")
ALT = ', alt: "Bar chart of monthly rainfall: under 160 mm a month from January to May, 418 mm in June, a peak of 655 mm in July, then falling to 306 mm in September."'
FORMULA = '#math.equation(alt: "418 plus 655 plus 541 plus 306 equals 1920", $418 + 655 + 541 + 306 = 1920$)'
assert ALT in text and FORMULA in text
write("no-title.typ", text.replace("title: [Monsoon rainfall at Station A], ", "").replace("#title()\n", ""))
write("no-alt.typ", text.replace(ALT, ""))
write("formula.typ", text.replace(FORMULA, "$418 + 655 + 541 + 306 = 1920$"))
run(r"""
python3 make_chart.py
typst compile --pdf-standard ua-1 report.typ ua1.pdf
python3 vera.py ua1 ua1.pdf
!typst compile --pdf-standard ua-1 no-title.typ
!typst compile --pdf-standard ua-1 no-alt.typ
!typst compile --pdf-standard ua-1 formula.typ
!typst compile --no-pdf-tags --pdf-standard ua-1 report.typ
typst eval --in no-alt.typ 'query(image).map(it => (it.source, it.alt))'
typst eval --in formula.typ 'query(math.equation).map(it => (it.block, it.alt))'
""") Output
$ python3 make_chart.py
$ typst compile --pdf-standard ua-1 report.typ ua1.pdf
$ python3 vera.py ua1 ua1.pdf
ua1.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
$ typst compile --pdf-standard ua-1 no-title.typ
error: PDF/UA-1 error: missing document title
= hint: set the title with `set document(title: [...])`
[exit status 1]
$ typst compile --pdf-standard ua-1 no-alt.typ
error: PDF/UA-1 error: missing alt text
┌─ no-alt.typ:12:2
│
12 │ image("chart.png", width: 60%),
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= hint: make sure your images and equations have alt text
[exit status 1]
$ typst compile --pdf-standard ua-1 formula.typ
error: PDF/UA-1 error: missing alt text
┌─ formula.typ:31:0
│
31 │ $418 + 655 + 541 + 306 = 1920$~mm.
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= hint: make sure your images and equations have alt text
[exit status 1]
$ typst compile --no-pdf-tags --pdf-standard ua-1 report.typ
error: cannot disable PDF tags when exporting a PDF/UA-1 document
[exit status 1]
$ typst eval --in no-alt.typ 'query(image).map(it => (it.source, it.alt))'
[["chart.png",null]]
$ typst eval --in formula.typ 'query(math.equation).map(it => (it.block, it.alt))'
[[false,null]]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ua1_session.py
The report passed all 106 rules. Each draft stopped with an error that names the file and the line: “missing document
title”, with the hint to set it with set document(title: [...]); “missing alt text” for the image, with a caret under
the call; and the same message for an inline formula, which needs math.equation(alt: …) too. With
--no-pdf-tags, the export refused outright: PDF/UA-1 cannot be met without tags. The
resume lesson met the two heading errors as well: the first heading
must be of level 1, and levels may not be skipped.
The last two commands read the same gaps without exporting: typst eval with query(image) lists every image with its
alternative text: A description in words that a PDF or web page gives instead of something a reader may not see, such as an image or an equation. For an equation it reads the formula aloud. More in “Maths fonts, symbol lookup and accessible equations”, null where there is none, and the same for equations. A build script can
run them over every chapter before a long export. Step through the loop that the errors set up:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The draft. The draft has a title, a language and a table with table.header, but its chart has no alt text. Typst would export it as an ordinary tagged PDF without a word.
- The export stops. With --pdf-standard ua-1 the same draft stops before any file is written. Nothing reaches a reader in a state the export can tell is broken.
- The error names the gap. error: PDF/UA-1 error: missing alt text, with a caret under image("chart.png", width: 60%) on line 12 and the hint to give images and equations alt text.
- One fix at a time. The fix goes into the source, not the PDF. A formula without alt text gives the same message for its line, and a missing title asks for set document(title).
- Export again. The loop repeats until the export writes a file. typst eval with query(image) lists the images still without alt text, so a script can find them all first.
- The validator. veraPDF passed the exported report on all 106 rules of its PDF/UA-1 profile. It checks what a machine can decide, and nothing more.
- The tree. A grid used for data, a table without a header and a description that says only chart all passed. Read the tree, then listen to the file with a screen reader.
Alternative text on the figure and on the image
Wrong: figure(image("chart.png", alt: "…"), alt: "…"), or alt on a figure that holds a table. The guide says a
figure’s alt makes many screen readers skip its contents, so it belongs only on a figure whose body cannot speak
for itself, such as a drawing made of shapes, and never on both. Right: alt on the image, as in report.typ.
Checkpoint
Check: PDF/UA-1 export and its errors
A table needs table.header; a grid is read as loose text
The guide’s rule for tables is short: use table, not grid, for data, because a table lets
assistive technology: Software or devices that people with disabilities use to read and work with documents, such as screen readers, which speak the text, and refreshable braille displays. They read a PDF through its tags. move by row and column, while a grid’s contents are read in
source order. This session sets the report’s data three more ways and exports each for PDF/UA-1:
from session import files, read, run, write
# The report's table in three more forms: as a grid, as a table without table.header, and as a table whose first column
# is marked as row headers with pdf.header-cell, which needs the a11y-extras feature. Each is exported for PDF/UA-1,
# and the structure trees show what a screen reader gets. (2>/dev/null hides a warning poppler prints about an
# attribute of Typst's table tags.)
files("report/report.typ", "report/make_chart.py", "vera.py", "tags.py")
text = read("report.typ")
HEADER = "table.header[Month][Rain (mm)][Rainy days],"
ROWS = "[June], [418], [21],\n [July], [655], [27],\n [August], [541], [25],\n [September], [306], [17],"
assert HEADER in text and ROWS in text
write("grid.typ", text.replace("table(", "grid(").replace(HEADER, "[Month], [Rain (mm)], [Rainy days],"))
write("no-header.typ", text.replace(HEADER, "[Month], [Rain (mm)], [Rainy days],"))
write("row-headers.typ", text.replace(HEADER, 'table.header(pdf.header-cell(scope: "both")[Month], [Rain (mm)], [Rainy days]),')
.replace(ROWS, "\n ".join(f'pdf.header-cell(scope: "row")[{month}], [{rain}], [{days}],' for month, rain, days in
(("June", 418, 21), ("July", 655, 27), ("August", 541, 25), ("September", 306, 17)))))
run(r"""
python3 make_chart.py
typst compile --pdf-standard ua-1 grid.typ grid.pdf
pdfinfo -struct-text grid.pdf 2>/dev/null | python3 tags.py | sed -n '/wettest/,/June/p'
typst compile --pdf-standard ua-1 no-header.typ no-header.pdf
pdfinfo -struct-text no-header.pdf 2>/dev/null | python3 tags.py | grep -a -E "T[HD] " | head -4
python3 vera.py ua1 grid.pdf no-header.pdf
!typst compile --pdf-standard ua-1 row-headers.typ row-headers.pdf
typst compile --features a11y-extras --pdf-standard ua-1 row-headers.typ row-headers.pdf
pdfinfo -struct-text row-headers.pdf 2>/dev/null | python3 tags.py | grep -a -E "T[HD] " | head -8
python3 vera.py ua1 row-headers.pdf
""") Output
$ python3 make_chart.py
$ typst compile --pdf-standard ua-1 grid.typ grid.pdf
$ pdfinfo -struct-text grid.pdf 2>/dev/null | python3 tags.py | sed -n '/wettest/,/June/p'
H1 "2. The wettest months"
Div
Caption
Span "Figure 2: The four monsoon months."
Div
Div
Span "Month"
Div
Span "Rain (mm)"
Div
Span "Rainy days"
Div
Span "June"
$ typst compile --pdf-standard ua-1 no-header.typ no-header.pdf
$ pdfinfo -struct-text no-header.pdf 2>/dev/null | python3 tags.py | grep -a -E "T[HD] " | head -4
TD "Month"
TD "Rain (mm)"
TD "Rainy days"
TD "June"
$ python3 vera.py ua1 grid.pdf no-header.pdf
grid.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
no-header.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
$ typst compile --pdf-standard ua-1 row-headers.typ row-headers.pdf
error: module `pdf` does not contain `header-cell`
┌─ row-headers.typ:21:21
│
21 │ table.header(pdf.header-cell(scope: "both")[Month], [Rain (mm)], [Rainy days]),
│ ^^^^^^^^^^^
[exit status 1]
$ typst compile --features a11y-extras --pdf-standard ua-1 row-headers.typ row-headers.pdf
$ pdfinfo -struct-text row-headers.pdf 2>/dev/null | python3 tags.py | grep -a -E "T[HD] " | head -8
TH "Month"
TH "Rain (mm)"
TH "Rainy days"
TH "June"
TD "418"
TD "21"
TH "July"
TD "655"
$ python3 vera.py ua1 row-headers.pdf
row-headers.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tables_session.py
The grid became Div and Span tags in source order, and its caption turned into “Figure 2”: with no table inside, Typst
counted the figure as an ordinary figure. The table without table.header kept its Table tag, but every cell was a TD. veraPDF passed both
files on all 106 rules: neither the grid nor a missing header breaks a rule a machine can check. With table.header,
the report’s own export had a THead of three TH cells, so each number can be read with the name of its column.
The months in the first column name their rows. pdf.header-cell(scope: "row") marks such a cell, and
scope: "both" the corner cell, but only behind the a11y-extras feature: without --features a11y-extras the export
stopped with “module pdf does not contain header-cell”, and with it the months became TH cells. The PDF reference
warns that these functions have no final interface and will be removed in a future release, so keep them out of a
template that must build for years. Answer three questions about your own content:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Element | Moving by row and column | Cells named by their headings | PDF/UA-1 export | Stable in later versions |
|---|---|---|---|---|
| grid Tags it as: Div and Span in source order | Poor read as loose text, cell after cell | Poor no header cells at all | Good passed all 106 rules | Good a built-in element |
| table without a header Tags it as: a Table of TD cells | Good a Table with rows | Poor every cell a TD | Good passed all 106 rules | Good a built-in element |
| table with table.header Tags it as: a THead of TH cells, then TD | Good a Table with rows | Mixed columns named; the row labels stay TD | Good passed all 106 rules | Good a built-in element |
| table.header and pdf.header-cell Tags it as: TH cells for columns and rows | Good a Table with rows | Good the months became TH cells too | Mixed passed, but only with --features a11y-extras | Poor no final interface; to be removed later |
How the answers decide
- “Is the content data that a reader compares across rows and columns?” Yes, it is data: rules out grid and table without a header. The guide says to use table, not grid, for data, and a table without a header gives a listener numbers with no column names.
- “Is the content data that a reader compares across rows and columns?” No, it only places things side by side: rules out table without a header, table with table.header and table.header and pdf.header-cell. Layout is not a table. A grid tags its parts as Div and Span, which is right for things placed side by side.
- “Is the content data that a reader compares across rows and columns?” Yes, it is data and “Does the first column name each row, as the months do?” Yes: adds a caveat to table with table.header. table.header names the columns only. The months stayed TD cells until pdf.header-cell marked them as row headers.
- “Does the first column name each row, as the months do?” Yes and “Must the source build unchanged with later Typst versions, as a template does?” Yes: adds a caveat to table.header and pdf.header-cell. The PDF reference says pdf.header-cell has no final interface and will be removed in a future release, so a template that uses it may break.
- “Must the source build unchanged with later Typst versions, as a template does?” No, it is built once: Every way passed the PDF/UA-1 export, so the export cannot choose for you; the structure tree shows the difference.
Key idea
Data goes in a table with a table.header, never in a grid: the validator passed all three versions, and only
the structure tree shows which one a screen reader can move through.
Checkpoint
Check: Tables, grids and header cells
One export can meet PDF/UA-1 and PDF/A-2a
An archive asks for PDF/A: The ISO 19005 family of standards for PDFs meant to be kept, whose files must carry what they need to show the same pages later, such as their fonts, and name their part and level in their metadata. More in “Archival PDF/A and final submission checks”, a reader for PDF/UA. Typst 0.15 can target several compatible standards in one build, its changelog says, and the accessibility guide recommends PDF/UA-1 with PDF/A-2a by default, or PDF/A-3a when files are attached. The session exports both pairs, checks each file against every profile it claims, and then tries the combinations Typst refuses:
from session import files, run
# report.typ exported for PDF/UA-1 together with PDF/A-2a, and together with PDF/A-3a, each file checked by veraPDF
# against the profiles it claims; then the combinations Typst 0.15.1 refuses, and PDF/UA-2.
files("report/report.typ", "report/make_chart.py", "vera.py")
run(r"""
python3 make_chart.py
typst compile --pdf-standard ua-1 --pdf-standard a-2a report.typ ua1-a2a.pdf
python3 vera.py ua1 ua1-a2a.pdf
python3 vera.py 2a ua1-a2a.pdf
pdfinfo -meta ua1-a2a.pdf | grep -o -E "<pdf(a|ua)id:(part|conformance)>[^<]*"
typst compile --pdf-standard ua-1 --pdf-standard a-3a report.typ ua1-a3a.pdf
python3 vera.py ua1 ua1-a3a.pdf
python3 vera.py 3a ua1-a3a.pdf
!typst compile --pdf-standard ua-1 --pdf-standard a-4 report.typ
!typst compile --pdf-standard ua-1 --pdf-standard 2.0 report.typ
!typst compile --pdf-standard a-2a --pdf-standard a-3a report.typ
!typst compile --pdf-standard 1.7 --pdf-standard 2.0 report.typ
!typst compile --pdf-standard ua-2 report.typ
""") Output
$ python3 make_chart.py $ typst compile --pdf-standard ua-1 --pdf-standard a-2a report.typ ua1-a2a.pdf $ python3 vera.py ua1 ua1-a2a.pdf ua1-a2a.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed $ python3 vera.py 2a ua1-a2a.pdf ua1-a2a.pdf: PDF/A-2a validation profile: compliant, 153 rules passed, 0 failed $ pdfinfo -meta ua1-a2a.pdf | grep -o -E "<pdf(a|ua)id:(part|conformance)>[^<]*" <pdfaid:part>2 <pdfaid:conformance>A <pdfuaid:part>1 $ typst compile --pdf-standard ua-1 --pdf-standard a-3a report.typ ua1-a3a.pdf $ python3 vera.py ua1 ua1-a3a.pdf ua1-a3a.pdf: PDF/UA-1 validation profile: compliant, 106 rules passed, 0 failed $ python3 vera.py 3a ua1-a3a.pdf ua1-a3a.pdf: PDF/A-3a validation profile: compliant, 155 rules passed, 0 failed $ typst compile --pdf-standard ua-1 --pdf-standard a-4 report.typ error: PDF/A-4 and PDF/UA-1 are mutually incompatible because they do not have any overlapping PDF versions hint: PDF/A-4 requires version PDF 2.0 hint: PDF/UA-1 requires a version between PDF 1.4 and PDF 1.7 [exit status 1] $ typst compile --pdf-standard ua-1 --pdf-standard 2.0 report.typ error: PDF 2.0 is not compatible with PDF/UA-1 hint: PDF/UA-1 requires a version between PDF 1.4 and PDF 1.7 [exit status 1] $ typst compile --pdf-standard a-2a --pdf-standard a-3a report.typ error: choose at most one PDF/A standard [exit status 1] $ typst compile --pdf-standard 1.7 --pdf-standard 2.0 report.typ error: PDF cannot conform to PDF 1.7 and PDF 2.0 at the same time [exit status 1] $ typst compile --pdf-standard ua-2 report.typ error: invalid value 'ua-2' for '--pdf-standard <PDF_STANDARD>' [possible values: 1.4, 1.5, 1.6, 1.7, 2.0, a-1b, a-1a, a-2b, a-2u, a-2a, a-3b, a-3u, a-3a, a-4, a-4f, a-4e, ua-1] tip: a similar value exists: 'ua-1' For more information, try '--help'. [exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 standards_session.py
| Standards asked for | What happened |
|---|---|
ua-1 and a-2a |
passed 106 rules of PDF/UA-1 and 153 of PDF/A-2a; the metadata claims both |
ua-1 and a-3a |
passed 106 rules of PDF/UA-1 and all 155 of PDF/A-3a, the part that allows attachments |
ua-1 and a-4 |
refused: no PDF version overlaps, since PDF/A-4 needs PDF 2.0 and PDF/UA-1 at most 1.7 |
ua-1 and 2.0 |
refused: PDF 2.0 is not compatible with PDF/UA-1 |
a-2a and a-3a |
refused: choose at most one PDF/A standard |
1.7 and 2.0 |
refused: a PDF cannot conform to two versions at once |
ua-2 |
not a value: Typst offers PDF/UA-1 only |
The a in 2a and 3a is the conformance level: The letter after a PDF/A part, as in PDF/A-2b or 2u: b for the basic rules, u for text that maps to Unicode as well, a for an accessible tagged file as well. More in “Archival PDF/A and final submission checks” that requires a tagged, accessible file, so it pairs naturally with PDF/UA-1. PDF/UA-2 needs PDF 2.0, which is why it pairs with PDF/A-4; the guide says it is planned for Typst but not yet available, and that PDF/A-4 dropped level a in favour of PDF/UA. The archival lesson shows what an archive’s PDF/A check reads.
Checkpoint
Check: PDF/UA-1 together with PDF/A
HTML export reflows the text and writes MathML
A PDF keeps its lines where they were set. Someone who enlarges the text or widens its spacing needs text that reflow: Laying text out again to fit the reader's screen, text size and spacing, as a web page does in a browser. A PDF keeps its lines where they were set, which is why an HTML version can help readers who enlarge the text., and WCAG 2.2’s success criterion 1.4.12 asks content written in markup, such as HTML, to survive wider spacing. The guide knows of no PDF viewer that lets readers change text spacing, and suggests distributing an HTML version beside the PDF. Typst’s HTML export is experimental, so it needs a feature flag:
"""Prints the elements of an HTML file in document order, one a line, indented by nesting: the element's name, its
lang or alt attribute, and the text it holds directly, cut at 60 characters. What a <style> element holds and the src
of an image (a long data: URL) are left out, so the outline shows the page's structure.
python3 html_outline.py report.html
"""
import sys
from html.parser import HTMLParser
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
ATTRIBUTES = ("lang", "alt", "href")
class Outline(HTMLParser):
def __init__(self):
super().__init__()
self.lines = [] # [depth, text]
self.open = [] # the lines of the elements that are open, innermost last
def handle_starttag(self, tag, attrs):
shown = " ".join(f'{name}="{value}"' for name, value in attrs if name in ATTRIBUTES and value)
line = [len(self.open), tag + (f" {shown}" if shown else "")]
self.lines.append(line)
if tag not in VOID:
self.open.append(line)
def handle_endtag(self, tag):
if self.open and self.open[-1][1].split()[0] == tag:
self.open.pop()
def handle_data(self, data):
text = " ".join(data.split())
if text and self.open and self.open[-1][1].split()[0] not in ("style", "script"):
line = self.open[-1]
line.append(text)
outline = Outline()
with open(sys.argv[1], encoding="utf-8") as page:
outline.feed(page.read())
for depth, element, *texts in outline.lines:
text = " ".join(texts)
if len(text) > 60:
text = text[:57] + "..."
print(" " * depth + element + (f' "{text}"' if text else "")) from session import files, run
# report.typ exported as HTML: refused without the html feature, then exported with it. html_outline.py prints the
# elements of the page Typst wrote, in order, with the language, the alternative text and the text of each element.
files("report/report.typ", "report/make_chart.py", "html_outline.py")
run(r"""
python3 make_chart.py
!typst compile --format html report.typ report.html
typst compile --features html --format html report.typ report.html
python3 html_outline.py report.html
""") Output
$ python3 make_chart.py
$ typst compile --format html report.typ report.html
error: html export is only available when `--features html` is passed
= hint: html export is under active development and incomplete
= hint: see https://github.com/typst/typst/issues/5512 for more information
[exit status 1]
$ typst compile --features html --format html report.typ report.html
warning: html export is under active development and incomplete
= hint: its behaviour may change at any time
= hint: do not rely on this feature for production use cases
= hint: see https://github.com/typst/typst/issues/5512 for more information
$ python3 html_outline.py report.html
html lang="en-IN"
head
meta
meta
title "Monsoon rainfall at Station A"
meta
style
body
h1 "Monsoon rainfall at Station A"
h2 "1. Summary"
p "Most of the rain at Station A, an invented station, falls..."
figure
img 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."
figcaption "Figure 1: Monthly rainfall at Station A."
h2 "2. The wettest months"
figure
table
thead
tr
th "Month"
th "Rain (mm)"
th "Rainy days"
tbody
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"
figcaption "Table 1: The four monsoon months."
p "The total for the four months is mm. The data are describ..."
math
mn "418"
mo "+"
mn "655"
mo "+"
mn "541"
mo "+"
mn "306"
mo "="
mn "1920"
a href="https://example.com/station-a" "https://example.com/station-a"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 html_session.py
Without --features html the export refused; with it, Typst warned that HTML export is under active development and
not for production use. The page it wrote kept what the tags held: lang="en-IN" on the html element, the title as
h1, the headings as h2, the image’s alt, a table with th cells, captions as figcaption and the formula as
MathML, which the 0.15.0 changelog says Typst now writes for equations. Pages built this way are HTML like any other:
check them with the same tools you use for a web page.
| In the PDF’s tags | In the HTML page |
|---|---|
/Lang (en-IN) |
<html lang="en-IN"> |
| H1 “1. Summary” | <h2> “1. Summary” |
| Figure with alternative text | <img alt="…"> in a <figure> |
| TH in a THead | <th> in a <thead> |
| Formula | <math> with MathML elements |
An experimental export
The warning says the behaviour may change at any time. Pin the Typst version that builds your HTML, and look at the outline again after an upgrade.
Checkpoint
Check: HTML export for reflow
Practise: find the gaps before the export does
Two checks a build can run before it exports. The first reads what typst eval lists for images and equations and
names each one without alternative text; the second says which --pdf-standard values Typst 0.15.1 will refuse
together, worded as the session’s errors are. Write each in the editor below and press Run for its sample tests; one
hint is open for each:
Code lab · 2 steps
Check a Typst export before it runs
Name the images and equations without alternative text from typst eval's lists, then catch the PDF standards that cannot be combined.
- Name the images and equations without alternative text To do
- Catch the PDF standards Typst will not combine To do
Exercise · Easy · Python
Name the images and equations without alternative text
A --pdf-standard ua-1 export stops at the first image or equation without alternative text. A script can list them all first. These two commands print what it needs, as JSON:
typst eval --in report.typ 'query(image).map(it => (it.source, it.alt))'prints one[source, alt]pair per image, such as[["chart.png",null]];typst eval --in report.typ 'query(math.equation).map(it => (it.block, it.alt))'prints one[block, alt]pair per equation, whereblockistruefor a display equation andfalsefor an inline one.
Write missing_alt(images, equations) in missing.py. It takes the two printed texts and returns a list of messages, the images first and then the equations, each in document order:
"image <source>: no alt text"for an image whose alt isnullor only spaces;"equation <n> (inline): no alt text"or"equation <n> (display): no alt text"for such an equation, wherenis its position among all the equations, counting from 1.
For the lesson's no-alt.typ, missing_alt('[["chart.png",null]]', '[[false,"418 plus 655 plus 541 plus 306 equals 1920"]]') returns ["image chart.png: no alt text"].
Starter code · missing.py
import json
def missing_alt(images, equations):
"""Return one message for each image and equation without alternative text."""
# Replace this line: read both JSON texts and check each alt.
return [] The sample tests · test_missing.py
from missing import missing_alt
REPORT_IMAGES = '[["chart.png","Bar chart of monthly rainfall: under 160 mm a month from January to May."]]'
REPORT_EQUATIONS = '[[false,"418 plus 655 plus 541 plus 306 equals 1920"]]'
def test_report():
"""finds nothing in the lesson's report.typ"""
assert missing_alt(REPORT_IMAGES, REPORT_EQUATIONS) == []
def test_image_without_alt():
"""names the chart of no-alt.typ"""
assert missing_alt('[["chart.png",null]]', REPORT_EQUATIONS) == ["image chart.png: no alt text"]
def test_inline_equation_without_alt():
"""names the inline formula of formula.typ as equation 1"""
assert missing_alt(REPORT_IMAGES, "[[false,null]]") == ["equation 1 (inline): no alt text"]
def test_numbers_and_kinds():
"""numbers equations among all of them and says display or inline"""
equations = '[[false,"x equals 2"],[true,null],[false,null]]'
assert missing_alt("[]", equations) == [
"equation 2 (display): no alt text",
"equation 3 (inline): no alt text",
]
def test_spaces_only():
"""treats alternative text of spaces only as missing"""
assert missing_alt('[["divider.svg"," "]]', "[]") == ["image divider.svg: no alt text"]
def test_images_first():
"""lists the images before the equations, each in document order"""
images = '[["a.png",null],["b.png","A map of the station"],["c.svg",null]]'
assert missing_alt(images, "[[true,null]]") == [
"image a.png: no alt text",
"image c.svg: no alt text",
"equation 1 (display): no alt text",
]
def test_empty_document():
"""returns an empty list for a document without images or equations"""
assert missing_alt("[]", "[]") == [] A hint
json.loads(images) turns the text into a list of two-item lists, with None for null. Treat an alt as missing when alt is None or not alt.strip(), and number the equations with enumerate(..., start=1).
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
Catch the PDF standards Typst will not combine
A build script takes the --pdf-standard values of a Typst export from a settings file. Write standard_problem(values) in standards.py, which says what Typst 0.15.1 would refuse before the export runs. values is a list such as ["ua-1", "a-2a"]. Check, in this order, and return the first problem as Typst words it, or None:
- a value that is not one of
1.4 1.5 1.6 1.7 2.0 a-1b a-1a a-2b a-2u a-2a a-3b a-3u a-3a a-4 a-4f a-4e ua-1:"invalid value '<value>'"; - more than one PDF/A value (those starting with
a-):"choose at most one PDF/A standard"; - more than one PDF version (
1.4to2.0):"PDF cannot conform to PDF <first> and PDF <second> at the same time", with the first two versions in the order given; ua-1witha-4,a-4fora-4e:"PDF/A-<part> and PDF/UA-1 are mutually incompatible", where<part>is what followsa-, such as4f;ua-1with2.0:"PDF 2.0 is not compatible with PDF/UA-1".
The lesson's session met all five: ["ua-1", "a-2a"] gives None, and ["ua-2"] gives "invalid value 'ua-2'".
Starter code · standards.py
ACCEPTED = "1.4 1.5 1.6 1.7 2.0 a-1b a-1a a-2b a-2u a-2a a-3b a-3u a-3a a-4 a-4f a-4e ua-1".split()
def standard_problem(values):
"""Return the first problem Typst 0.15.1 would report for these --pdf-standard values, or None."""
# Replace this line: test the five rules in order.
return None The sample tests · test_standards.py
from standards import standard_problem
def test_lesson_pairs():
"""accepts ua-1 with a-2a and with a-3a, as the session's exports did"""
assert standard_problem(["ua-1", "a-2a"]) is None
assert standard_problem(["ua-1", "a-3a"]) is None
def test_ua2():
"""rejects ua-2, which Typst 0.15.1 does not offer"""
assert standard_problem(["ua-2"]) == "invalid value 'ua-2'"
def test_two_pdfa():
"""refuses two PDF/A parts"""
assert standard_problem(["a-2a", "a-3a"]) == "choose at most one PDF/A standard"
def test_two_versions():
"""refuses two PDF versions, named in the order given"""
assert standard_problem(["1.7", "2.0"]) == "PDF cannot conform to PDF 1.7 and PDF 2.0 at the same time"
def test_pdfa4_with_ua1():
"""names the PDF/A-4 part that cannot go with PDF/UA-1"""
assert standard_problem(["ua-1", "a-4"]) == "PDF/A-4 and PDF/UA-1 are mutually incompatible"
assert standard_problem(["a-4f", "ua-1"]) == "PDF/A-4f and PDF/UA-1 are mutually incompatible"
def test_version_two_with_ua1():
"""refuses PDF 2.0 with PDF/UA-1"""
assert standard_problem(["ua-1", "2.0"]) == "PDF 2.0 is not compatible with PDF/UA-1"
def test_order_of_rules():
"""reports an invalid value before any other problem"""
assert standard_problem(["a-2a", "a-3a", "a-2c"]) == "invalid value 'a-2c'"
def test_alone_and_empty():
"""accepts PDF/A-4 or PDF 2.0 alone, and no standard at all"""
assert standard_problem(["a-4"]) is None
assert standard_problem(["2.0"]) is None
assert standard_problem([]) is None A hint
Keep the 17 accepted values in a list. Then split values into pdfa = [v for v in values if v.startswith("a-")] and versions = [v for v in values if v[0].isdigit()], and test the rules one after another, returning at the first one that applies.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the five ideas, from the default tags to the HTML page. Whatever you pick, the reason appears at once, and the next question comes from the idea that needs it most:
Practice that adapts to you
Practise this lesson
10 questions about 5 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): Typst tags every PDF by default. Why export with --pdf-standard ua-1 anyway?
The default export is a tagged PDF, but it is not a PDF/UA file. In this lesson’s run it failed 2 of the 106 rules of
veraPDF’s PDF/UA-1 profile: its metadata did not claim PDF/UA, and it did not ask viewers to show its title.
--pdf-standard ua-1 adds both, and it also turns the gaps Typst can detect into errors that stop the export: a
missing document title, an image or an equation without alternative text, a first heading below level 1 or a skipped
level. That makes the build fail where a person would otherwise have to notice. What it cannot catch is equally
important: a grid used for data, a table without a header row and poor descriptions all passed, so the structure tree
and a screen reader still need a look.
Two harder questions follow: invoices that read as a jumble of numbers, and theses that must be both archival and accessible. 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
A Typst template produces about 2,000 invoices a month as tagged PDFs, with the line items laid out by grid. A customer who uses a screen reader says the line items arrive as a jumble of numbers. Diagnose it and fix the template so that the fix holds.
-
Mid to senior
A university will accept theses written in Typst if each is both archival and accessible, and some must carry their data files inside the PDF. Choose the export settings and design the checks the build runs before a thesis is accepted.
Key takeaways
- Typst tags every PDF by default, but only you can give the title, the language and the alternative text, and the default export still failed two PDF/UA-1 rules.
--pdf-standard ua-1passed all 106 rules and stopped with an error for a missing title and for an image or a formula without alternative text;typst evallists the same gaps before an export.- Put data in a
tablewithtable.header: a grid and a table without a header both passed the validator, and only the structure tree showed the difference. ua-1pairs witha-2a, ora-3afor attachments, in one export; PDF/A-4, PDF 2.0 and a second PDF/A part were refused, and PDF/UA-2 is not available yet.- HTML export, behind
--features html, kept the language, the headings, the alternative text, the table headers and the formula as MathML, for readers who need text that reflows.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does Typst tag by itself, and what must you add?
It tags headings, paragraphs, lists, figures, tables and links, and marks headers, footers and shapes as artifacts; you add set document(title: ...), set text(lang: ...) and alt on every image and equation.
-
Why did Typst's default export fail PDF/UA-1?
Its metadata did not claim PDF/UA (rule 5-1) and it did not ask viewers to show its title (rule 7.1-10); --pdf-standard ua-1 adds both.
-
What does --pdf-standard ua-1 refuse?
A missing document title, an image or an equation without alt text, a first heading below level 1 and a skipped level. It refuses --no-pdf-tags outright.
-
Where does alt go for a picture inside a Typst figure?
On the image. A figure's own alt makes many screen readers skip its contents, so give it only to a figure whose body cannot speak for itself, and never to both.
-
Why put data in a Typst table with table.header rather than a grid?
A grid is tagged as Div and Span in source order, and a table without a header has only TD cells; both passed veraPDF, but only a table with its header can be read by row and column.
-
Which standards can one Typst export combine with PDF/UA-1?
PDF/A-2a, or PDF/A-3a when files are attached. PDF/A-4 and PDF 2.0 were refused, because PDF/UA-1 needs PDF 1.7 at most, and PDF/UA-2 is not available yet.
-
Why export an HTML version beside the PDF?
Readers who enlarge text or widen its spacing need text that reflows. Typst's HTML export, behind --features html, kept the language, headings, alt text, table headers and the formula as MathML.
-
How can a build list the images and equations without alt text before exporting?
typst eval with query(image) or query(math.equation) lists each one with its alt text, null where there is none.
Terms in this lesson
- assistive technology
- Software or devices that people with disabilities use to read and work with documents, such as screen readers, which speak the text, and refreshable braille displays. They read a PDF through its tags.
- reflow
- Laying text out again to fit the reader's screen, text size and spacing, as a web page does in a browser. A PDF keeps its lines where they were set, which is why an HTML version can help readers who enlarge the text.
References
- Typst guide: accessibility (Typst)
- Typst reference: PDF (standards and tagging) (Typst)
- Typst reference: pdf.header-cell (Typst)
- Typst 0.14.0 changelog (Typst)
- Typst 0.15.0 changelog (Typst)
- Typst reference: HTML export (Typst)
- veraPDF: validation (veraPDF Consortium)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress