I wrote my PhD thesis on AI and Natural Language Processing entirely in Typst! The overall experience was great, especially compared to my previous thesis, which I wrote in LaTeX (and which involved many hours of frustration…).
I didn’t use any official template, as my university didn’t provide one. Instead, I built the entire document from scratch, which turned out to be much easier than I expected. I was able to achieve almost exactly the layout and style I had in mind, with the possible exception of the bibliography, which seems to be a common challenge for thesis writing in Typst.
I could probably have taken advantage of more packages, but in the end I mainly relied on cetz and fletcher for diagrams, hydra for chapter headers, and flagada for national flags (I included several examples featuring text translated into different languages). I would also have loved to recreate my result charts using lilaq , but time was running short, so I ended up embedding them as PDFs.
I hope this might be useful or encouraging to someone considering Typst for a large academic project. Below are a couple of screenshots, along with some code snippets!
The code for the Transformer architecture figure from Vaswani et al. (2017):
#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
#import fletcher.shapes: house, hexagon, trapezium
#import "@preview/cetz:0.5.2": canvas, draw.bezier
#set par(leading: 0.5em)
#let blob(
pos,
label,
tint: color.white.transparentize(100%),
..args
) = node(
pos,
align(center, label),
width: 3cm,
fill: tint.lighten(60%),
stroke: 1pt + tint.darken(20%),
corner-radius: 5pt,
inset: 5pt,
..args,
)
// Renders a "block" of blobs with the main element in the center, the "Add & Norm" blob on top, and the dashed arrow on the side.
#let blob-add-norm(
pos,
group-name,
body,
tint: orange,
incoming-arrow-points: (-1, +1),
..args
) = {
if type(group-name) == label {
group-name = str(group-name)
}
// Main blob
let main-label = label(group-name + "-main")
blob(pos, name: main-label, [#body], tint: tint, ..args)
edge()
// Add & Norm blob
let add-label = label(group-name + "-add")
blob((rel: (0cm, 0.5cm), to: label(group-name + "-main.north")), name: add-label, [Add & Norm], tint: yellow, shape: hexagon, width: 2.8cm)
// Discriminate encoder (left) and decoder (right) blobs
let side = if group-name.starts-with("enc") {-2.2cm} else {2.2cm}
// Edge from init to Add & Norm
let btm-distance = if incoming-arrow-points.len() > 0 {0.8cm} else {0.4cm}
let bottom-origin-point = (rel: (0cm, -btm-distance), to: label(group-name + "-main.south"))
edge(
bottom-origin-point,
(rel: (side, 0cm), to: bottom-origin-point),
(rel: (side, 0cm), to: add-label),
add-label,
"--|>",
)
for x in incoming-arrow-points {
edge(
(rel: (0cm, -0.4cm), to: label(group-name + "-main.south")),
(rel: (x * 1cm, -0.4cm), to: label(group-name + "-main.south")),
(rel: (x * 1cm, 0cm), to: label(group-name + "-main.south")),
"-|>",
)
}
node(
enclose: (main-label, add-label, (rel: (side, 0cm), to: bottom-origin-point)),
stroke: none,
inset: 1pt,
name: label(group-name),
)
}
#diagram(
edge-stroke: 1pt,
edge-corner-radius: 5pt,
mark-scale: 70%,
{
blob((0cm, -0.5cm), name: <input>, [Inputs], stroke: none)
edge()
blob((0cm, 1cm), name: <input-embs>, [Input\ Embeddings], width: auto, tint: red.lighten(50%), shape: trapezium)
edge(<enc-mhatt-main>, "-|>")
blob-add-norm((0cm, 5cm), <enc-mhatt>, tint: orange)[Multi-Head\ Attention]
edge(<enc-mhatt-add>, <enc-ff-main>, "-|>")
blob-add-norm((0cm, 8cm), <enc-ff>, tint: aqua, incoming-arrow-points: ())[Feed\ Forward]
node(
name: <enc>,
enclose: (
<enc-mhatt>,
<enc-ff>
),
inset: 3mm,
fill: gray.transparentize(80%),
stroke: 1pt + gray,
corner-radius: 14pt,
)
let s = 5cm
blob((s, -0.5cm), [Outputs\ (shifted right)], name: <output>, stroke: none)
edge()
blob((s, 1cm), name: <output-embs>, [Output\ Embeddings], width: auto, tint: red.lighten(50%), shape: trapezium)
edge(<dec-mask-main>, "-|>")
blob-add-norm((s, 5.2cm), <dec-mask>, tint: orange)[Masked\ Multi-Head\ Attention]
edge(<dec-mask-add>, <dec-mhatt-main>, "-|>")
blob-add-norm((s, 8.7cm), <dec-mhatt>, tint: orange, incoming-arrow-points: (1, ))[Multi-Head\ Attention]
edge(<dec-mhatt-add>, <dec-ff-main>, "-|>")
blob-add-norm((s, 11.5cm), <dec-ff>, tint: aqua, incoming-arrow-points: ())[Feed\ Forward]
node(
name: <dec>,
enclose: (
<dec-mask>,
<dec-mhatt>,
<dec-ff>
),
inset: 3mm,
fill: gray.transparentize(80%),
stroke: 1pt + gray,
corner-radius: 14pt,
)
edge(
<enc-ff-add.north>,
(rel: (0cm, 1cm)),
(rel: (s / 2, 0cm)),
(rel: (-s / 2, -0.5cm), to: <dec-mhatt-main.south>),
(rel: (-0.4cm, -0.5cm), to: <dec-mhatt-main.south>),
(rel: (-0.4cm, 0cm), to: <dec-mhatt-main.south>),
"-|>",
)
edge(<dec-mhatt-add>, <linear>, "-|>")
blob((s, 14cm), name: <linear>, [Linear], tint: purple.lighten(50%))
edge("-|>")
blob((s, 15.2cm), name: <softmax>, [Softmax], tint: green)
edge("-|>")
blob((s, 16.8cm), [Output\ Probabilities],)
node((rel: (-0.5cm, 0cm), to: <enc.west>))[N$times$]
node((rel: (0.5cm, 0cm), to: <dec.east>))[N$times$]
let pos-enc = circle(radius: 12pt)[#canvas(bezier((-12pt, 0), (12pt, 0), (0, 16pt), (0, -16pt)))]
node((0cm, 2.5cm), inset: 0mm, label: <enc-plus>)[#text(size: 1.5em)[$plus.o$]]
edge()
node((-2cm, 2.5cm), inset: 0mm)[#stack(dir: ltr, spacing: 8pt, align(right)[Positional\ Encoding], pos-enc)]
node((s, 2.5cm), inset: 0mm, label: <dec-plus>)[#text(size: 1.5em)[$plus.o$]]
edge()
node((s + 2cm, 2.5cm), inset: 0mm)[#stack(dir: ltr, spacing: 8pt, pos-enc, align(left)[Positional\ Encoding])]
}
)

