LaTeX & Typst (papers, theses, resumes and slides) Module 7 – Slides, posters and handouts
Typst slides with Touying and Polylux
Build a Typst slide deck with Touying and with Polylux, reveal it step by step, make the handout from one source and pin the exact package versions.
What you will learn
- Build a slide deck with Touying's simple theme, headings as slides and
- Build the same deck with Polylux
- Create a handout from one source with Touying's export mode or Polylux's handout mode, and predict what each keeps
- Specify the exact version of every slide package and build the deck without network access
- Choose between Touying, Polylux and Beamer for a talk
Before you start
What you’ll learn · Before you start
What you will learn
- Build a slide deck with Touying's simple theme, headings as slides and
- Build the same deck with Polylux
- Create a handout from one source with Touying's export mode or Polylux's handout mode, and predict what each keeps
- Specify the exact version of every slide package and build the deck without network access
- Choose between Touying, Polylux and Beamer for a talk
Before you start
On this page
Typst has no slide class of its own: the four-frame talk of the Beamer lesson needs a package, and two packages on Typst Universe, Touying and Polylux, both turned it into the same 9 pages but into handouts of 4 and 5 pages. Which lines a handout keeps, which bullets a reveal leaves behind and which version a build downloads all depend on the package.
Below you will build the talk with Touying’s simple theme and with Polylux, make each handout from the same source with one command-line flag, pin both packages so that the build runs without network access, and compare the three systems on one table. Every page, file count and time on this page comes from the recorded builds.
Touying makes a slide of every second-level heading
Touying reads a deck like a document. A first-level heading, = The question, starts a section; a second-level
heading, == Three questions, starts a slide; #pause splits a slide into subslide: In Touying and Polylux, one step of a slide, printed as one PDF page: a slide with two #pause marks has three subslides., one
page each. #show: simple-theme.with(aspect-ratio: "16-9") applies one of the six themes listed on its Universe page
(simple, metropolis, dewdrop, university, aqua and stargazer). #only and #uncover work as in Beamer: #only(1)
takes its content away on the other subslides, while #uncover(2) keeps its space. talk-touying.typ is the Beamer
talk, slide for slide:
talk/talk-touying.typ
#import "@preview/touying:0.8.0": *
#import themes.simple: *
#set document(title: [How much rain one roof can catch], author: "R. Menon")
#show: simple-theme.with(
aspect-ratio: "16-9",
config-common(new-section-slide-fn: none),
)
#title-slide[
#text(1.4em, weight: "bold")[How much rain one roof can catch]
R. Menon, Station A rain gauge team
]
= The question
== Three questions
- How much rain falls on the roof in July?
#pause
- How much of it reaches the pipe?
#pause
- Where does the water go?
= The answer
== The volume a roof catches
$ V = A times R times C $
#pause
Roof area $A = 120 "m"^2$, July rain $R = 0.655 "m"$, runoff share $C = 0.8$.
#pause
$ V = #alert[$62.88 "m"^3$], "about 62,880 litres." $
== Where the water goes
A 10,000-litre tank holds part of it.
#only(1)[The tank fills in the first rainy week.]
#only(2)[The other 52,880 litres go to a recharge pit.]
#uncover(2)[Plan the pit before the tank.] pages.py
"""Prints what each page of a PDF holds, one line a page: its number, then its lines of text joined by " | ".
python3 pages.py talk.pdf
The text comes from pdftotext (poppler), one page at a time, so a slide that shows one more item than the page before
it shows one more piece here too. Empty lines are left out.
"""
import subprocess
import sys
def page_count(pdf):
info = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, check=True).stdout
return int(next(line.split()[-1] for line in info.splitlines() if line.startswith("Pages:")))
def page_text(pdf, number):
done = subprocess.run(["pdftotext", "-f", str(number), "-l", str(number), pdf, "-"],
capture_output=True, text=True, check=True)
return [line.strip() for line in done.stdout.splitlines() if line.strip()]
def main(pdf):
for number in range(1, page_count(pdf) + 1):
print(f"page {number}: " + " | ".join(page_text(pdf, number)))
if __name__ == "__main__":
main(sys.argv[1]) from session import files, packages, run
# talk-touying.typ: the talk of the Beamer lesson in Touying's simple theme. Touying 0.8.0 and the one package it
# imports itself, uniwarn 0.1.1, are put into the session's package cache, where Typst keeps a downloaded package.
packages("touying:0.8.0", "uniwarn:0.1.1")
files("talk/talk-touying.typ", "pages.py")
run("""
typst compile talk-touying.typ
pdfinfo talk-touying.pdf | grep -E "^(Title|Pages|Page size|Tagged)"
python3 pages.py talk-touying.pdf
""") Output
$ typst compile talk-touying.typ $ pdfinfo talk-touying.pdf | grep -E "^(Title|Pages|Page size|Tagged)" Title: How much rain one roof can catch Tagged: yes Pages: 9 Page size: 841.89 x 473.563 pts $ python3 pages.py talk-touying.pdf page 1: How much rain one roof can catch | R. Menon, Station A rain gauge team page 2: The question | Three questions | • How much rain falls on the roof in July? | 1/3 page 3: The question | Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? | 1/3 page 4: The question | Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? | • Where does the water go? | 1/3 page 5: The answer | The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | 2/3 page 6: The answer | The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. | 2/3 page 7: The answer | The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. | 𝑉 = 62.88m3 , about 62,880 litres. | 2/3 page 8: The answer | Where the water goes | A 10,000-litre tank holds part of it. | The tank fills in the first rainy week. | 3/3 page 9: The answer | Where the water goes | A 10,000-litre tank holds part of it. | The other 52,880 litres go to a recharge pit. | Plan the pit before the tank. | 3/3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 touying_session.py
Typst wrote 9 pages, as Beamer did, on pages of 841.89 by 473.563 points, which is 297 by 167 mm: the simple theme sets its text at 25 points on that larger page. The header of each slide names its section, and the footer counts slides, not pages, so the three subslides of “Three questions” all say 1/3. pdfinfo found the PDF tagged, because Typst writes tagged PDF unless told not to. Step through how the source became those pages:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The title slide. #title-slide[…] is a slide function of the simple theme. It makes page 1 and does not count towards the slide numbers in the footer.
- A section, but no page. A first-level heading starts a section. The simple theme would give it a slide of its own; config-common(new-section-slide-fn: none) turns that off, so the section only names the header of the slides that follow.
- A heading of level 2 is a slide. Each second-level heading starts a new slide, and everything up to the next heading of level 1 or 2 is its body.
- #pause splits it. Two #pause marks split the slide into three subslides, pages 2, 3 and 4, each with one more question. The footer says 1/3 on all three: it counts slides, not pages.
- The second section. The next first-level heading changes the section, so the header of the following slides reads The answer.
- The formula slide. The volume slide has two #pause marks as well: the equation, then the numbers, then the result, pages 5 to 7.
- #only and #uncover. #only(2) and #uncover(2) ask for a second subslide, so the last slide makes pages 8 and 9. #only takes its line away on the other subslide; #uncover keeps its space, as in Beamer.
The simple theme would also give each section a slide of its own; config-common(new-section-slide-fn: none) turns
that off here, so the page count matches the Beamer talk. One more mark is worth knowing: #meanwhile sets the
subslide count back to 1, so what follows it appears in step with what came before it, for example a picture that
stays beside a list whose items come in one by one.
One look for a whole deck
A theme is a show rule, so it changes every slide at once. Change one colour without changing theme with
config-colors(primary: …) in the same simple-theme.with(…) call.
Checkpoint
Check: Touying's headings, pauses and subslides
Polylux builds each slide from a #slide block, and its reveals need a check
Polylux keeps to fewer ideas: each #slide[…] block is a slide, and you size the page and the text yourself with
#set page(paper: "presentation-16-9") and #set text(size: 25pt). It has no themes in the package; its Universe
page points to templates instead. Inside a slide, #uncover("2-") and #only(2) take the same specifications as
Beamer’s, written as a number or as a string such as "2-", and #show: later hides everything after it until the
next subslide, like \pause. talk-polylux.typ is the same talk, and bullets.typ reveals two items in three ways:
talk/talk-polylux.typ
#import "@preview/polylux:0.4.0": *
#set document(title: [How much rain one roof can catch], author: "R. Menon")
#enable-handout-mode(sys.inputs.at("handout", default: "no") == "yes")
#set page(paper: "presentation-16-9")
#set text(size: 25pt)
#slide[
#set align(horizon)
#text(1.4em, weight: "bold")[How much rain one roof can catch]
R. Menon, Station A rain gauge team
]
#slide[
== Three questions
// Each item in a block of its own, so that a hidden item hides its bullet too.
#uncover("1-", block[- How much rain falls on the roof in July?])
#uncover("2-", block[- How much of it reaches the pipe?])
#uncover("3-", block[- Where does the water go?])
]
#slide[
== The volume a roof catches
$ V = A times R times C $
#show: later
Roof area $A = 120 "m"^2$, July rain $R = 0.655 "m"$, runoff share $C = 0.8$.
#show: later
$ V = 62.88 "m"^3, "about 62,880 litres." $
]
#slide[
== Where the water goes
A 10,000-litre tank holds part of it.
#only(1)[The tank fills in the first rainy week.]
#only(2)[The other 52,880 litres go to a recharge pit.]
#uncover(2)[Plan the pit before the tank.]
] talk/bullets.typ
#import "@preview/polylux:0.4.0": *
#set page(paper: "presentation-16-9")
#set text(size: 25pt)
#slide[
== item-by-item
#item-by-item[
- How much rain falls on the roof in July?
- How much of it reaches the pipe?
]
]
#slide[
== uncover, one block per item
#uncover("1-", block[- How much rain falls on the roof in July?])
#uncover("2-", block[- How much of it reaches the pipe?])
]
#slide[
== show: later between items
- How much rain falls on the roof in July?
#show: later
- How much of it reaches the pipe?
] from session import files, packages, run
# talk-polylux.typ: the same talk with Polylux 0.4.0, then bullets.typ, three ways of revealing the same two items.
packages("polylux:0.4.0")
files("talk/talk-polylux.typ", "talk/bullets.typ", "pages.py")
run("""
typst compile talk-polylux.typ
pdfinfo talk-polylux.pdf | grep -E "^(Pages|Page size|Tagged)"
python3 pages.py talk-polylux.pdf
typst compile bullets.typ
python3 pages.py bullets.pdf
""") Output
$ typst compile talk-polylux.typ $ pdfinfo talk-polylux.pdf | grep -E "^(Pages|Page size|Tagged)" Tagged: yes Pages: 9 Page size: 841.89 x 473.563 pts $ python3 pages.py talk-polylux.pdf page 1: How much rain one roof can catch | R. Menon, Station A rain gauge team page 2: Three questions | • How much rain falls on the roof in July? page 3: Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? page 4: Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? | • Where does the water go? page 5: The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 page 6: The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. page 7: The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. | 𝑉 = 62.88m3 , about 62,880 litres. page 8: Where the water goes | A 10,000-litre tank holds part of it. | The tank fills in the first rainy week. page 9: Where the water goes | A 10,000-litre tank holds part of it. | The other 52,880 litres go to a recharge pit. | Plan the pit before the tank. $ typst compile bullets.typ $ python3 pages.py bullets.pdf page 1: item-by-item | • How much rain falls on the roof in July? | • page 2: item-by-item | • How much rain falls on the roof in July? | • How much of it reaches the pipe? page 3: uncover, one block per item | • How much rain falls on the roof in July? page 4: uncover, one block per item | • How much rain falls on the roof in July? | • How much of it reaches the pipe? page 5: show: later between items | • How much rain falls on the roof in July? | • page 6: show: later between items | • How much rain falls on the roof in July? | • How much of it reaches the pipe?
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 polylux_session.py
The talk made 9 pages again, on the same 297 by 167 mm page. bullets.typ shows why its list is written the way it is.
With #item-by-item, Polylux’s own helper for lists, page 1 of the slide held the first question and a bare “•”:
Polylux 0.4.0 under Typst 0.15.1 hid the second item’s words but drew its bullet. #show: later between the two
items left the same bare bullet on page 5. With each item in a block of its own inside #uncover, the bullet went
with its item, and page 3 held only the first question.
The same steps, written in each of the three systems:
| What it does | Beamer | Touying | Polylux |
|---|---|---|---|
| a slide | \begin{frame}{Title} |
== Title |
#slide[== Title …] |
| hide the rest until the next step | \pause |
#pause |
#show: later |
| from step 2 on, space kept | \uncover<2->{…} |
#uncover("2-")[…] |
#uncover("2-")[…] |
| only on step 1, no space | \only<1>{…} |
#only(1)[…] |
#only(1)[…] |
Trusting a reveal you have not looked at
Wrong: revealing a list with #item-by-item[…] or #show: later and presenting it unseen, so the audience sees
an empty bullet for every item still to come. Right: look at every subslide of the PDF, or run pages.py on it,
before the talk; on these versions, put each item in its own block.
Checkpoint
Check: Polylux slides and checking their reveals
One source file gives the slides and the handout
Both packages make the handout from the same file, and neither needs the source edited: Typst’s --input passes a
value on the command line that the source reads from sys.inputs. Touying reads export-mode itself, so
--input export-mode=handout gives its handout. Polylux switches with #enable-handout-mode(true);
talk-polylux.typ passes it sys.inputs.at("handout", default: "no") == "yes", so --input handout=yes does the
same:
from session import files, packages, run
# One source file each, two PDFs each: Touying's export mode and Polylux's handout mode, both chosen on the command
# line with --input, which the source reads through sys.inputs.
packages("touying:0.8.0", "uniwarn:0.1.1", "polylux:0.4.0")
files("talk/talk-touying.typ", "talk/talk-polylux.typ", "pages.py")
run("""
typst compile --input export-mode=handout talk-touying.typ touying-handout.pdf
python3 pages.py touying-handout.pdf
typst compile --input handout=yes talk-polylux.typ polylux-handout.pdf
python3 pages.py polylux-handout.pdf
""") Output
$ typst compile --input export-mode=handout talk-touying.typ touying-handout.pdf $ python3 pages.py touying-handout.pdf page 1: How much rain one roof can catch | R. Menon, Station A rain gauge team page 2: The question | Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? | • Where does the water go? | 1/3 page 3: The answer | The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. | 𝑉 = 62.88m3 , about 62,880 litres. | 2/3 page 4: The answer | Where the water goes | A 10,000-litre tank holds part of it. | The other 52,880 litres go to a recharge pit. | Plan the pit before the tank. | 3/3 $ typst compile --input handout=yes talk-polylux.typ polylux-handout.pdf $ python3 pages.py polylux-handout.pdf page 1: How much rain one roof can catch | R. Menon, Station A rain gauge team page 2: Three questions | • How much rain falls on the roof in July? | • How much of it reaches the pipe? | • Where does the water go? page 3: The volume a roof catches | 𝑉 =𝐴×𝑅×𝐶 | Roof area 𝐴 = 120m2 , July rain 𝑅 = 0.655 m, runoff share 𝐶 = 0.8. | 𝑉 = 62.88m3 , about 62,880 litres. page 4: Where the water goes | A 10,000-litre tank holds part of it. | The tank fills in the first rainy week. | Plan the pit before the tank. page 5: Where the water goes | A 10,000-litre tank holds part of it. | The other 52,880 litres go to a recharge pit. | Plan the pit before the tank.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 handout_session.py
The two handouts differ on the last slide. Touying’s handout keeps only the last subslide of each slide, which its
configuration describes as the default, so the line that #only(1) showed on the first subslide, “The tank fills in
the first rainy week”, is not in it: 4 pages. Polylux’s handout mode switches off the reveals that keep their space,
#uncover and #show: later, but the Polylux book says that only and alternatives are exempt, so the last slide
still made two pages: 5 pages. With the Beamer talk beside them, each system treats that last slide its own way:
The last slide, with only(1), only(2) and uncover(2) |
In the handout |
|---|---|
Beamer, class option handout |
one page with both \only lines, unless one says handout:0 |
Touying, export-mode=handout |
one page: the last subslide, without the only(1) line |
Polylux, enable-handout-mode(true) |
two pages: one with each only line |
To keep a line in Touying’s handout, show it on the last subslide too, pick other subslides with
config-common(handout-subslides: …), or add text for the handout alone with #handout-only[…].
Key idea
The same only gave three handouts in three systems, so read the handout page by page before you share it, and
decide for each only which version must keep its line.
Checkpoint
Check: Handouts from one source
Pin every package to the exact version you built with
#import "@preview/touying:0.8.0": * names a namespace, a package and a version, and the package repository’s rules
say you must always give the full version. Typst downloads the package on first use and keeps it in its
package cache: The folder where Typst keeps each package it has downloaded, so that later imports of the same version need no network access.; the same rules say that importing a cached package does not use the
network. A new build machine has an empty cache, and a machine without network access then cannot build at all.
pins_session.py plays that machine: a proxy that refuses every connection stands in for the missing network, and the
project keeps its packages in a folder of its own, vendor, in Typst’s own layout, preview/<name>/<version>/. Step
through where Typst looked:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The import names a version. The import names the namespace preview, the package touying and the full version 0.8.0. A version without its patch number, 0.8, stopped the compile before any search.
- First the package path. Typst first looks in the package path. With no --package-path and an empty data folder, there is nothing there yet.
- Then the cache. Next the package cache, where a package lands after its first download. On a fresh build machine it is empty too.
- Then the network. Only now does Typst download touying-0.8.0.tar.gz. A proxy that refuses every connection stands in for a machine without network access, so the download fails.
- The build stops. The compile ended with "failed to download package" and exit status 1, at the line of the import.
- With the vendor folder. With --package-path vendor, the first place Typst looks holds preview/touying/0.8.0 and preview/uniwarn/0.1.1, so the compile finished without the network and read 28 and 3 files from them.
deps.py
"""Summarises the dependency list that `typst compile --deps deps.json` writes: how many files the compile read from
each package, by namespace, name and version, and how many from the project itself.
python3 deps.py deps.json
A package folder is found by its path, …/<namespace>/<name>/<version>/…, so the summary is the same wherever the
packages were kept: Typst's cache, a package path or a vendor folder of the project.
"""
import json
import re
import sys
from collections import Counter
def main(path):
inputs = json.load(open(path, encoding="utf-8"))["inputs"]
packages = Counter()
project = 0
for name in inputs:
found = re.search(r"/(preview|local)/([a-z0-9-]+)/(\d+\.\d+\.\d+)/", name)
if found:
packages["@{}/{}:{}".format(*found.groups())] += 1
else:
project += 1
for package, count in sorted(packages.items()):
print(f"{package}: {count} files")
print(f"the project itself: {project} file" + ("s" if project != 1 else ""))
if __name__ == "__main__":
main(sys.argv[1]) sizes.py
"""Prints how many files each vendored package folder holds and how large they are together, in KB (1,000 bytes).
python3 sizes.py vendor
The folder has Typst's own layout, <namespace>/<name>/<version>/, as a package path or the package cache does.
"""
import sys
from pathlib import Path
def main(root):
for folder in sorted(Path(root).glob("*/*/*")):
files = [f for f in folder.rglob("*") if f.is_file()]
size = sum(f.stat().st_size for f in files)
namespace, name, version = folder.parts[-3:]
print(f"@{namespace}/{name}:{version}: {len(files)} files, {size / 1000:.1f} KB")
if __name__ == "__main__":
main(sys.argv[1]) from session import files, run
# The packages as a project keeps them: typst-packages/ of this folder copied to ~/demo/vendor, and nothing in Typst's
# package cache. A proxy address that refuses every connection (port 9 on this computer) stands in for a build machine
# without network access. Then the files the compile read, by package; what Typst says about versions that are not
# exact; the compiler version Touying's manifest asks for; and the size of each vendored package.
files("talk/talk-touying.typ", "typst-packages=vendor", "deps.py", "sizes.py")
OFFLINE = "HTTPS_PROXY=http://127.0.0.1:9 HTTP_PROXY=http://127.0.0.1:9"
run(f"""
!{OFFLINE} typst compile talk-touying.typ
{OFFLINE} typst compile --package-path vendor --deps deps.json talk-touying.typ
python3 deps.py deps.json
sed 's/touying:0.8.0/touying:0.8/' talk-touying.typ > loose.typ
!{OFFLINE} typst compile --package-path vendor loose.typ
grep -E "^(version|compiler)" vendor/preview/touying/0.8.0/typst.toml
python3 sizes.py vendor
""") Output
$ HTTPS_PROXY=http://127.0.0.1:9 HTTP_PROXY=http://127.0.0.1:9 typst compile talk-touying.typ error: failed to download package (https://packages.typst.org/preview/touying-0.8.0.tar.gz: Connection Failed: Connect error: Connection refused (os error 61)) ┌─ talk-touying.typ:1:8 │ 1 │ #import "@preview/touying:0.8.0": * │ ^^^^^^^^^^^^^^^^^^^^^^^^ [exit status 1] $ HTTPS_PROXY=http://127.0.0.1:9 HTTP_PROXY=http://127.0.0.1:9 typst compile --package-path vendor --deps deps.json talk-touying.typ $ python3 deps.py deps.json @preview/touying:0.8.0: 28 files @preview/uniwarn:0.1.1: 3 files the project itself: 1 file $ sed 's/touying:0.8.0/touying:0.8/' talk-touying.typ > loose.typ $ HTTPS_PROXY=http://127.0.0.1:9 HTTP_PROXY=http://127.0.0.1:9 typst compile --package-path vendor loose.typ error: version number is missing patch version ┌─ loose.typ:1:8 │ 1 │ #import "@preview/touying:0.8": * │ ^^^^^^^^^^^^^^^^^^^^^^ [exit status 1] $ grep -E "^(version|compiler)" vendor/preview/touying/0.8.0/typst.toml version = "0.8.0" compiler = "0.15.0" $ python3 sizes.py vendor @preview/polylux:0.4.0: 7 files, 14.8 KB @preview/touying:0.8.0: 29 files, 759.5 KB @preview/uniwarn:0.1.1: 4 files, 5.3 KB
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pins_session.py
Without the folder the compile stopped with “failed to download package”. With --package-path vendor it finished,
and --deps listed what it read: 28 files of Touying 0.8.0, 3 files of uniwarn 0.1.1, a package that Touying itself
imports, and the talk. So pinning Touying pins uniwarn too, and a vendor folder must hold both. An import of
touying:0.8 stopped before any search with “version number is missing patch version”. Touying’s manifest,
typst.toml, asks for Typst 0.15.0 or newer, so the Typst version belongs in the build notes as well. The vendor
folder costs little: 759.5 KB in 29 files for Touying, 14.8 KB for Polylux and 5.3 KB for uniwarn.
Versions on this page
Every page here was built with Typst 0.15.1, Touying 0.8.0, uniwarn 0.1.1 and Polylux 0.4.0. Polylux’s own README warns that there are no backwards compatibility guarantees, which is one more reason to name the version.
Checkpoint
Check: Pinning and vendoring packages
Choose by what the talk needs
The three systems made the same 9 pages; they differ in what they ask of you. The build times below are from one computer and depend on what else it was doing; the pages do not:
"""Times the same talk in three systems on this computer: Beamer with latexmk from a clean folder (it runs pdfLaTeX as
often as the deck needs), and Typst with Touying and with Polylux, three compiles each (the table gives the median).
A time is wall-clock time, so it depends on the computer and on what else it is doing; the pages do not.
"""
import re
import statistics
import time
from session import files, packages, shell
packages("touying:0.8.0", "uniwarn:0.1.1", "polylux:0.4.0")
files("talk/talk-beamer.tex", "talk/talk-touying.typ", "talk/talk-polylux.typ")
def timed(command):
"""Runs `command` in ~/demo and returns the seconds it took and what it printed; stops if it fails."""
start = time.perf_counter()
status, output = shell(command)
seconds = time.perf_counter() - start
if status != 0:
raise SystemExit(f"`{command}` ended with exit status {status}:\n{output[-1500:]}")
return seconds, output
def pages(pdf):
_, info = shell(f"pdfinfo {pdf}")
return int(re.search(r"^Pages:\s+(\d+)", info, re.M).group(1))
seconds, output = timed("latexmk -pdf -interaction=nonstopmode talk-beamer.tex 2>&1")
runs = len(re.findall(r"^Run number \d+ of rule 'pdflatex'", output, re.M))
rows = [("Beamer, latexmk", f"{runs} pdfLaTeX runs", pages("talk-beamer.pdf"), seconds)]
for name, source in [("Touying", "talk-touying"), ("Polylux", "talk-polylux")]:
times = [timed(f"typst compile {source}.typ")[0] for _ in range(3)]
rows.append((f"{name}, typst compile", "median of 3", pages(f"{source}.pdf"), statistics.median(times)))
print(f"{'build':<23}{'runs':<17}{'pages':>5}{'seconds':>9}")
for what, runs, page_count, taken in rows:
print(f"{what:<23}{runs:<17}{page_count:>5}{taken:>9.2f}") Output
build runs pages seconds Beamer, latexmk 2 pdfLaTeX runs 9 8.54 Touying, typst compile median of 3 9 0.25 Polylux, typst compile median of 3 9 0.11
This output changes from run to run: the build times are wall-clock times, which change with the computer and its load on every run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timing_session.py
latexmk needed two pdfLaTeX runs and 8.54 seconds for the Beamer talk; Touying took 0.25 seconds and Polylux 0.11. The Typst builds were more than thirty times faster, which is what you notice while you edit. Answer the three questions for your own talk:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| System | Ready-made look | Handout from one source | Build of the talk | What a build needs |
|---|---|---|---|---|
| Touying 0.8.0 Gives you: six themes, headings as slides, export modes | Good six themes in the package; simple was used here | Good --input export-mode=handout: 4 pages, the last subslide of each slide | Good 0.25 s for 9 pages | Mixed Typst 0.15.0 or newer, and Touying with uniwarn: 765 KB to vendor |
| Polylux 0.4.0 Gives you: #slide blocks and reveals; the look is yours | Poor none in the package; templates on Typst Universe | Mixed enable-handout-mode: 5 pages, because only is kept | Good 0.11 s for 9 pages | Good one package of 14.8 KB |
| Beamer 3.78 Gives you: LaTeX frames, themes and modes | Good many themes with the class, more on CTAN | Good class option handout: 4 pages, with mode specifications to choose | Mixed 8.54 s with latexmk, which ran pdfLaTeX twice | Mixed a TeX distribution with beamer and the theme packages |
How the answers decide
- “Do you want a ready-made look, rather than writing your own set and show rules?” Yes, a theme: adds a caveat to Polylux 0.4.0. Polylux has no themes in the package; its Universe page points to templates, or you write the look with set and show rules.
- “Do your co-authors and your templates already use LaTeX?” Yes, LaTeX: adds a caveat to Touying 0.8.0 and Polylux 0.4.0. A Typst deck cannot reuse a LaTeX preamble or Beamer theme; the talk and its macros would be written again.
- “Will you rebuild the deck after every small edit while you write?” Yes, many builds: adds a caveat to Beamer 3.78. latexmk took 8.54 s for the 9-page talk, against under a second for either Typst package; label the frame you edit and use \includeonlyframes.
In short: Touying for a ready-made theme and a handout from one flag; Polylux for a small package whose look you write yourself; Beamer when co-authors and templates are already in LaTeX.
Checkpoint
Check: Choosing a system for a talk
Practise: count the pages, check the pins
Two checks to run on a Touying deck before you share it: the first counts the pages a source makes, as the talk and as the handout; the second finds imports whose version is not exact. Run each step’s sample tests in the browser; a first hint is there when you need one:
Code lab · 2 steps
Check a Touying deck before you share it
Count the pages a Touying source makes as slides and as a handout, then list the package imports that do not name a full version.
Exercise · Medium · Python
Count the pages a Touying deck makes
Before you share a deck, check how many pages it should have. Write page_count(source, handout=False) in pages.py. source is the text of a Touying deck in the style of talk-touying.typ, with the simple theme and no section slides; return the number of PDF pages it makes, as the talk or, with handout=True, as the handout.
- A line that starts with
#title-slideis a slide of its own. A line that starts with==starts a new slide. - A line that starts with
=(one=and a space) starts a section, which makes no page. - Inside a slide, every line that is
#pause(spaces around it allowed) adds one subslide. #only(or#uncover(followed by a number, or by a quoted specification such as"2-", asks for at least that many subslides. A slide has as many subslides as its pauses give or its largest such number asks for, whichever is more.- In the handout, every slide is one page. Lines before the first slide (imports, set and show rules) make no page.
Starter code · pages.py
def page_count(source, handout=False):
"""Return how many pages this Touying source makes, as the talk or as the handout."""
# Replace this line: keep one count of subslides per slide while you read the lines.
return 0 The sample tests · test_pages.py
from pages import page_count
TALK = '''#import "@preview/touying:0.8.0": *
#import themes.simple: *
#show: simple-theme.with(aspect-ratio: "16-9", config-common(new-section-slide-fn: none))
#title-slide[
How much rain one roof can catch
]
= The question
== Three questions
- How much rain falls on the roof in July?
#pause
- How much of it reaches the pipe?
#pause
- Where does the water go?
= The answer
== The volume a roof catches
$ V = A times R times C $
#pause
Roof area, rain and runoff share.
#pause
The result.
== Where the water goes
A 10,000-litre tank holds part of it.
#only(1)[The tank fills in the first rainy week.]
#only(2)[The other 52,880 litres go to a recharge pit.]
#uncover(2)[Plan the pit before the tank.]
'''
def test_the_talk_of_the_lesson():
"""talk-touying.typ made 9 pages and a 4-page handout"""
assert page_count(TALK) == 9
assert page_count(TALK, handout=True) == 4
def test_a_slide_without_marks_is_one_page():
assert page_count("== One\nText.\n== Two\nMore text.\n") == 2
def test_sections_and_set_rules_make_no_page():
assert page_count("#set text(size: 25pt)\n= Part one\n== A slide\nText.\n") == 1
def test_a_quoted_specification_counts_its_number():
assert page_count('== Steps\nFirst.\n#uncover("3-")[Third.]\n') == 3
def test_pauses_and_numbers_take_the_larger():
source = "== Mixed\nA\n#pause\nB\n#only(4)[D]\n"
assert page_count(source) == 4
assert page_count(source + "#pause\nE\n#pause\nF\n") == 4
assert page_count("== Many\nA\n #pause \nB\n#pause\nC\n#pause\nD\n#pause\nE\n#only(2)[x]\n") == 5
def test_an_empty_deck():
assert page_count("") == 0 A hint
Go through the lines once and keep two numbers for each slide: its pauses and the largest number an #only or #uncover asks for. A new slide starts both at 0 and 1. At the end, a slide makes max(pauses + 1, largest) pages; the talk is the sum over the slides and the handout their number.
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 · Easy · Python
Find the package imports that are not pinned
A deck builds the same way next year only if each package it imports is named with a full version. Write loose_imports(source) in pins.py. It returns, in the order they appear, the package specifications of the #import lines of source that do not name a full version.
- A package import looks like
#import "@preview/touying:0.8.0": *: an@, a namespace, a/, a name, then a colon and the version. Only the text in quotes after#importcounts; an import of a file, such as#import "theme.typ": *, is not a package and is never returned. - A full version is three whole numbers separated by dots, as in
0.8.0or1.10.2. Return the specification, such as"@preview/touying:0.8", when the version has fewer or more parts, has anything but digits in a part, or is missing altogether ("@preview/polylux"). - Ignore spaces before
#importand anything after the closing quote.
Starter code · pins.py
def loose_imports(source):
"""Return the package specifications in source whose version is not a full x.y.z version."""
# Replace this line: find each #import of a package and check its version.
return [] The sample tests · test_pins.py
from pins import loose_imports
def test_the_lessons_imports_are_pinned():
source = '#import "@preview/touying:0.8.0": *\n#import "@preview/polylux:0.4.0": *\n'
assert loose_imports(source) == []
def test_a_version_without_its_patch_number():
"""the import that stopped with "version number is missing patch version" """
assert loose_imports('#import "@preview/touying:0.8": *') == ["@preview/touying:0.8"]
def test_a_missing_version():
assert loose_imports('#import "@preview/polylux"') == ["@preview/polylux"]
def test_files_are_not_packages():
assert loose_imports('#import "theme.typ": *\n#import "lib/slides.typ": slide\n') == []
def test_odd_versions():
source = '\n'.join([
'#import "@preview/cetz:0.5.2.1": canvas',
'#import "@preview/zero:v0.7.1": num',
' #import "@local/house-style:1.10.2": *',
'#import "@preview/fletcher:latest": diagram',
])
assert loose_imports(source) == ["@preview/cetz:0.5.2.1", "@preview/zero:v0.7.1", "@preview/fletcher:latest"] A hint
A regular expression such as #import\s+"(@[^"]+)" finds the package specifications. Split each at its last :; when there is no colon, the version is missing. Then check that the version splits at . into exactly three parts, each made of digits only.
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.
The practice questions take the five ideas in turn and come back to any you miss:
Practice that adapts to you
Practise this lesson
14 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): how does Touying decide where one slide ends and the next begins?
It reads the headings. With the default slide level, a heading of level 2 starts a new slide and everything up to the
next heading of level 1 or 2 is its body; a heading of level 1 starts a section, which the simple theme shows in the
header and, unless you turn it off, as a slide of its own. Within a slide, each #pause starts a subslide, one PDF
page, and #only or #uncover with a higher number ask for more. That is why talk-touying.typ gave 9 pages from
four slides: 1 for the title, 3 and 3 for the two slides with two pauses each, and 2 for the slide with only(2).
The footer counts slides, so the three subslides of “Three questions” all say 1/3.
Two harder questions follow, about a build that fails in CI and a handout that lost a line. Decide your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your Touying slides build on your laptop, but the CI job fails with "failed to download package" at the import line, and the job has no network access. Why does the build work on your laptop, and how do you make it the same on both, now and next year?
-
Fresher to mid
After a lecture, a student says the handout you made with Touying's export-mode=handout is missing a sentence that was on the slides, although both come from the same file. How did the sentence get lost, and how do you fix the deck and keep it from happening again?
Key takeaways
- Touying turns second-level headings into slides and each
#pauseinto a subslide: the talk came out as 9 tagged pages of 297 by 167 mm, with a footer that counts slides, 1/3, not pages. - Polylux builds slides from
#slideblocks with Beamer-like specifications; on these versions#item-by-itemand#show: laterleft bare bullets on the page, and oneblockper item inside#uncoverfixed it. --input export-mode=handoutgave Touying’s 4-page handout, which keeps only each slide’s last subslide, while Polylux’s handout mode keptonlyand gave 5 pages; read a handout before you share it.- Imports name a full version, Touying also pulls in uniwarn 0.1.1, and a vendor folder with both, passed with
--package-path, built the talk without network access. - The three systems made the same 9 pages; latexmk took 8.54 seconds where Touying took 0.25 and Polylux 0.11, so the choice rests on the look, the handout and the people who edit the deck.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does Touying split a document into slides?
A first-level heading starts a section, a second-level heading starts a slide, and each #pause starts a subslide, which is one PDF page.
-
What does the footer of Touying's simple theme count?
Slides, not pages: the three subslides of one slide all say 1/3.
-
What does #meanwhile do in Touying?
It sets the subslide count back to 1, so what follows appears in step with what came before it, such as a picture that stays beside a list whose items appear one at a time.
-
How does Polylux mark a slide and a reveal?
Each #slide[...] block is a slide; #uncover("2-") and #only(2) take Beamer-like specifications, and #show: later hides the rest until the next subslide.
-
Why give each Polylux list item a block of its own inside #uncover?
On Polylux 0.4.0 with Typst 0.15.1, #item-by-item and #show: later left a bare bullet for each hidden item; one block per item inside #uncover took the bullet with it.
-
How do you build a Touying handout without editing the source?
typst compile --input export-mode=handout. It keeps only the last subslide of each slide, so a line shown only on an earlier subslide is not in it.
-
How did the talk build without network access?
From a vendor folder in Typst's preview/<name>/<version>/ layout, passed with --package-path, holding Touying 0.8.0 and uniwarn 0.1.1, the package Touying itself imports.
-
How did Beamer, Touying and Polylux compare on the same talk?
All three made the same 9 pages; latexmk took 8.54 s for Beamer, Touying 0.25 s and Polylux 0.11 s in the recorded run.
Terms in this lesson
- package cache
- The folder where Typst keeps each package it has downloaded, so that later imports of the same version need no network access.
- subslide
- In Touying and Polylux, one step of a slide, printed as one PDF page: a slide with two #pause marks has three subslides.
References
- Touying on Typst Universe (Typst)
- Touying documentation: handout mode (The Touying project)
- Touying changelog: export modes and handout-only content (The Touying project)
- Polylux on Typst Universe (Typst)
- The Polylux book: handout mode (The Polylux project)
- Typst packages: the package repository and its rules (Typst)
- Typst reference: scripting, packages (Typst)
- Typst reference: page (paper sizes) (Typst)
- Typst reference: sys (sys.inputs) (Typst)
- Typst guide: Accessibility (Typst)
- beamer: a LaTeX class for producing presentations and slides (CTAN)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress