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
.typfile 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 andtable.footer - Pass a parameter from the command line with
--input, and read it withsys.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
javacturns.javainto.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:
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:
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.
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.headerrepeats on every page, as a column header should.int(n.words)converts text to a number, asNumber()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)))),
)
},
3. Report footer¶
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, andtypst watchwhile you work. - In a
.typfile, markup is text and#starts code. Comments are//. - CSV values are text in Typst too:
int(n.words)first, andstr(...)to put a number in a cell. table.headerrepeats on each page.table.footerrepeats too, unless you writerepeat: false, which makes it a report footer.--input key=valueandsys.inputsturn 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-typeargument, because the rows arrive as plain arrays - A
--rootoption, 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.