LaTeX & Typst (papers, theses, resumes and slides) Module 2 – Document structure, text and page layout
Fonts, engines and Unicode
Choose between pdfLaTeX, XeLaTeX and LuaLaTeX, load OpenType fonts in LaTeX and Typst, catch the characters a font lacks and check the PDF's text.
What you will learn
- Choose between pdfLaTeX, XeLaTeX and LuaLaTeX for a document's fonts and its venue
- Load an OpenType font with fontspec in both Unicode engines and with Typst's text function
- Detect the characters a font lacks with the log, \tracinglostchars and Typst's font list
- Check with pdffonts and two extractors that the PDF's text can be copied and searched
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between pdfLaTeX, XeLaTeX and LuaLaTeX for a document's fonts and its venue
- Load an OpenType font with fontspec in both Unicode engines and with Typst's text function
- Detect the characters a font lacks with the log, \tracinglostchars and Typst's font list
- Check with pdffonts and two extractors that the PDF's text can be copied and searched
Before you start
On this page
A seminar notice that names its speaker, Ирина Петрова, came out of LuaLaTeX with a gap where the 12 letters of the name should be, and the build ended with status 0. The only trace was 36 lines in the middle of the run’s output.
Below you will choose a LaTeX engine for the fonts a document needs, load OpenType fonts in LaTeX and in Typst, make a character the font lacks stop the build instead of vanishing, and check that the PDF’s text copies and searches the way it reads. Most results come from one seminar notice, built by every engine and read back by two text extractors.
pdfLaTeX, XeLaTeX and LuaLaTeX read fonts in two different ways
pdfLaTeX works with 8-bit fonts: each font holds at most 256 characters, arranged by a font encoding: The table that says which character each slot of a font holds, such as OT1 or T1 for pdfLaTeX. OT1, the default, lacks some ASCII characters, so LaTeX draws them and they copy out wrongly. More in “Special characters, quotes, dashes and spaces” such as T1, and a package such as lmodern picks the font files. XeLaTeX and LuaLaTeX are Unicode engines: they read every character as its code point: The number Unicode gives a character, written U+ and four or more hexadecimal digits, such as U+2013 for the en dash. Two characters that look alike can have different code points. More in “Special characters, quotes, dashes and spaces” and set it with an OpenType font: A font file, ending in .otf or .ttf, that holds glyphs for Unicode characters together with rules for ligatures and other features. XeLaTeX and LuaLaTeX load such fonts through fontspec; Typst uses them directly., which the fontspec package loads. Its CTAN description calls fontspec a package for XeLaTeX and LuaLaTeX, so it is the first thing to fail when the engine is wrong. Here is the notice, once for the Unicode engines and once for pdfLaTeX:
notice/notice.tex
% A seminar notice for LuaLaTeX or XeLaTeX. fontspec loads its default, Latin Modern, which has no Cyrillic letters.
\documentclass{article}
\usepackage{fontspec}
\begin{document}
Speaker: Ирина Петрова, from the field station. The office is efficient:
official badges are issued within a day.
\end{document} notice/notice-pdf.tex
% The same notice for pdfLaTeX, which cannot load fontspec: T1-encoded Latin Modern.
\documentclass{article}
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\begin{document}
Speaker: Ирина Петрова, from the field station. The office is efficient:
official badges are issued within a day.
\end{document} This session builds them with all three engines and lists the fonts each PDF embeds with poppler’s pdffonts:
from session import files, read, run, write
# The notice through the three LaTeX engines. pdfLaTeX gets notice-pdf.tex, which loads no fontspec, and then a copy
# with the speaker's name in Latin letters; LuaLaTeX and XeLaTeX get notice.tex. pdffonts lists the fonts each PDF
# embeds.
files("notice/notice.tex", "notice/notice-pdf.tex")
write("notice-latin.tex", read("notice-pdf.tex").replace("Ирина Петрова", "Irina Petrova"))
run(r"""
!pdflatex -interaction=nonstopmode -halt-on-error notice.tex > /dev/null
grep -A1 "^! Fatal" notice.log
!pdflatex -interaction=nonstopmode -halt-on-error notice-pdf.tex > /dev/null
grep -A1 "^! LaTeX Error" notice-pdf.log
pdflatex -interaction=nonstopmode notice-latin.tex | grep Output
pdffonts notice-latin.pdf
lualatex -interaction=nonstopmode notice.tex | grep Output
pdffonts notice.pdf
xelatex -interaction=nonstopmode notice.tex | grep Output
pdffonts notice.pdf
""") Output
$ pdflatex -interaction=nonstopmode -halt-on-error notice.tex > /dev/null
[exit status 1]
$ grep -A1 "^! Fatal" notice.log
! Fatal Package fontspec Error: The fontspec package requires either XeTeX or
(fontspec) LuaTeX.
$ pdflatex -interaction=nonstopmode -halt-on-error notice-pdf.tex > /dev/null
[exit status 1]
$ grep -A1 "^! LaTeX Error" notice-pdf.log
! LaTeX Error: Unicode character И (U+0418)
not set up for use with LaTeX.
$ pdflatex -interaction=nonstopmode notice-latin.tex | grep Output
Output written on notice-latin.pdf (1 page, 29696 bytes).
$ pdffonts notice-latin.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
LPQTCA+LMRoman10-Regular Type 1 Custom yes yes yes 4 0
$ lualatex -interaction=nonstopmode notice.tex | grep Output
Output written on notice.pdf (1 page, 5621 bytes).
$ pdffonts notice.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
VFSOVW+LMRoman10-Regular CID Type 0C Identity-H yes yes yes 4 0
$ xelatex -interaction=nonstopmode notice.tex | grep Output
Output written on notice.pdf (1 page).
$ pdffonts notice.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
NUWFSN+LMRoman10-Regular-Identity-H CID Type 0C Identity-H yes yes yes 4 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 engines_session.py
pdfLaTeX stopped twice. With fontspec it gave the fatal error that fontspec requires XeTeX or LuaTeX; without it, it refused И, the first letter of the name, because no font it had loaded was set up for that character. With the name in Latin letters, it embedded LMRoman10-Regular as a Type 1 font. LuaLaTeX and XeLaTeX built notice.tex with no error and embedded the same design as a CID Type 0C font, which the pdffonts manual describes as a 16-bit PostScript CFF font: the OpenType version.
Which engine fits depends on the fonts and on where the source goes. Answer two questions about your document:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Engine | Loads OpenType fonts | Finds TeX tree fonts by name | Reports a missing glyph |
|---|---|---|---|
| pdfLaTeX Fonts it uses: Type 1 fonts, chosen by encoding | Poor fontspec stops it: it requires XeTeX or LuaTeX | Mixed loads fonts through packages such as lmodern, not by name | Good stops at И: Unicode character not set up for use with LaTeX |
| XeLaTeX Fonts it uses: OpenType, through fontspec | Good any .otf or .ttf through fontspec | Poor not on macOS or arXiv: Libertinus Serif cannot be found | Mixed one log line per letter, an error only with \tracinglostchars=3 |
| LuaLaTeX Fonts it uses: OpenType, through fontspec | Good any .otf or .ttf through fontspec | Good luaotfload found Libertinus Serif in the TeX tree | Mixed three log lines per letter, an error only with \tracinglostchars=3 |
| Typst Fonts it uses: OpenType, through text(font:) | Good its own four, system fonts and --font-path folders | Mixed not the TeX tree: point --font-path at a font folder | Poor draws a box and prints nothing; a named family warns |
How the answers decide
- “Which fonts does the document need?” An OpenType font, such as Libertinus Serif: rules out pdfLaTeX. fontspec, which loads OpenType fonts in LaTeX, stops pdfLaTeX with a fatal error.
- “Will the LaTeX source go to arXiv?” Yes: rules out LuaLaTeX and Typst. arXiv lists tex, pdftex, latex, pdflatex and xelatex as its processors: no LuaLaTeX, and a Typst file is not TeX.
- “Which fonts does the document need?” An OpenType font, such as Libertinus Serif and “Will the LaTeX source go to arXiv?” Yes: adds a caveat to XeLaTeX. XeLaTeX on arXiv finds fonts of the TeX tree only by file name, as its TeX Live page explains.
In short: pdfLaTeX when a template or venue asks for it and its fonts suffice, XeLaTeX for OpenType fonts that must also build on arXiv, whose list of processors includes xelatex but not LuaLaTeX, and LuaLaTeX or Typst otherwise.
Checkpoint
Check: How the three LaTeX engines use fonts
fontspec loads a font by name in LuaLaTeX, by file in XeLaTeX
\setmainfont{Libertinus Serif} asks for a font by the name you would pick from a menu. Libertinus Serif ships with
TeX Live, so its files sit in the TeX tree: The folders where a TeX distribution keeps its classes, packages, fonts and settings, such as texmf-dist. Engines find each file in it by name, and kpsewhich prints where a file is. More in “Install TeX Live, TinyTeX or MiKTeX, and Typst”. A second copy of the notice names the same
font by its files instead, with fontspec’s Extension and UprightFont keys, so that *-Regular becomes
LibertinusSerif-Regular.otf:
notice/notice-name.tex
% The notice in Libertinus Serif, a font of the TeX tree, asked for by its name.
\documentclass{article}
\usepackage{fontspec}
\setmainfont{Libertinus Serif}
\begin{document}
Speaker: Ирина Петрова, from the field station. The office is efficient:
official badges are issued within a day.
\end{document} notice/notice-file.tex
% The notice in Libertinus Serif, asked for by its file names, which LuaLaTeX and XeLaTeX both find.
\documentclass{article}
\usepackage{fontspec}
\setmainfont{LibertinusSerif}[
Extension = .otf,
UprightFont = *-Regular,
BoldFont = *-Bold,
ItalicFont = *-Italic,
BoldItalicFont = *-BoldItalic]
\begin{document}
Speaker: Ирина Петрова, from the field station. The office is efficient:
official badges are issued within a day.
\end{document} from session import files, read, run, write
# Libertinus Serif, which lives in the TeX tree, asked for by name and by file name. luaotfload-tool asks LuaLaTeX's
# font database where a name leads. XeLaTeX builds a copy of notice-name.tex under its own name, so that its PDF does
# not replace LuaLaTeX's.
files("notice/notice-name.tex", "notice/notice-file.tex")
write("notice-name-xe.tex", read("notice-name.tex"))
run(r"""
luaotfload-tool --find="Libertinus Serif"
lualatex -interaction=nonstopmode notice-name.tex | grep -E "rror|Output"
pdffonts notice-name.pdf
!xelatex -interaction=nonstopmode notice-name-xe.tex > /dev/null
grep -A2 "^! Package fontspec Error" notice-name-xe.log | head -3
grep -c "in font nullfont" notice-name-xe.log
pdftotext notice-name-xe.pdf - | wc -w
xelatex -interaction=nonstopmode notice-file.tex | grep -E "rror|Output"
pdffonts notice-file.pdf
""") Output
$ luaotfload-tool --find="Libertinus Serif"
luaotfload | resolve : Font "Libertinus Serif" found!
luaotfload | resolve : Resolved file name "…/texmf-dist/fonts/opentype/public/libertinus-fonts/LibertinusSerif-Regular.otf"
$ lualatex -interaction=nonstopmode notice-name.tex | grep -E "rror|Output"
Output written on notice-name.pdf (1 page, 7057 bytes).
$ pdffonts notice-name.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
KPJYNR+LibertinusSerif-Regular CID Type 0C Identity-H yes yes yes 4 0
$ xelatex -interaction=nonstopmode notice-name-xe.tex > /dev/null
[exit status 1]
$ grep -A2 "^! Package fontspec Error" notice-name-xe.log | head -3
! Package fontspec Error:
(fontspec) The font "Libertinus Serif" cannot be found; this
(fontspec) may be but usually is not a fontspec bug. Either
$ grep -c "in font nullfont" notice-name-xe.log
97
$ pdftotext notice-name-xe.pdf - | wc -w
0
$ xelatex -interaction=nonstopmode notice-file.tex | grep -E "rror|Output"
Output written on notice-file.pdf (1 page).
$ pdffonts notice-file.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
ZFUMQQ+LibertinusSerif-Regular-Identity-H CID Type 0C Identity-H yes yes yes 4 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_session.py
LuaLaTeX’s font loader, luaotfload, keeps its own database of fonts, and luaotfload-tool --find showed that it
includes the TeX tree: the name led to LibertinusSerif-Regular.otf. fontspec’s manual explains the difference:
in LuaTeX, fonts of the TeX tree can be loaded by name, while XeTeX can do so on Windows and Linux but not on macOS,
where this lesson ran; by file name, both always find them. So XeLaTeX failed with
The font "Libertinus Serif" cannot be found, and in nonstopmode it went on to report 97 characters missing from
nullfont, a font with no glyphs: the run ended with status 1 and a PDF with no text at all. By file name, XeLaTeX
built the notice. Follow both lookups:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A name. notice-name.tex asks for Libertinus Serif by its family name, the way you would pick it from a menu. The font's files are in the TeX tree that TinyTeX installed.
- LuaLaTeX looks it up. LuaLaTeX's font loader, luaotfload, keeps a database of fonts that includes the TeX tree. luaotfload-tool found the name and resolved it to LibertinusSerif-Regular.otf.
- The PDF has it. pdffonts listed LibertinusSerif-Regular, embedded and with a Unicode map, and the build printed no error.
- XeLaTeX looks elsewhere. fontspec's manual says XeTeX can load fonts of the TeX tree by name on Windows and Linux but not on macOS, where this ran. XeLaTeX looked for the name and did not find it.
- An error, then nothing. fontspec reported The font "Libertinus Serif" cannot be found. In nonstopmode the run went on, and the log then reported 97 characters missing from nullfont, a font with no glyphs at all.
- The file name works. notice-file.tex names the files, LibertinusSerif with the extension .otf and Regular, Bold and Italic faces. XeLaTeX found them in the TeX tree by file name, as arXiv's TeX Live page tells XeLaTeX users to do.
arXiv’s TeX Live page gives the same rule for XeLaTeX on its servers, where practically no fonts are registered with fontconfig: fonts of the TeX Live distribution need the file name lookup. A file name works in both engines, so use it for any font a document must find on other machines.
A font's licence travels with your template
Fonts come with their own licences: CTAN lists the GUST Font License for Latin Modern and the SIL Open Font
License for Libertinus Serif. The OFL lets a font be bundled and redistributed with your files as long as it is
not sold by itself, and it does not apply to documents made with the font. Read the licence before you put a font
into a template you share, and keep the licence file beside it, as this lesson’s fonts folder does for Noto Sans
Tamil.
Checkpoint
Check: Loading OpenType fonts with fontspec
A character the font lacks is only a warning in the log
Back to the notice in Latin Modern, the font fontspec loads when you choose none. Latin Modern has no Cyrillic
letters. LaTeX News 33 says what the Unicode engines then do: they typeset nothing and write a Missing character
warning to the log. This session builds the notice, then a copy with \tracinglostchars=3, then the copy in
Libertinus Serif, and lists which fonts of the TeX tree have И at all:
from session import files, run, shell
# The speaker's name in Latin Modern: how many Missing character lines LuaLaTeX and XeLaTeX print, the first of them,
# what the PDF's text then says, and what \tracinglostchars=3 changes, with -halt-on-error and without it. Then the
# notice in Libertinus Serif, the fee notice of the India note, and the fonts of the TeX tree that have И (U+0418) and
# ₹ (U+20B9), by fc-query's list of the characters each font covers.
files("notice/notice.tex", "notice/notice-strict.tex", "notice/notice-file.tex", "notice/rupee.tex")
run(r"""
lualatex -interaction=nonstopmode notice.tex | grep -c "Missing character"
grep -m1 "Missing character" notice.log
pdftotext notice.pdf - | head -1
xelatex -interaction=nonstopmode notice.tex | grep -E "Output"
grep -c "Missing character" notice.log
pdftotext notice.pdf - | head -1
!lualatex -interaction=nonstopmode -halt-on-error notice-strict.tex > /dev/null
grep -m1 -E "^Missing character" notice-strict.log
grep "Fatal error" notice-strict.log
!xelatex -interaction=nonstopmode -halt-on-error notice-strict.tex > /dev/null
grep -A1 "^! Missing character" notice-strict.log
!ls notice-strict.pdf
!lualatex -interaction=nonstopmode notice-strict.tex > /dev/null
pdftotext notice-strict.pdf - | head -1
lualatex -interaction=nonstopmode notice-file.tex | grep -E "Missing|Output"
pdftotext notice-file.pdf - | head -1
lualatex -interaction=nonstopmode rupee.tex | grep -c "Missing character"
pdftotext rupee.pdf - | head -1
""")
def covers(font, code):
"""Whether fc-query lists the character among the ones the font file has."""
_, ranges = shell(f'fc-query -f "%{{charset}}" "$(kpsewhich {font})"')
for part in ranges.split():
low, _, high = part.partition("-")
if int(low, 16) <= code <= int(high or low, 16):
return True
return False
print("# fonts of the TeX tree that have И (U+0418) and ₹ (U+20B9)")
for font in ["lmroman10-regular.otf", "texgyretermes-regular.otf", "XCharter-Roman.otf", "LibertinusSerif-Regular.otf",
"NewCM10-Book.otf", "STIXTwoText-Regular.otf", "FiraSans-Regular.otf"]:
marks = ["yes" if covers(font, code) else "no " for code in (0x418, 0x20B9)]
print(f"{font:30} И {marks[0]} ₹ {marks[1]}") Output
$ lualatex -interaction=nonstopmode notice.tex | grep -c "Missing character" 36 $ grep -m1 "Missing character" notice.log Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;! $ pdftotext notice.pdf - | head -1 Speaker: ����� �������, from the field station. The office is efficient: official $ xelatex -interaction=nonstopmode notice.tex | grep -E "Output" Output written on notice.pdf (1 page). $ grep -c "Missing character" notice.log 12 $ pdftotext notice.pdf - | head -1 Speaker: ����� �������, from the field station. The office is efficient: official $ lualatex -interaction=nonstopmode -halt-on-error notice-strict.tex > /dev/null [exit status 1] $ grep -m1 -E "^Missing character" notice-strict.log Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;! $ grep "Fatal error" notice-strict.log ! ==> Fatal error occurred, no output PDF file produced! $ xelatex -interaction=nonstopmode -halt-on-error notice-strict.tex > /dev/null [exit status 1] $ grep -A1 "^! Missing character" notice-strict.log ! Missing character: There is no И (U+0418) in font [lmroman10-regular]:mapping=tex-text;. l.6 Speaker: Ирина $ ls notice-strict.pdf ls: notice-strict.pdf: No such file or directory [exit status 1] $ lualatex -interaction=nonstopmode notice-strict.tex > /dev/null [exit status 1] $ pdftotext notice-strict.pdf - | head -1 Speaker: ����� �������, from the field station. The office is efficient: official $ lualatex -interaction=nonstopmode notice-file.tex | grep -E "Missing|Output" Output written on notice-file.pdf (1 page, 7057 bytes). $ pdftotext notice-file.pdf - | head -1 Speaker: Ирина Петрова, from the field station. The office is efficient: official $ lualatex -interaction=nonstopmode rupee.tex | grep -c "Missing character" 3 $ pdftotext rupee.pdf - | head -1 Hostel fee for the term: �4,500. # fonts of the TeX tree that have И (U+0418) and ₹ (U+20B9) lmroman10-regular.otf И no ₹ no texgyretermes-regular.otf И no ₹ no XCharter-Roman.otf И yes ₹ no LibertinusSerif-Regular.otf И yes ₹ yes NewCM10-Book.otf И yes ₹ yes STIXTwoText-Regular.otf И yes ₹ yes FiraSans-Regular.otf И yes ₹ yes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 missing_session.py
Both engines ended with status 0 and wrote the PDF. LuaLaTeX wrote 36 Missing character lines for the 12 letters,
the first There is no И (U+0418) in font [lmroman10-regular], XeLaTeX wrote 12, and nothing at the end of either run
repeats them. The text of both PDFs read Speaker: ����� �������: a replacement character stands for each lost
letter, so the loss also reaches search and copy. With \tracinglostchars=3 the first missing letter became an
error. Run with -halt-on-error, both engines stopped there with status 1 and wrote no PDF; run without it, LuaLaTeX
also ended with status 1 but still wrote the PDF, gap included, so a script has to stop on the status.
The rupee sign
Latin Modern has no ₹ either. The session’s rupee.tex, a fee of ₹4,500 in fontspec’s default font, gave three
Missing character lines and the text �4,500. Of the fonts in the table, Libertinus Serif, New Computer Modern,
STIX Two Text and Fira Sans have ₹; XCharter does not.
Step through what happened to the first letter of the name:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The letter. notice.tex contains the name Ирина Петрова, which starts with И, the code point U+0418. LuaLaTeX and XeLaTeX read it as one character, as they read every Unicode character.
- The font in use. fontspec's default font is Latin Modern Roman, so the letter goes to lmroman10-regular, the font the log names.
- The question. The engine asks the font for a glyph of U+0418. fc-query's list of the characters in lmroman10-regular.otf has no Cyrillic letters at all.
- When the font has it. In Libertinus Serif, which has Cyrillic letters, the glyph is placed, as notice-file.pdf showed with the whole name in its text.
- When it does not. LuaLaTeX wrote 36 Missing character lines for the 12 letters and XeLaTeX 12, among the other lines of the run and in the log. Nothing at the end of the run repeats them.
- A gap and a status of 0. Both runs ended with Output written and status 0. The page has a gap where the name was, and pdftotext read a replacement character for each lost letter.
- The strict setting. With \tracinglostchars=3 in the preamble the first missing letter became an error, and both engines ended with status 1. Run with -halt-on-error, they stopped there and wrote no PDF at all.
The fix is a font that has the characters. fc-query, which lists every character a font file covers, found И in
XCharter, Libertinus Serif, New Computer Modern, STIX Two Text and Fira Sans, and not in Latin Modern or TeX Gyre
Termes. The notice in Libertinus Serif built with no Missing character line and read the name back in full.
Make every unattended build strict
Put \tracinglostchars=3 into the preamble of every document that a script builds, such as badges or reports
made each night, and run the engine with -halt-on-error. A character the font lacks then stops the build before
it writes a PDF, instead of a gap reaching readers.
Checkpoint
Check: Characters a font lacks
Typst falls back to any font it knows, then draws a box
Typst’s text reference gives its default font as Libertinus Serif, one of four fonts built into the command-line
tool, and lists where it finds others: folders given with --font-path first, then the system’s fonts, then the
built-in ones. typst fonts prints what it found. Typst tries the families of the font list in order for each
character and, with fallback on by default, then any font it knows. To see what happens when none has the
character, the session uses a Tamil word, with system fonts switched off:
notice/notice.typ
// The notice in Typst, in its default font, Libertinus Serif.
Speaker: Ирина Петрова, from the field station. The office is efficient:
official badges are issued within a day. tamil/thanks.typ
// A Tamil word with no font for Tamil script asked for.
Thank you: நன்றி. tamil/thanks-named.typ
// The same word with the font list naming a Tamil font after the Latin one.
#set text(font: ("Libertinus Serif", "Noto Sans Tamil"))
Thank you: நன்றி. from session import files, run
# Typst's fonts with system fonts ignored: the notice in the default font, a Tamil word with no Tamil font, the same
# word with a font list that names one, and both again with a folder that holds Noto Sans Tamil, which the second
# file names and the first finds by fallback.
files("notice/notice.typ", "tamil/thanks.typ", "tamil/thanks-named.typ",
"fonts/NotoSansTamil-Regular.ttf=fonts/NotoSansTamil-Regular.ttf")
run(r"""
typst fonts
typst compile notice.typ
pdffonts notice.pdf
pdftotext notice.pdf - | head -1
typst compile thanks.typ
pdftotext thanks.pdf - | head -1
mutool draw -F txt thanks.pdf 2>/dev/null | head -1
typst compile thanks-named.typ
typst fonts --font-path fonts
typst compile --font-path fonts thanks-named.typ
pdffonts thanks-named.pdf
pdftotext thanks-named.pdf - | head -1
typst compile --font-path fonts thanks.typ thanks-fallback.pdf
pdftotext thanks-fallback.pdf - | head -1
""") Output
$ typst fonts
DejaVu Sans Mono
Libertinus Serif
New Computer Modern
New Computer Modern Math
$ typst compile notice.typ
$ pdffonts notice.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
WNBPYW+LibertinusSerif-Regular-Identity-H CID Type 0C Identity-H yes yes yes 8 0
$ pdftotext notice.pdf - | head -1
Speaker: Ирина Петрова, from the field station. The office is efficient: official badges are issued
$ typst compile thanks.typ
$ pdftotext thanks.pdf - | head -1
Thank you: ன்றி.
$ mutool draw -F txt thanks.pdf 2>/dev/null | head -1
Thank you: �ன்றி.
$ typst compile thanks-named.typ
warning: unknown font family: noto sans tamil
┌─ thanks-named.typ:2:16
│
2 │ #set text(font: ("Libertinus Serif", "Noto Sans Tamil"))
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
$ typst fonts --font-path fonts
DejaVu Sans Mono
Libertinus Serif
New Computer Modern
New Computer Modern Math
Noto Sans Tamil
$ typst compile --font-path fonts thanks-named.typ
$ pdffonts thanks-named.pdf
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
QBLHXJ+LibertinusSerif-Regular-Identity-H CID Type 0C Identity-H yes yes yes 8 0
BTLLDN+NotoSansTamil-Regular CID TrueType Identity-H yes yes yes 11 0
$ pdftotext thanks-named.pdf - | head -1
Thank you: நன்றி.
$ typst compile --font-path fonts thanks.typ thanks-fallback.pdf
$ pdftotext thanks-fallback.pdf - | head -1
Thank you: நன்றி.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typst_fonts_session.py
The notice needed nothing: Libertinus Serif has Cyrillic letters, and the text read the name back. With only the
four built-in fonts, no font had Tamil letters, so Typst drew a box, a tofu: The empty box drawn where no font has a glyph for a character, named after its shape. Typst draws one when no font it knows has the character, and prints no warning., for each, printed
nothing, and the text read back as ன்றி from pdftotext, one letter short. thanks-named.typ names Noto Sans Tamil in
its font list, and that did draw a warning, unknown font family: noto sans tamil. With --font-path fonts,
typst fonts listed Noto Sans Tamil, both files drew the word, and the text read நன்றி. Step through one letter:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One letter. thanks.typ holds the Tamil word நன்றி. Typst finds a glyph for each character in the families of the font list, in order.
- The first family. The default list is Libertinus Serif alone, and Libertinus Serif has no Tamil letters.
- A family by name. thanks-named.typ names Noto Sans Tamil next. Without a font folder Typst could not find that family and warned, unknown font family, which is the warning a missing font gets.
- Found by name. With --font-path fonts, typst fonts listed Noto Sans Tamil, and the named family drew the word. pdffonts showed NotoSansTamil-Regular embedded, and pdftotext read நன்றி back.
- The last resort. For a letter that no named family has, Typst's fallback, on by default, tries any font it knows. thanks.typ, built with the fonts folder, found Noto Sans Tamil this way.
- Nothing has it. With only Typst's own four fonts, nothing has ந. Typst drew a box for each letter and printed no warning at all, and the text read back as ன்றி from poppler.
Key idea
Typst warns about a font family it cannot find, not about a glyph that no font has: name the fonts your text
depends on, and keep them in a folder that every build passes with --font-path.
Checkpoint
Check: Typst's fonts and its fallback
Copying, search and screen readers get the PDF’s text, not its look
A PDF draws glyphs; the characters behind them come from a map that each font carries. The pdffonts manual describes
its uni column as yes when the PDF has an explicit ToUnicode map: The table in a PDF that says which characters each glyph of a font stands for, so that its text can be copied, searched and read aloud. pdffonts prints yes in its uni column when a font has one. for the font, and
every PDF in this lesson said yes. ligature: One glyph that stands for two or more letters, such as ffi or fl. The PDF must map it back to those letters, or copied and searched text comes out wrong. test that map: “efficient” and “official”
are set with one ffi glyph, which must read back as three letters. LaTeX News 33 says pdfTeX’s PDFs now get that
information automatically, so the most common ligatures copy as intended. This session builds the notice with all
four engines and reads it back with poppler’s pdftotext and MuPDF’s mutool:
from session import files, read, run, shell, write
# The words "efficient" and "official" from the notice, set with their ffi ligatures by four engines, and read back
# by two text extractors: poppler's pdftotext and MuPDF's mutool. The LaTeX copies spell the speaker's name in Latin
# letters, which Latin Modern has. The table shows the start of the second sentence as each extractor reads it.
files("notice/notice-pdf.tex", "notice/notice.tex", "notice/notice.typ")
write("notice-latin.tex", read("notice-pdf.tex").replace("Ирина Петрова", "Irina Petrova"))
write("notice-lm.tex", read("notice.tex").replace("Ирина Петрова", "Irina Petrova"))
builds = {
"pdfLaTeX": "pdflatex -interaction=nonstopmode notice-latin.tex > /dev/null && mv notice-latin.pdf pdflatex.pdf",
"LuaLaTeX": "lualatex -interaction=nonstopmode notice-lm.tex > /dev/null && mv notice-lm.pdf lualatex.pdf",
"XeLaTeX": "xelatex -interaction=nonstopmode notice-lm.tex > /dev/null && mv notice-lm.pdf xelatex.pdf",
"Typst": "typst compile notice.typ typst.pdf",
}
for engine, command in builds.items():
status, output = shell(command)
if status:
raise SystemExit(f"{engine} failed: {output}")
print("# the start of the second sentence, as each extractor reads it from each PDF")
for engine in builds:
pdf = engine.lower() + ".pdf"
for tool, command in [("poppler", f"pdftotext {pdf} -"), ("MuPDF", f"mutool draw -F txt {pdf} 2>/dev/null")]:
_, text = shell(command)
line = " ".join(text.split())
sentence = line[line.index("The o"):]
print(f"{engine:9} {tool:8} {sentence[:48]}")
run(r"""
pdffonts xelatex.pdf
""") Output
# the start of the second sentence, as each extractor reads it from each PDF pdfLaTeX poppler The office is efficient: official badges are iss pdfLaTeX MuPDF The office is efficient: official badges are iss LuaLaTeX poppler The office is efficient: official badges are iss LuaLaTeX MuPDF The office is efficient: official badges are iss XeLaTeX poppler The office is efficient: official badges are issued XeLaTeX MuPDF The office is efficient: official badges are iss Typst poppler The office is efficient: official badges are iss Typst MuPDF The office is efficient: official badges are iss $ pdffonts xelatex.pdf name type encoding emb sub uni object ID ------------------------------------ ----------------- ---------------- --- --- --- --------- SLWVZF+LMRoman10-Regular-Identity-H CID Type 0C Identity-H yes yes yes 4 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extract_session.py
Seven of the eight pairs read the sentence exactly. poppler read XeLaTeX’s PDF as “office”, “efficient” and “official”, with the single character ff, U+FB00, standing for two letters, while MuPDF read the same file correctly. A portal or search that does not normalise that character would not find the word “efficient” in the PDF.
Checking the page instead of the text
Wrong: reading the PDF on screen and calling it done. A missing glyph, a tofu and a misread ligature can all look acceptable or go unnoticed. Right: extract the text with two tools and compare it with the source, and search it for U+FFFD, the replacement character.
Checkpoint
Check: The text that copying and search get
Practise: a font check for every build
Two checks catch most of what this lesson showed before a reader does: a scan of the log for lost characters, and a
read of pdffonts for fonts that are not embedded or have no Unicode map. The sample tests use the log lines and
tables this page recorded, and the first hint of each step is open:
Code lab · 2 steps
A font check for every build
List the characters a log says were lost, then find the fonts of a PDF that are not embedded or have no Unicode map.
- List the characters a LaTeX log says were lost To do
- Find the fonts that are not embedded or cannot be copied To do
Exercise · Medium · Python
List the characters a LaTeX log says were lost
Write missing_characters(log) in glyphs.py. It reads the text of a LuaLaTeX or XeLaTeX log and returns every character that a font had no glyph for, so that a build script can fail when the list is not empty.
The log reports each lost character on a line like the ones in the lesson:
Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;!(LuaLaTeX)Missing character: There is no И (U+0418) in font [lmroman10-regular]:mapping=tex-text;!(XeLaTeX)! Missing character: There is no И (U+0418) in font [lmroman10-regular]:mapping=tex-text;.(an error, with\tracinglostchars=3)Missing character: There is no S ("53) in font nullfont!(an old-style code, in hexadecimal after")
Return a list of (code, character, font) tuples, each once, in the order the log first reports them: the code as U+ and at least four upper-case hexadecimal digits (U+0418, U+0053), and the font's name without its brackets and features (lmroman10-regular, nullfont). The same character in two fonts is two entries.
Starter code · glyphs.py
def missing_characters(log):
"""Return (code, character, font) for every character the log says a font had no glyph for, once each."""
# Replace this comment: find each "There is no … in font …" report and collect what it names.
return [] The sample tests · test_glyphs.py
from glyphs import missing_characters
LUALATEX = """Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;!
Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;!
Missing character: There is no р (U+0440) in font [lmroman10-regular]:+tlig;!
Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;!
Output written on notice.pdf (1 page, 5621 bytes).
"""
def test_lualatex_repeats_a_character():
"""lists each lost letter once, in the order the log first reports it"""
assert missing_characters(LUALATEX) == [("U+0418", "И", "lmroman10-regular"), ("U+0440", "р", "lmroman10-regular")]
def test_xelatex_and_the_strict_error():
"""reads XeLaTeX's warning and the error that \\tracinglostchars=3 turns it into"""
log = ("Missing character: There is no И (U+0418) in font [lmroman10-regular]:mapping=tex-text;!\n"
"! Missing character: There is no ✓ (U+2713) in font [lmroman10-regular]:mapping=tex-text;.\n")
assert missing_characters(log) == [("U+0418", "И", "lmroman10-regular"), ("U+2713", "✓", "lmroman10-regular")]
def test_old_style_codes_in_nullfont():
"""turns "53 into U+0053 and keeps the order of the log"""
log = ('Missing character: There is no S ("53) in font nullfont!\n'
'Missing character: There is no p ("70) in font nullfont!\n'
'Missing character: There is no S ("53) in font nullfont!\n')
assert missing_characters(log) == [("U+0053", "S", "nullfont"), ("U+0070", "p", "nullfont")]
def test_the_same_character_in_two_fonts():
"""keeps one entry for each font that lacks the character"""
log = ("Missing character: There is no И (U+0418) in font [lmroman10-regular]:+tlig;!\n"
"Missing character: There is no И (U+0418) in font [lmroman10-bold]:+tlig;!\n")
assert missing_characters(log) == [("U+0418", "И", "lmroman10-regular"), ("U+0418", "И", "lmroman10-bold")]
def test_a_clean_log():
"""returns an empty list when nothing was lost"""
assert missing_characters("Output written on notice-file.pdf (1 page, 7057 bytes).\n") == [] A hint
One regular expression can read every form: There is no (.) \((?:U\+([0-9A-F]+)|"([0-9A-F]+))\) in font (\[[^\]]*\]|[^\s!.:;]+). With re.findall, each match gives the character, the new-style code or the old-style code (one of the two is empty) and the font; int(code, 16) and f"U+{number:04X}" write the code, and font.strip("[]") drops the brackets.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Find the fonts that are not embedded or cannot be copied
Write font_problems(table) in fonts.py. It reads what pdffonts printed for a PDF, two header lines and then one line per font, and returns the problems a reader would meet:
"<name>: not embedded"for a font whoseembcolumn saysno: the PDF then looks different on a computer without that font;"<name>: no Unicode map"for a font whoseunicolumn saysno: copied text may then come out wrong.
Return them in the order of the table, the embedding problem first when a font has both. Watch the lesson's output: a long name, such as QBLHXJ+LibertinusSerif-Regular-Identity-H, pushes the other columns to the right, so the columns do not always start where the header says. Skip blank lines.
Starter code · fonts.py
def font_problems(table):
"""Return "<name>: not embedded" and "<name>: no Unicode map" for the fonts of a pdffonts table that need them."""
# Replace this comment: read each font line after the two header lines, find its emb and uni values.
return [] The sample tests · test_fonts.py
from fonts import font_problems
HEADER = ("name type encoding emb sub uni object ID\n"
"------------------------------------ ----------------- ---------------- --- --- --- ---------\n")
def test_the_lessons_notice():
"""finds nothing wrong with LuaLaTeX's notice.pdf"""
table = HEADER + "VFSOVW+LMRoman10-Regular CID Type 0C Identity-H yes yes yes 4 0\n"
assert font_problems(table) == []
def test_a_long_name_shifts_the_columns():
"""reads both fonts of thanks-named.pdf, whose first name is longer than its column"""
table = HEADER + (
"QBLHXJ+LibertinusSerif-Regular-Identity-H CID Type 0C Identity-H yes yes yes 8 0\n"
"BTLLDN+NotoSansTamil-Regular CID TrueType Identity-H yes yes yes 11 0\n")
assert font_problems(table) == []
def test_a_font_that_is_not_embedded():
"""reports a font whose emb column says no"""
table = HEADER + "Arial-BoldMT TrueType WinAnsi no no yes 9 0\n"
assert font_problems(table) == ["Arial-BoldMT: not embedded"]
def test_a_font_without_a_unicode_map():
"""reports a font whose uni column says no"""
table = HEADER + "QWERTY+NotoSansTamil-Regular CID TrueType Identity-H yes yes no 11 0\n"
assert font_problems(table) == ["QWERTY+NotoSansTamil-Regular: no Unicode map"]
def test_both_problems_and_a_blank_line():
"""lists the embedding problem first and skips a blank line at the end"""
table = HEADER + "Helvetica Type 1 Custom no no no 5 0\n\n"
assert font_problems(table) == ["Helvetica: not embedded", "Helvetica: no Unicode map"] A hint
Split each font line into words with line.split(). The name is the first word, and the last five words are always emb, sub, uni, the object number and the generation number, whatever the type and encoding columns hold, so words[-5] is emb and words[-3] is uni.
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.
Afterwards, the self-check below covers engines, font loading, lost characters, Typst’s fallback and extracted text, beginning with whichever of them you are least sure of:
Practice that adapts to you
Practise this lesson
15 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): when would you build a LaTeX document with XeLaTeX or LuaLaTeX instead of pdfLaTeX?
When it needs fonts or characters that pdfLaTeX cannot give it. pdfLaTeX uses 8-bit Type 1 fonts chosen through a
font encoding and packages such as lmodern, and it stops on a character that its fonts are not set up for, as it did
on the И of a Russian name. XeLaTeX and LuaLaTeX read Unicode and load any OpenType font through fontspec, which
pdfLaTeX cannot load at all. Between the two: LuaLaTeX finds fonts of the TeX tree by name, while XeLaTeX needs
their file names on macOS and on arXiv; and arXiv’s list of processors includes xelatex but not LuaLaTeX. Keep
pdfLaTeX when a template or a venue asks for it and its fonts are enough. With either Unicode engine, add
\tracinglostchars=3, because a character the font lacks is otherwise only a line in the log.
Two harder questions follow, about names that vanished from conference badges and about a font that a build server cannot find. Decide where you would start before you open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Conference badges are built each night with LuaLaTeX from a list of speakers. The badges of speakers whose names are written in Cyrillic come out with a blank where the name should be, and every build exits with status 0. Find the cause, fix the template, and make the next such loss fail the build.
-
Mid to senior
A report template builds on your laptop, but on the build server XeLaTeX stops with The font "Libertinus Serif" cannot be found, and the PDF has no text. Explain why it worked on the laptop, fix the template, and make the build independent of each machine's fonts, in LaTeX and in Typst.
Key takeaways
- pdfLaTeX sets 8-bit Type 1 fonts through font encodings, while XeLaTeX and LuaLaTeX set OpenType fonts through fontspec, which stops pdfLaTeX with a fatal error.
- LuaLaTeX finds a TeX tree font such as Libertinus Serif by name, while XeLaTeX on macOS and on arXiv finds it only by file name, so file names keep a document building on every machine.
- A character the font lacks costs only a Missing character line and a replacement character in the text, until
\tracinglostchars=3makes it an error; choose a font that has the character. - Typst falls back to any font it knows and then draws a tofu without a word, but warns about a named family it
cannot find, so name your fonts and pass their folder with
--font-path. - Copying and search read the PDF’s text map, not the page: check it with pdffonts and two extractors, because poppler misread XeLaTeX’s ligatures where MuPDF did not.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do pdfLaTeX and the Unicode engines differ in their fonts?
pdfLaTeX sets 8-bit fonts of at most 256 characters through a font encoding; XeLaTeX and LuaLaTeX read every character as its code point and set OpenType fonts loaded with fontspec.
-
What does fontspec do in pdfLaTeX?
It stops the build with a fatal error: fontspec requires XeTeX or LuaTeX.
-
Why give fontspec a font's file names rather than its family name?
LuaLaTeX finds fonts of the TeX tree by name, but XeLaTeX on macOS and on arXiv finds them only by file name; file names work in both engines everywhere.
-
What do LuaLaTeX and XeLaTeX do with a character the font lacks?
They set nothing, write a Missing character line to the log and end with status 0; the PDF's text holds a replacement character in its place.
-
How do you make a missing character stop the build?
Put \tracinglostchars=3 in the preamble and run with -halt-on-error, so the first lost character is an error and no PDF is written.
-
What does Typst do when no font it knows has a character?
It tries every font it knows, then draws a box, a tofu, without a warning; it warns only about a named font family it cannot find.
-
How do you give Typst a font that is not installed on the computer?
Keep the font file in a folder of the project and pass it with --font-path; typst fonts then lists the family.
-
Why check a PDF's text with two extractors?
Copying and search read the PDF's text map, not the page: poppler read XeLaTeX's ff ligature as the single character U+FB00, while MuPDF read the same words correctly.
Terms in this lesson
- ligature
- One glyph that stands for two or more letters, such as ffi or fl. The PDF must map it back to those letters, or copied and searched text comes out wrong.
- OpenType font
- A font file, ending in .otf or .ttf, that holds glyphs for Unicode characters together with rules for ligatures and other features. XeLaTeX and LuaLaTeX load such fonts through fontspec; Typst uses them directly.
- tofu
- The empty box drawn where no font has a glyph for a character, named after its shape. Typst draws one when no font it knows has the character, and prints no warning.
- ToUnicode map
- The table in a PDF that says which characters each glyph of a font stands for, so that its text can be copied, searched and read aloud. pdffonts prints yes in its uni column when a font has one.
References
- The fontspec package (CTAN)
- The luaotfload package (CTAN)
- LaTeX News, issue 33 (The LaTeX Project)
- TeX Live at arXiv (arXiv)
- Typst reference: text (Typst)
- Poppler (freedesktop.org)
- pdffonts(1) manual page (freedesktop.org (Poppler))
- LaTeX font encodings (The LaTeX Project)
- SIL Open Font License, version 1.1 (SIL International)
- The GUST Font License (CTAN)
- The lm package: Latin Modern fonts (CTAN)
- The libertinus-fonts package (CTAN)
- Noto Sans Tamil (Noto project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress