Tutorial2 days ago

3. Typst's Most Popular CV Template Ships a Broken PDF and Exits 0

We ran the most downloaded Typst CV template on a clean Mac. It produced a resume with five missing font families, no icons, and exit code 0. Then we built our own template and generated 8 branded PDFs in 0.78 seconds.

The WJS Desk

Sep 22, 2026 · 9 min read

Photo by Magda Ehlers on Pexels

Parts one and two got you compiling and got your data into the document. This part is about the two things that decide whether Typst is usable for real work: other people's packages, and your own template.

We will do both. We run the most popular CV template in the registry and show you the PDF it produces on a clean Mac, which is not the PDF the screenshots show. Then we build a small branded template of our own and use it to generate eight PDFs from one source file in 0.78 seconds.

The registry, measured

Typst packages live at typst.app/universe and are pulled from a single index. You can fetch that index yourself, which is the fastest way to get an honest picture of the ecosystem's size:

curl -s https://packages.typst.org/preview/index.json -o idx.json
ls -la idx.json

The file is 2,245,412 bytes. Parsing it, we counted 1,617 unique package names across 4,789 published versions. For scale, that is a fraction of a percent of CTAN or npm, which is about what you would expect from a registry that did not exist a few releases ago.

There is no install step. You write an import with a version in it, and Typst downloads the package on first compile into ~/Library/Caches/typst. There is also no lockfile, no resolver, and no way to say "whatever the latest compatible version is". Every import pins an exact version, and transitive dependencies bring whichever version they pinned. Part two showed what that costs when two of them are the same library.

Running somebody else's template

Templates are just packages that carry a project scaffold. We cleared the package cache first so this was genuinely a first run:

rm -rf ~/Library/Caches/typst/packages/preview
mkdir -p /tmp/wjs-typst/tpl
cd /tmp/wjs-typst/tpl
typst init @preview/modern-cv

It downloaded modern-cv 0.10.0, 1.1 MiB, and scaffolded a project in 1.44 seconds total including the download. You get resume.typ, resume2.typ, two cover letter variants, an assets directory and a sections directory. Then:

cd modern-cv
typst compile resume.typ

1.15 seconds. A 71,528 byte PDF. Exit code 0. And this on stderr:

warning: unknown font family: source sans pro
warning: unknown font family: source sans 3
warning: unknown font family: roboto
warning: unknown font family: font awesome 7 free
warning: unknown font family: font awesome 7 brands

Five missing font families, and the word "error" appears nowhere. The command succeeded. A PDF exists. It has the wrong typeface throughout and no icons at all, because the phone, email and GitHub glyphs in that template are Font Awesome characters and Font Awesome is not installed. If you scripted this into a pipeline and checked the exit code, your pipeline is green and your CV is wrong.

This is not modern-cv's fault, and it is not a bug in Typst either. Typst deliberately treats a missing font as recoverable, and a template cannot ship licensed fonts it does not own. But the two behaviours combine into a default experience where the most popular template in the registry produces a broken document and tells you so only in a stream most people do not read.

Fixing it, with the actual downloads

Both fonts are free, and neither needs an account. Font Awesome publishes a desktop archive on its release page, and Adobe publishes Source Sans on GitHub:

mkdir -p /tmp/wjs-typst/fonts
cd /tmp/wjs-typst/fonts
curl -sL -o fa.zip https://github.com/FortAwesome/Font-Awesome/releases/download/7.0.0/fontawesome-free-7.0.0-desktop.zip
curl -sL -o ss3.zip https://github.com/adobe-fonts/source-sans/releases/download/3.052R/OTF-source-sans-3.052R.zip
unzip -o -q fa.zip -d fa
unzip -o -q ss3.zip -d ss3
mkdir -p flat
cp fa/fontawesome-free-7.0.0-desktop/otfs/*.otf flat/
cp ss3/OTF/*.otf flat/
ls flat | wc -l

6,037,236 bytes for Font Awesome (1.22 seconds) and 2,387,997 bytes for Source Sans, flattened into a directory of 17 OTF files. Now point Typst at it:

cd /tmp/wjs-typst/tpl/modern-cv
typst compile --font-path /tmp/wjs-typst/fonts/flat resume.typ resume-fixed.pdf

Five warnings went to two, and the PDF grew from 71,528 to 80,819 bytes, which is the icon glyphs actually being embedded. The two survivors are source sans pro and roboto, which the template lists as fallback alternates rather than requirements, so it falls through to the Source Sans 3 we installed.

Pro tip: --font-path also reads from the TYPST_FONT_PATHS environment variable, and it recurses. Commit a fonts/ directory next to your document, export that variable once in your shell profile and in CI, and the class of failure where your laptop and your build server render different documents goes away. This is the single highest value fifteen minutes in this course.

How the cache behaves when the network is gone

We wanted to know whether a Typst build is reproducible on a plane or in a sealed CI container, so we tested it rather than guessing. Typst honours the standard proxy variables, so pointing them at a closed port is a clean way to simulate no network:

HTTPS_PROXY=http://127.0.0.1:9 HTTP_PROXY=http://127.0.0.1:9 typst compile usecetz.typ

With the package not yet cached, that failed loudly and correctly:

error: failed to download package (https://packages.typst.org/preview/cetz-0.5.2.tar.gz:
Connection Failed: Connect error: Connection refused (os error 61))

We then compiled once with the network up, and ran the identical command with the dead proxy again. Exit code 0. The cache is enough. Over the course of our testing it grew from 1.9 MB with three packages to 3.8 MB, and importing cetz silently pulled in a transitive dependency called oxifmt that we never asked for and never named.

The failure messages for the other two package mistakes are genuinely good, and both exit 1:

error: package found, but version 99.0.0 does not exist (latest is 0.5.2)
error: package not found (searched for @preview/this-package-does-not-exist-wjs:1.0.0)

Telling you the latest version in the error is a small kindness that saves a browser tab.

The worked example: your own template, used eight times

Here is the thing we actually wanted out of Typst. One branded template, one data file, and a PDF per row, generated from the shell.

A template in Typst is not a special file type. It is a module with functions in it. Ours is 25 lines, in brand.typ:

#let accent = rgb("#FF6B35")

#let wjs-page(title: "", subtitle: "", body) = {
  set page(paper: "a4", margin: (x: 2cm, y: 2.2cm), header: [
    #set text(size: 8pt, fill: gray)
    WhatJustShipped desk report #h(1fr) #datetime.today().display("[year]-[month]-[day]")
  ])
  set text(font: "Libertinus Serif", size: 11pt)
  show heading.where(level: 1): it => [
    #set text(fill: accent, size: 20pt)
    #it.body
    #v(-6pt)
    #line(length: 100%, stroke: 1.5pt + accent)
  ]
  heading(level: 1, title)
  if subtitle != "" { text(size: 10pt, fill: gray, subtitle); v(6pt) }
  body
}

#let stat(label, value) = box(
  inset: 8pt, radius: 4pt, fill: luma(245), width: 100%,
)[
  #text(size: 8pt, fill: gray, upper(label)) \\
  #text(size: 16pt, weight: "bold", value)
]

The interesting line is the show rule. show heading.where(level: 1) intercepts every top level heading and redraws it in our orange with a rule underneath. That is the whole styling model: you match elements and replace how they are drawn, from anywhere, without the document body knowing.

The document that uses it is 22 lines, and it takes its subject from the command line:

#import "brand.typ": wjs-page, stat

#let target = sys.inputs.at("repo")
#let rows = csv("repos.csv", row-type: dictionary)
#let r = rows.find(x => x.name == target)

#show: wjs-page.with(
  title: r.name,
  subtitle: "Generated from the GitHub REST API",
)

#grid(columns: (1fr, 1fr, 1fr), gutter: 8pt,
  stat("Stars", r.stars),
  stat("Open issues", r.open_issues),
  stat("Language", r.language),
)

#v(10pt)

License is *#r.license*. Last push on #r.pushed.

Stars per open issue: *#calc.round(int(r.stars) / calc.max(int(r.open_issues), 1), digits: 1)*.

sys.inputs is populated by --input key=value. #show: wjs-page.with(...) wraps the entire rest of the document in our template function. Now generate all eight:

mkdir -p out
tail -n +2 repos.csv | while IFS=, read -r name rest; do
  n=$(printf "%s" "$name" | tr -d '"')
  typst compile --input repo="$n" onepager.typ "out/$(printf "%s" "$n" | tr '/' '-').pdf"
done

Confirmed: eight branded A4 PDFs, 160 KB total, in 0.78 seconds. We ran it four times and got 0.95, 1.10, 0.78 and 0.78 seconds. That is roughly 0.10 seconds per document, process startup included, from 47 lines of source across two files.

The typst/typst sheet came out with 56,184 in the stars box, 1,292 in open issues, and a computed 43.5 stars per open issue in the body text. None of those numbers are typed into the document.

What broke, part two: the error that points at the wrong line

We ran the one-pager two ways it would plausibly get run wrong. Forgetting --input entirely gives you a fair message:

error: dictionary does not contain key "repo" and no default value was specified

Passing a repo that is not in the CSV does not:

error: cannot access fields on type none
  ┌─ onepager.typ:8:11
  │
8 │   title: r.name,
  │            ^^^^

The caret is on line 8. The mistake is on line 5, where .find() returned none because nothing matched, and none then travelled three lines before anything complained. On a 22 line file that is an annoyance. In a template with a dozen helpers it is a genuine hunt. The fix is to check your own preconditions and say so:

#let target = sys.inputs.at("repo", default: none)
#if target == none { panic("pass --input repo=owner/name") }

#let r = rows.find(x => x.name == target)
#if r == none { panic("no row for " + target + " in repos.csv") }

Both now fail on the right line with a sentence a human wrote: error: panicked with: no row for not/a-repo in repos.csv.

Wiring it into a build

Typst will tell you what a document actually depended on, which is what you need if you are putting this in a Makefile and want incremental rebuilds to be correct:

typst compile --input repo=typst/typst --deps - --deps-format make onepager2.typ out.pdf

That printed out.pdf: repos.csv brand.typ onepager2.typ on stdout. Three real files, including the CSV, discovered by the compiler rather than maintained by you.

Common mistakes

  • Trusting the exit code of a template compile. Read stderr. Missing fonts are warnings.
  • Installing fonts into macOS instead of a project directory. It fixes your laptop and guarantees your CI diverges. Use a committed directory and --font-path.
  • Expecting typst init to scaffold a blank project. It will not. typst init . returns error: package specification must start with '@'. A blank project is touch main.typ.
  • Pinning two versions of the same package. There is no resolver to save you.
  • Assuming a clean checkout can build offline. It cannot, until the packages are cached. Warm the cache in your Docker image, or vendor the packages with --package-path.
  • Letting .find() return none. Add a panic() with a useful sentence. Future you is the reader.

The escape hatch

Nothing here is load bearing. A template is a plain .typ file you import, so deleting the import and pasting the set rules into your document leaves you with a working document and no dependency. Packages can be vendored into a local directory and pointed at with --package-path, which takes the registry out of your build entirely. And the whole cache is one directory you can delete.

What we did not test

We ran one template package (modern-cv 0.10.0) and three library packages (cetz, cetz-plot, fontawesome), out of 1,617. We did not publish a package, so we cannot speak to the submission process. We did not test --package-path vendoring, only read that it exists and confirmed the flag is there. Our offline test simulates a dead network with a closed proxy port, which is not identical to a machine with no route at all. And the font fix was verified by counting warnings and the PDF size growing, not by a human reading the resume in a PDF viewer and confirming every icon.

Next

Part four is the verdict, with the benchmark we have been building toward: Typst against a real LaTeX engine on identical content at 1, 50, 200 and 1,000 sections, including the first run that took 8 minutes and 43 seconds. Plus the PDF/A and accessibility results, validated by an independent checker, and the honest list of who should not switch.

Share

The most popular Typst CV template compiled to a PDF with 5 missing font families, no icons, and exit code 0. Nothing in the output said the word error. #Typst #OpenSource #Fonts #Typesetting

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading