Skip to content

PDF reports with Typst

Exercise typst-pdf-reports-annex.pdf · PDF

At the end of term your tutor hands out 25 report cards. Same boxes, same logo, same footer. Does she draw the table 25 times? Of course not. The school prints the form once, and each student's marks fill it. And if the school changes its logo? One form changes, and 25 cards follow.

Your report lives on a screen. Today it becomes an A4 PDF you can send, print or archive, and nobody draws it by hand. If you copy and paste a layout, you own two layouts. A template is how you own one.

What you will learn

  • Explain what a template is: the part of a report that is the same every time
  • Compile a .typ file into a PDF, and tell markup from code
  • Load a CSV in Typst and convert its text into numbers
  • Build the report table with table.header, group headers, group footers and table.footer
  • Pass a parameter from the command line with --input, and read it with sys.inputs
  • The trap: why a table footer needs repeat: false

The form and the ink

We will keep one picture all the way through this page: a pre-printed form.

A template is the form: page size, header, footer, fonts. You write it once. The data is the ink: rows and totals.

On the report card In your report Lives in
The pre-printed form Page, page header, page footer template.typ
The ink Rows, groups, totals notes-report.typ + the CSV

Careful: a PDF report is not a screenshot of the page

A screenshot of a web page is cut wherever the paper ends: half a row at the bottom, and no page numbers. A report on paper needs page bands (page header, page footer) that a screen does not have. You met them in the structure of a report.

What Typst is

Typst is a typesetting system: a markup language plus a compiler. Text goes in, a PDF comes out, like javac turns .java into .class.

Typst was started by Laurenz Mädje and Martin Haug in Berlin, and its compiler has been open source since March 2023. The compiler is one program on your machine, so it does not need the internet. The code on this page was run on Typst 0.15.1. Check yours with typst --version.

Two commands do almost everything:

Command What it does
typst compile file.typ Writes file.pdf once
typst watch file.typ Compiles again every time you save

And one file mixes two languages:

Language Looks like Used for
Markup = Title, *bold* Text
Code, after a # #table(...), #let x = 3 Logic. It can read files: csv(...), json(...)

In Typst, # is not a comment. Comments are //, as in Java.

A preview inside VS Code

For a live preview in VS Code, install the extension Tinymist Typst. Without it, typst watch and any PDF viewer that reloads work just as well.

Hello, report

In your project, create the folder reports/ and the file reports/notes-report.typ. It reads the same notes.csv as your app:

#let notes = csv("../static/data/notes.csv", row-type: dictionary)

= Notes report

There are #notes.len() notes. The first one is *#notes.first().title*,
with #notes.first().words words.

In the terminal, from the project root:

typst compile --root . reports/notes-report.typ notes-report.pdf

The PDF says: There are 36 notes. The first one is Complaint from table 7, with 419 words.

With row-type: dictionary, each row looks like our JavaScript objects: n.title, n.words. And just as in JavaScript, every value is text: "419".

The error everyone gets

Run it without --root ., or from inside reports/, and Typst answers:

error: path `"../static/data/notes.csv"` would escape the project root

Typst only reads files inside its root, which by default is the folder of the .typ file. A document cannot wander around your disk. --root . says "the root is this project".

The template

Save this file as reports/template.typ.

template.typ

Read it from top to bottom and ask, for each part: which band is this?

// The template: everything that is the same in every report.
#let report(title: "Report", subtitle: none, body) = {
  set document(title: title)
  set text(size: 10pt, lang: "en")
  set page(
    paper: "a4",
    margin: (x: 20mm, top: 30mm, bottom: 22mm),
    // Page header: repeated at the top of every page
    header: [
      #grid(
        columns: (1fr, auto),
        text(weight: "bold")[NoteBox], title,
      )
      #line(length: 100%, stroke: 0.5pt)
    ],
    // Page footer: repeated at the bottom of every page
    footer: context grid(
      columns: (1fr, auto),
      [Generated on #datetime.today().display("[day]/[month]/[year]")],
      counter(page).display("1 / 1", both: true),
    ),
  )

  // Report header: once, on the first page
  text(size: 22pt, weight: "bold", title)
  if subtitle != none {
    linebreak()
    text(fill: luma(90), subtitle)
  }
  v(6pt)

  body
}

report is a function: it receives a title, a subtitle and the body of the document, and it returns the body dressed with the form. The footer uses today's date, so two compilations on different days give different PDFs. That is intended, because a report is a photo of a moment.

To use it, replace the heading and the paragraph of notes-report.typ:

#import "template.typ": report

#let notes = csv("../static/data/notes.csv", row-type: dictionary)

#show: report.with(
  title: "Notes report",
  subtitle: [Folder: All · #notes.len() notes],
)

