Building a Linkedin data visualisation template with Quarto and Typst

I like to share data visualisations that I make in R with ggplot on Linkedin, but Linkedin’s support for images in posts is not very good. It does have quite good support for embedding PDF files, so I created a template using Quarto and Typst to produce nicely formatted PDFs.

The end result

Here’s a preview of the rendered PDF from Quarto that shows the main features.

The key features are:

  • A topic heading
  • Text above the chart to summarise a key finding
  • Chart made with ggplot
  • Data source text
  • More text in two columns below the chart (optional)
  • A footer with logo and page number.
  • This example has a square aspect ratio, and the template also supports a portrait (A4) ratio for charts that need more vertical space.

What are Quarto and Typst?

Briefly, Quarto is a scientific publishing system that enables you to combine text with R code (and Python and Julia). Typst is a document publishing and layout framework in the spirit of LaTeX but much easier to use with a modern syntax.

Quarto recently added support for Typst as a front-end for producing PDF output. Quarto could produce PDF output already but customising the document formatting was not so simple as it uses LaTeX to generate PDFs by default. In contrast, Typst has a really nice code-based templating system, although it’s still in development and has some limitations at this stage.

When you use them together, Quarto handles combining text and R code output, and Typst makes the resulting document look nice and turns it into a PDF.

Creating a Quarto Typst template

Quarto actually calls templates custom format extensions. Creating a barebones extension is explained in Quarto’s guide. Basically you just type quarto create extension format:typst in a terminal. This creates a sample custom format extension (template!) for Typst with three files:

  • _extension.yml: A yaml configuration file for your custom extension.
  • typst-show.typ: A Typst code file that handles passing information about documents between Quarto and Typst.
  • typst-template.typ: Another Typst code file where you specify how Typst should format documents.

Note: The Quarto guide for creating extensions recommends using GitHub repositories to distribute them, but this currently doesn’t work with private repositories. If you don’t want your extension to be public, you’ll need to manually copy the _extensions folder with your extension to any Quarto project where you want to use it.

Typst has its own unique language and syntax for specifying document layout and formatting. It’s very powerful; in fact it’s a bona-fide programming language and you can even make games with it. I found it somewhat confusing at first, but it starts to make sense as you use it more. There is a good tutorial and reference documentation.

Using the template

Before explaining how to configure the template, I’ll show how a Quarto document (.qmd file) that uses it is structured. Here’s a simple example:

---
title: "Example of Quarto Typst template"
author: "Aaron Schiff"
date: 2024-09-02
format: asdv-typst
aspect: "square"
---

# Topic heading

## This is a summary of a key point

```{r}
#| message: false
#| fig-cap: "Temperature and ozone level."
library(ggplot2)
ggplot(airquality, aes(Temp, Ozone)) +
  geom_point() +
  geom_smooth(method = "loess", se = FALSE)
```

::: {.discussion}

### Heading under the chart

This is explanatory text under the chart which is in two columns. It is optional.

### Another heading under the chart

More explanatory text can go here, as much as can fit under the chart.
:::

The key parts of this are:

  • The document metadata specifies the template to use. My template is just called asdv but Quarto wants the format to be asdv-typst since its a Typst format. It took me a long time to figure that out!
  • The output aspect ratio is also specified in the metadata. The tempalate is set up to handle square and portrait (A4) aspect ratios.
  • Heading levels 1, 2 and 3 become the three types of headings in the PDF output.
  • The fig-cap property of the R code block that produces the chart will be used to show the data source under the chart.
  • A Quarto div (indicated with ::: {.discussion}) is used to flag the text that goes under the chart. This enables this part to be formatted in two columns (see below for how this works).

Configuring the extension

The first thing to do is edit _extension.yml to tell Quarto what your extension does and specify some options. Here’s the one I created, see below for an explanation.

title: asdv
author: Aaron Schiff
version: 1.0.0
quarto-required: ">=1.5.0"
contributes:
  formats:
    typst:
      format-resources:
        - logo-grey.svg
      toc: false
      execute:
        echo: false
        warning: false
      fig-width: 6
      fig-height: 4.5
      template-partials:
        - typst-template.typ
        - typst-show.typ
      filters:
        - asdv.lua

This tells Quarto that my extension contributes a Typst format. Tables of contents are disabled for documents using this format. Code echo and warnings are turned off. The default figure sizes (in inches) are specified. A logo svg file is specified that can be included in Typst pdf output. The two template files are specified. A custom “filter” is specified that will perform some intermediate processing on Quarto’s output before it is handed to Typst (more on that below).

Creating the filter

In _extension.yml we specified a filter asdv.lua. This is a lua script that modifies the intermediate document in between Quarto’s output and the input that Typst receives. This is necessary to apply the two-column formatting to the contents of the .discussion div in the Quarto document. You could also use this technique to apply more fancy formatting – see the Dept News template for Quarto for an example that plucks out parts of the document and puts them in a sidebar, using a grid layout.

If we don’t create a filter, the .discussion div in our Quarto document gets turned into Typst code like this:

#block[
=== Heading under the chart
<heading-under-the-chart>
This is explanatory text under the chart which is in two columns. It is optional.

=== Another heading under the chart
<another-heading-under-the-chart>
More explanatory text can go here, as much as can fit under the chart.
]

(Tip: Set keep-typ: true in _extension.yml to keep a copy of the intermediate Typst code that Quarto produced, which is useful for debugging.)

All that happened is that the .discussion div got turned into a Typst block (and the headings got Typst labels assigned to them.) To format this part of the page in two columns, we need to turn that #block[...] into #columns(2)[...]. This is where the filter comes in.

I don’t know Lua, so I just modified an example filter that I found. Here’s the filter code:

function Div(el)
  if el.classes:includes('discussion') then
    local blocks = pandoc.List({
      pandoc.RawBlock('typst', '#columns(2)[')
    })
    blocks:extend(el.content)
    blocks:insert(pandoc.RawBlock('typst', ']\n'))
    return blocks
  end
end

What this does is look for divs with the class discussion and wraps the content of them in #columns(2)[], as we wanted. Here’s what the relevant part of the Typst code looks like with the filter in place:

#columns(2)[
=== Heading under the chart
<heading-under-the-chart>
This is explanatory text under the chart which is in two columns. It is optional.

=== Another heading under the chart
<another-heading-under-the-chart>
More explanatory text can go here, as much as can fit under the chart.
]

Editing typst-show.typ

The typst-show.typ file is Typst code that takes properties of a Quarto document and passes them to Typst by calling a Typst show function called article() with relevant parameters (the function doesn’t have to be article(), you can name it whatever you want but there’s no real reason to change it). I modified the default typst-show.typ file that came with the skeleton extension to delete some of the unnecessary document parameters, like the author details and abstract.

Setting up the template

Now for the fun part! The rules for how Typst should format and lay out the document generated by Quarto are in typst-template.typ. This specifies the article() function that gets called in typst-show.typ. This function will receive relevant metadata as well as the contents of the document, and tells Typst the format and layout of that content. To understand this part, you need to know how Typst templates work; I recommend their tutorial as a starting point.

Here’s the definition of the article function, with default values for some parameters, and doc is the contents of the document:

#let article(
  title: none,
  author: none,
  date: none,
  aspect: "portrait",
  flipped: false,
  lang: "en",
  region: "US",
  font: "National 2",
  fontsize: 12pt,
  doc
) = {

First define the page size based on A4 paper, allowing aspect to be either portrait or square:

// Define output page size and margins -- either square or A4 portrait
let width = 21.01cm
let height = 29.71cm
let margin = (x: 20mm, y: 20mm)

if aspect == "square" {
  height = 21.01cm
  margin = (x: 10mm, y: 10mm)
}

Note for R coders: Typst is strict about variable scope, so height and margin are set to the portrait values first and then changed if aspect is square. If height and margin were first defined inside an if statement, they would not be available outside that scope.

Next, page setup. The width, height, and margin are applied to page(). The footer is defined using a grid function so the logo can float at the left and the page number floats at the right. This is wrapped in a context function so the page counter knows which page number to show.

set page(
  width: width,
  height: height,
  flipped: flipped,
  margin: margin,
  footer: context[
    #grid(
      columns: (50%, 50%),
      rows: (auto),
      align(left + horizon)[#image("logo-grey.svg", height: 1em, width: auto)],
      align(right + horizon)[
        #set text(size: 0.8em, weight: "regular", fill: rgb(80, 80, 80))
        #counter(page).display("1 / 1", both: true)
      ]
    )
  ]
)

Next, set some general properties of paragraphs, text, and headings. These should be self-explanatory.

// Paragraphs
set par(
  justify: false,
  linebreaks: "optimized"
)

// Text
set text(
  lang: lang,
  region: region,
  font: font,
  size: fontsize
)

// All headings
set heading(numbering: none)
show heading: set text(fill: rgb(60, 60, 60))

For heading 1, a custom show rule is defined to set particular aspects of the formatting, and to ensure that each heading starts on a new page.

// Heading 1
show heading.where(level: 1): it => {
  set par(
    leading: 0.5em
  )
  set text(
    weight: "light",
    fill: rgb(30, 30, 30),
    size: 1.25em
  )
  pagebreak(weak: true)
  block(
    width: 100%,
    below: 0.75em,
    it.body
  )
}

I also made a show rule for heading 2, to customise the formatting:

// Heading 2
show heading.where(level: 2): it => {
  set text(
    weight: "bold",
    fill: rgb(204, 75, 194),
    size: 1em
  )
  block(
    above: 0pt,
    below: 1em,
    it.body
  )
}

Images are set to be full width, and to be fully contained within the page. This means the original aspect ratio of the image will be preserved and it will not be cropped. If you don’t set this, the default may lead to images being cropped to fully cover the area they are in. The gap parameter sets the spacing between a figure’s image and its caption.

// Figures
set image(width: 100%, fit: "contain")
set figure(gap: 0.75em)

I don’t want figure captions per se, so the caption is hijacked to show the data source under the chart.

// Figure captions
show figure.caption: it => {
  set align(left)
  set text(size: 1em, fill: rgb(80, 80, 80))

  emph[Data source: #it.body]
}

Finally, we mustn’t forget to include our document contents at the end of the article() function:

doc
}

Rendering a PDF

Once you have created the template and copied it to the _extensions directory in the same directory as your Quarto document, it’s simply a matter of telling Quarto to render the output: quarto render mydoc.qmd. One of the nice things about Typst is its very helpful error messages, but I still needed a lot of trial and error to get things just right.