Anatomy of a Typst template

Developed by two graduate students from Technical University of Berlin, Typst is a computer program for typesetting documents, thoughtfully designed to be fast, portable, feature-packed, and script-able. It would not be unfair to call it a spiritual successor to LaTeX. With contributions from talented open-source enthusiasts, it is evolving well and blowing-up LaTeX’s handicaps, viz.,

With good defaults (font and paragraph properties, paper size, margins, etc.), one can get going without needing a single line of preamble code. But if you are a seasoned LaTeX user, then you’d know how useful document classes are — something Typst does not yet come pre-packaged with. And so, a little universe is starting to form — for users, by users. If you’re one of them, and your tastes are slightly different from those available there-in, then it would not hurt to know how to create a Typst template from scratch. Here is how.

Rule one of publishing is to separate presentation + styling from content, be it offline or online. So we create two files:

main.typ: This requires only a few key user-inputs (which would be variables pre-defined in template.typ), e.g. document title, name of the author, and paper size (useful for me) other than the content itself. (Note that every element of the look and feel is my own; there’s no default; it’s like I’m making my own document class.)

#import "template.typ": *
#show: note.with(
  title: [On-bottom stability],
  author: "C Kunte",
  paper: "a4", // defaults don't need to be defined; I added paper option because I often use "a5"
) // report content from here-on

template.typ: Basic preamble is as follows. Defaults will be used when things like margins are undefined in either of the two files.

#let note(
  title: none,
  author: none,
  paper: "a4",
  body,
) = {
  set document(
    title: title, 
    author: author
  ) // set metadata (seen in document properties)

  set page(
    paper: paper, // enables user-definable in main.typ
    numbering: "1", // another example: "1 of 1"
  ) // page properties

  align(center)[
    #text(1.8em)[*#title*]
    #v(2em, weak: true)
    #text(1em, author)
    #v(1em, weak: true)
    #datetime.today().display("[month repr:long] [day], [year]")
    #v(5em, weak: true)
  ] // title block

  body
}

If I were to automate setting margins based on paper size, then it would be like so, in which the top margin overrides the y parameter to make room for a custom header.

set page(
  margin: if paper == "a5" {
    (x: 0.75in, y: 0.75in, top: 0.9in)
  } else {
    (x: 1.0in, y: 1.0in, top: 1.25in)
  },
)

Strictly speaking the above is totally unnecessary. Here’s why:

By default, Typst will create margins proportional to the page size of your document. (The margins are set automatically to 2.5/21 times the smaller dimension of the page.)

We can do something similar for font size:

set text(
  size: if paper == "a5" { 11pt } else { 12pt },
)

Typographical controls are covered too, e.g.,

set text(
  font: "Segoe UI", // e.g. "STIX Two Text" or "erewhon",
  top-edge: "cap-height", 
  bottom-edge: "baseline",
  number-type: "old-style",
  size: if paper == "a5" { 11pt } else { 12pt },
)

Prefer indented classic look for paragraphs? You can do this:

set par(
  spacing: 0.65em, 
  leading: 0.65em, 
  first-line-indent: 12pt, 
  justify: true
)

Here is a little complex header code to make the title of the note (paper) appear on every even page, and the active section name appear on every odd page other than the first.

set page(
  header: context {
    // set custom header: make title appear on even pages
    if calc.even(counter(page).get().first()) { 
      emph(title) 
    } else { none }

    // make section appear on odd pages other than the first
    let page-num = counter(page).get().first()
    if page-num > 1 and calc.odd(page-num) {
      let headings = query(heading)
      let curr-heading = none
      let found = false

      for heading-elem in headings {
        if heading-elem.location() != none and heading-elem.location().page() == page-num {
          curr-heading = heading-elem.body
          found = true
        } else if heading-elem.location() != none and heading-elem.location().page() < page-num {
          curr-heading = heading-elem.body // keep track of the last heading on a prev page
        } else if found { break } // stop once we have moved past the curr page
      }
      align(right, emph(curr-heading))
    } else { none }
  }, // context ends
) // page ends

Put the template file together and then either run the following at command line, or use build instructions to setup text editor to compile typ to pdf.

$ typst compile main.typ
View of a report compiled using Typst typesetting computer program View of a report headers compiled using Typst typesetting computer program
View of the report (top-left); template (top-right); report headers (bott).

An example template is at my repository, note. For a Neovim user there is also a snippet plug-in.

Outline

Producing an outline (table of contents in Typst parlance) is simple like so (and can be seen in the view above):

#outline(
  indent: 1em, 
  depth: 4,
) // toc

#outline(
  title: [List of figures],
  target: figure.where(kind: image), 
) // figures

#outline(
  title: [List of tables],
  target: figure.where(kind: table),
) // tables

However, even when there are no figures or tables, the headings ‘List of figures’ and ‘List of tables’ are produced. To tweak this to produce only when they exist, this code snippets seems to do the job — this will be in the main.typ:

#context {
  // Count the number of figures of kind "image"
  let fig-count = query(figure.where(kind: image)).len()
  let tbl-count = query(figure.where(kind: table)).len()

  // Only render the outline if there are figures
  if fig-count > 0 {
    outline(
      title: [List of figures],
      target: figure.where(kind: image),
    )
  }
  // Only render the outline if there are tables
  if tbl-count > 0 {
    outline(
      title: [List of tables],
      target: figure.where(kind: table),
    )
  }
}

The entire table of contents block can be placed in a separate file, if you like, say, toc.typ and called on demand: with the following:

#include("toc.typ")

with the contents of the toc.typ file, combining the above:

#outline(
  indent: 1em, 
  depth: 4,
) // toc

#context {
  // Count the number of figures of kind "image"
  let fig-count = query(figure.where(kind: image)).len()
  let tbl-count = query(figure.where(kind: table)).len()

  // Only render the outline if there are figures
  if fig-count > 0 {
    outline(
      title: [List of Figures],
      target: figure.where(kind: image),
    )
  }
  // Only render the outline if there are tables
  if tbl-count > 0 {
    outline(
      title: [List of Figures],
      target: figure.where(kind: table),
    )
  }
}

Subtitle and logo

Same trick to produce these above.

main.typ to have this below. Notice the keys “logo” and “subtitle” in the block below.

#show: note.with(
  logo: "yes",
  title: [Asset],
  subtitle: [Execution Plan],
  author: "Execution Team",
  paper: "a4",
)

Now in template.typ, we add legs to these keys, like so:

#let note(
  // logo
  logo: none,

  // title
  title: none,

  // subtitle
  subtitle: none,

  // author of the note (or paper)
  author: none,

  // paper size:
  paper: none,

  // content of the note (or paper)
  body,
) = {
  // ...
  // ... [skipping other bits of template code for brevity]
  // ...
  // print title block (includes logo, title, subtitle, author, and date)
  align(center)[
    #if logo == "yes" {
      image("logo.svg", height: 0.75in) // above the title
      v(3em, weak: true)
    }
    #text(1.8em)[*#title*]
    #if subtitle != none {
      v(1em, weak: true)
      text(1em)[_ #subtitle _]
    }
    #v(2em, weak: true)
    #text(1em, author)
    #v(1em, weak: true)
    #datetime.today().display("[month repr:long] [day], [year]")
    #v(5em, weak: true)
  ]

  //
  body
}

Importing data into table

Creating a table by importing CSV data seems like a neat thing to do that avoids the verbose formatting needed in fitting the tabular data. Here’s an example:

#let qty = csv("quantity.csv")

#table(
  columns: 2,
  ..for (.., description, quantity) in qty {
    (description, quantity)
  }
)

In the above, .., can be used to skip data from preceding columns.