#show: report.with(...) means "pass the rest of this file through the function report". Compile it: you get an A4 page with the page header, the title, "Folder: All · 36 notes", and the footer with the date and 1 / 1.

The table, band by band

Compile after each step. typst watch in a second terminal saves you typing.

1. Column header and detail

#let minutes(n) = calc.ceil(int(n.words) / 238)

#table(
  columns: (1fr, auto, auto, auto),
  align: (left, left, right, right),
  stroke: (x: none, y: 0.5pt),
  table.header([*Title*], [*Created*], [*Words*], [*Reading (min)*]),
  ..notes.map(n => (n.title, n.created, n.words, str(minutes(n)))).flatten(),
)

Three things to notice:

  • table.header repeats on every page, as a column header should.
  • int(n.words) converts text to a number, as Number() did in JavaScript.
  • The two dots .. spread an array into separate cells.

2. Groups

Try this one yourself first. You need the list of folders, and then one block of cells per folder. Above the table:

#let sum(list) = list.fold(0, (total, x) => total + x)
#let folders = notes.map(n => n.folder).dedup().sorted()

And inside #table(...), in place of the ..notes.map line:

  ..for f in folders {
    let group = notes.filter(n => n.folder == f)
    (
      // Group header
      table.cell(colspan: 4, fill: luma(225), strong(f)),
      // Detail rows
      ..group.map(n => (n.title, n.created, n.words, str(minutes(n)))).flatten(),
      // Group footer: count and subtotals
      table.cell(colspan: 2, emph[#group.len() notes in #f]),
      emph(str(sum(group.map(n => int(n.words))))),
      emph(str(sum(group.map(minutes)))),
    )
  },

This is the last argument of the table:

  table.footer(
    repeat: false,
    table.cell(colspan: 2, strong[Total · #notes.len() notes]),
    strong(str(sum(notes.map(n => int(n.words))))),
    strong(str(sum(notes.map(minutes)))),
  ),

Why repeat: false? Because a table footer repeats on every page by default, so your grand total would show up at the bottom of page 1, before the data has ended. Grand totals appear once, at the end: that is a report footer.

4. Check

You get two pages, and the last line says Total · 36 notes · 9213 · 58. The group footers are Personal 1640 · 12, School 5217 · 28, Shopping 286 · 7 and Work 2070 · 11. These are the same numbers as on the screen, and they must be: same CSV, same formula.

The error What it means
cannot add integer and string An int() is missing around n.words
expected content, found integer A table cell must be content or text, so wrap numbers in str(...)
The columns are shifted .flatten() is missing, or a group footer has three cells instead of four (count the colspan)
unknown variable: report The #import line is missing, or the template is in another folder

A parameter

Now for a report of one folder, without touching the file. Above the table:

#let folder = sys.inputs.at("folder", default: "All")
#let rows = notes.filter(n => folder == "All" or n.folder == folder)

Then replace notes with rows in the subtitle, the folder list, the groups and the footer, and write Folder: #folder in the subtitle. Compile twice:

typst compile --root . reports/notes-report.typ notes-report.pdf
typst compile --root . --input folder=School reports/notes-report.typ school.pdf

school.pdf has one page: 12 notes · 5217 words · 28 minutes. An --input key=value on the command line becomes sys.inputs.key, and the value is always text.

Who could run that second command instead of you? A program. In the next lesson, your app runs it when the user clicks Export PDF, with the filters on the screen as parameters. A document is written once. A report is generated every time somebody asks, with today's data and their filter.

The complete report file

reports/notes-report.typ:

#import "template.typ": report

// 1 · Data: one dictionary per CSV row. Every value arrives as text.
#let notes = csv("../static/data/notes.csv", row-type: dictionary)

// 2 · Parameter: typst compile --input folder=School ...
#let folder = sys.inputs.at("folder", default: "All")
#let rows = notes.filter(n => folder == "All" or n.folder == folder)

// 3 · Calculated value and totals
#let minutes(n) = calc.ceil(int(n.words) / 238)
#let sum(list) = list.fold(0, (total, x) => total + x)

#show: report.with(
  title: "Notes report",
  subtitle: [Folder: #folder · #rows.len() notes],
)

// 4 · Body: one group per folder, then the grand total
#let folders = rows.map(n => n.folder).dedup().sorted()

#table(
  columns: (1fr, auto, auto, auto),
  align: (left, left, right, right),
  stroke: (x: none, y: 0.5pt),
  table.header([*Title*], [*Created*], [*Words*], [*Reading (min)*]),
  ..for f in folders {
    let group = rows.filter(n => n.folder == f)
    (
      // Group header
      table.cell(colspan: 4, fill: luma(225), strong(f)),
      // Detail rows
      ..group.map(n => (n.title, n.created, n.words, str(minutes(n)))).flatten(),
      // Group footer: count and subtotals
      table.cell(colspan: 2, emph[#group.len() notes in #f]),
      emph(str(sum(group.map(n => int(n.words))))),
      emph(str(sum(group.map(minutes)))),
    )
  },
  // Report footer: grand totals
  table.footer(
    repeat: false,
    table.cell(colspan: 2, strong[Total · #rows.len() notes]),
    strong(str(sum(rows.map(n => int(n.words))))),
    strong(str(sum(rows.map(minutes)))),
  ),
)

JavaScript and Typst, side by side

It is the same report logic, twice. You already know the left column.

Step JavaScript Typst
Load csvParse(text, toNote) csv("notes.csv", row-type: dictionary)
Text → number Number(row.words) int(n.words)
Calculated value Math.ceil(words / 238) calc.ceil(int(n.words) / 238)
Filter notes.filter((n) => ...) notes.filter(n => ...)
Sum rows.reduce((s, n) => s + n.words, 0) list.fold(0, (total, x) => total + x)
Group Object.groupBy(rows, (n) => n.folder) for f in folders { rows.filter(...) }
Parameter $state + bind:value sys.inputs.at("folder", default: "All")

Your own report

Write the PDF version of your own report with the same template.typ: your data file, a column header, one grouping with group footers, a calculated value, a report footer, and one --input parameter shown in the subtitle. Then change the app name in the page header of the template, and check that every report you have follows it. That is the form at work.

Key takeaways

  • A template is the form, written once: page, page header, page footer. The data is the ink.
  • Typst compiles text into a PDF: typst compile, and typst watch while you work.
  • In a .typ file, markup is text and # starts code. Comments are //.
  • CSV values are text in Typst too: int(n.words) first, and str(...) to put a number in a cell.
  • table.header repeats on each page. table.footer repeats too, unless you write repeat: false, which makes it a report footer.
  • --input key=value and sys.inputs turn a document into a report a program can generate.

Quick check

1. A school changes the logo on its 500 PDF certificates, which share a Typst template. What has to change?
  • One template file, and then the certificates are compiled again
  • All 500 report files, each edited by hand with the new logo
  • Only the CSV file, because the logo is one of its data columns
  • Nothing at all, because PDFs update themselves after being sent

The template is the form, written once. That is exactly why you do not copy and paste a layout: one form changes, and every report follows when it is compiled again.

2. What does the # do in a Typst file?
  • It starts code inside the markup, such as #table(...)
  • It starts a comment, which the compiler ignores
  • It makes the text after it bigger, like a heading
  • It marks the end of a page in the final document

Typst mixes markup for text and code after a #. Comments are //, and a heading is = Title.

3. You run typst compile reports/notes-report.typ and get an error saying a path would escape the project root. Why?
  • The file reads a CSV outside its folder, and the root is that folder by default
  • The CSV has more rows than a Typst table is able to hold on a single page
  • Typst needs an internet connection to read any file with a relative path
  • The output PDF has the same name as a file already there in the project

Typst only reads files inside its root, which defaults to the folder of the .typ file. Run it from the project root with --root ., and ../static/data/notes.csv is allowed.

4. A Typst report fails with cannot add integer and string when summing the words. What is missing?
  • An int() around the value, because CSV values arrive as text
  • A str() around the value, because table cells only hold text
  • A row-type argument, because the rows arrive as plain arrays
  • A --root option, because the sum is out of the project folder

Just as in JavaScript, every CSV value is text. Convert with int(n.words) before adding. str(...) goes the other way, to put a number into a cell.

5. What happens if you forget repeat: false in the table.footer of a two-page report?
  • The grand total also appears at the bottom of page 1, before the data has ended
  • The grand total disappears, because a footer needs that argument to be shown
  • The column header disappears from page 2, because only one element can repeat
  • Typst stops with an error, because the argument is required on every footer

repeat defaults to true, so the footer behaves like something on every page. With repeat: false it appears once, at the end: a report footer.

6. How does the value of --input folder=School reach the document?
  • As sys.inputs.folder, and its value is the text "School"
  • As a new column named folder in every row of the CSV file
  • As a new file named School in the folder of the template
  • As an extra page of the PDF with the name of the folder

--input key=value makes the value available in the dictionary sys.inputs. Reading it with .at("folder", default: "All") keeps the file working when no input is given.

7. A bank sends each of its 4000 customers a monthly PDF statement of their card payments. Pick the two correct statements.
  • The logo and the page footer belong in the template
  • The customer is a parameter, so a program runs the compiler 4000 times
  • The payments belong in the template, so that they are the same for everyone
  • A person edits the report file by hand for each customer before compiling it

The form is what every statement shares, and the ink is what changes. With a parameter, the same file produces every statement, and a program can do the running.

You have built the machine. Now somebody asks for changes, and the app gets its Export button: Change it, export it.