Typst

Three years ago I wrote about a workflow to generate PDF files out of my Markdown content, both on this website and on De Programmatica Ipsum. That first setup involved Pandoc and TeX, and it was a solution that worked well, but was unfortunately slow as hell, particularly given the amount of articles I have in both sites. A faster solution was badly needed.

Looking for options I discovered that Pandoc supports Typst since version 3.1.2, released in March 2023… but then I asked myself, what is Typst? Well it turns out it’s a new markup language coupled with a processor toolchain written in Rust; not just another one of those great “rewritings in Rust” but a new product, aiming at the same market than LaTeX but with all the speed of a Rust-built binary.

Typst provides all of the features I need, including syntax highlighting, math equation support, links as footnotes, and much more. So I gave it a shot, and with the help of Cursor I adapted the old LaTeX template used to generate PDF files into the one required by Typst, and I launched the generation of PDF files. The final templates are very readable I have to say, much more than the equivalent ones for LaTeX, that’s for sure.

So what happened? Well, the results were nothing short of spectacular. On my personal 2022 ThinkPad X1 Carbon (running Fedora 44) the time for a full build of all the PDF files for De Programmatica Ipsum went from more than 2 hours to… a mere 15 minutes 😳 ! And not only that, but there are many more configuration options available, providing much finer control over the final output.

I also adapted the PDF generation toolchain for my websites, and you can see the final products, for example on this website or on De Programmatica Ipsum. For reference, find below the Typst template I use for the PDFs generated with the contents of this website at the time of this writing.

// Pandoc Typst template
// Compatible with current Typst and Pandoc template variables.

#let horizontalrule = line(start: (25%, 0%), end: (75%, 0%))

#show terms: it => {
  it.children
    .map(child => [
      #strong[#child.term]
      #block(inset: (left: 1.5em, top: -0.4em))[#child.description]
    ])
    .join()
}

#set table(inset: 6pt, stroke: none)

#let admonition(title: [], strokeColor: rgb("#1976D2"), fillColor: rgb("#E7F1FB"), titleColor: rgb("#0D47A1"), body) = block(
  width: 100%,
  inset: (x: 10pt, y: 8pt),
  radius: 4pt,
  fill: fillColor,
  stroke: (left: (paint: strokeColor, thickness: 2.3pt)),
)[
  #set text(size: 0.88em, weight: "bold", fill: titleColor)
  #title
  #parbreak()
  #v(0.2em)
  #set text(size: 1em, weight: "regular", fill: black)
  #body
]

#show figure.where(kind: table): set figure.caption(position: $if(table-caption-position)$$table-caption-position$$else$top$endif$)
#show figure.where(kind: image): set figure.caption(position: $if(figure-caption-position)$$figure-caption-position$$else$bottom$endif$)

#let resolve-image-path = path => {
  if path.starts-with("/") or path.starts-with("http://") or path.starts-with("https://") {
    path
  } else {
    let rp = "$resource-path$"
    if rp == "" {
      path
    } else {
      rp + "/" + path
    }
  }
}

#let _image = image
#let image(path, ..args) = _image(resolve-image-path(path), ..args)

#let month-name = m => (
  if m == "01" { "January" }
  else if m == "02" { "February" }
  else if m == "03" { "March" }
  else if m == "04" { "April" }
  else if m == "05" { "May" }
  else if m == "06" { "June" }
  else if m == "07" { "July" }
  else if m == "08" { "August" }
  else if m == "09" { "September" }
  else if m == "10" { "October" }
  else if m == "11" { "November" }
  else if m == "12" { "December" }
  else { m }
)

#let ordinal = day => {
  let d = int(day)
  if calc.rem(d, 100) == 11 or calc.rem(d, 100) == 12 or calc.rem(d, 100) == 13 {
    [#d#("th")]
  } else if calc.rem(d, 10) == 1 {
    [#d#("st")]
  } else if calc.rem(d, 10) == 2 {
    [#d#("nd")]
  } else if calc.rem(d, 10) == 3 {
    [#d#("rd")]
  } else {
    [#d#("th")]
  }
}

#let format-commit-date = raw => {
  // Accept YYYY-MM-DD or ISO-like values and keep first 10 chars.
  let clean = if raw.len() >= 10 { raw.slice(0, 10) } else { raw }
  let parts = clean.split("-")
  if parts.len() == 3 {
    [#month-name(parts.at(1)) #ordinal(parts.at(2)), #parts.at(0)]
  } else {
    [#raw]
  }
}

#set smartquote(enabled: true)

$for(header-includes)$
$header-includes$

$endfor$

#show link: it => [
  #it.body
  #footnote(it.dest)
]

#show: doc => {
  set document(
$if(title-meta)$
    title: [$title-meta$],
$elseif(title)$
    title: [$title$],
$endif$
$if(subject)$
    description: [$subject$],
$endif$
$if(author-meta)$
    author: [$author-meta$],
$endif$
$if(keywords)$
    keywords: ($for(keywords)$[$keywords$]$sep$,$endfor$),
$endif$
  )

  $if(lang)$
  set text(lang: "$lang$")
  $endif$

  $if(mainfont)$
  set text(font: ("$mainfont$",))
  $else$
  set text(font: ("IBM Plex Sans",))
  $endif$

  $if(fontsize)$
  set text(size: $fontsize$)
  $endif$

  $if(monofont)$
  show raw: set text(font: ("$monofont$",))
  $else$
  show raw: set text(font: ("IBM Plex Mono",))
  $endif$

  set page(
    numbering: none,
    footer: context {
      let n = counter(page).get().first()
      if n > 1 {
        align(center, counter(page).display($if(page-numbering)$"$page-numbering$"$else$"1"$endif$))
      }
    },
  $if(papersize)$
    paper: "$papersize$",
  $endif$
  $if(margin)$
    margin: ($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$),
  $else$
    // More balanced defaults for article PDFs.
    margin: (top: 2.6cm, bottom: 2.2cm, left: 2.2cm, right: 2.2cm),
  $endif$
  )

  doc
}

$for(include-before)$
$include-before$

$endfor$

$if(title)$
#align(center)[
  #set text(size: 1.55em, weight: "bold")
  $title$
  $if(subtitle)$
  #v(0.25em)
  #parbreak()
  #set text(size: 1.08em, weight: "regular")
  $subtitle$
  $endif$
  $if(date)$
  #v(0.15em)
  #parbreak()
  #set text(size: 0.50em, weight: "regular")
  #format-commit-date("$date$")
  #v(0.45em)
  #parbreak()
  $endif$
]

$if(abstract)$
#parbreak()
#heading(level: 1)[Abstract]
$abstract$
$endif$
$endif$

$if(image)$
#figure(
  image("$resource-path$/$image$"),
)
$endif$

$if(toc)$
#outline(
$if(toc-title)$
  title: [$toc-title$],
$else$
  title: auto,
$endif$
  depth: $toc-depth$,
)
$endif$

$if(lof)$
#outline(
  title: [List of Figures],
  target: figure.where(kind: image),
)
$endif$

$if(lot)$
#outline(
  title: [List of Tables],
  target: figure.where(kind: table),
)
$endif$

$body$

$if(citations)$
$if(csl)$
#set bibliography(style: "$csl$")
$elseif(bibliographystyle)$
#set bibliography(style: "$bibliographystyle$")
$endif$
$if(bibliography)$

#bibliography($for(bibliography)$"$bibliography$"$sep$,$endfor$)
$endif$
$endif$

$for(include-after)$

$include-after$
$endfor$

You can download an example output of this new PDF generation toolchain in the form of the file conway-in-c89.pdf, extracted using the source of the article with the same name.