With Typst 0.13, we wanted to improve the day-to-day experience of using Typst. We fixed some of the most long-standing bugs and made Typst even more flexible to use. And on top, we're shipping a first, experimental version of HTML export.

It's been almost two years since Typst's open-source launch and the project has matured quite a bit since then. Typst 0.12's development cycle saw many large-scale changes to Typst's foundations. With Typst 0.13, we moved the focus to the day-to-day experience of using Typst. We made quality-of-life improvements all across and fixed some of the biggest paper cuts. But, of course, we also shipped some exciting new features!

Contents

In this blog post, I'll walk you through the highlights of the release. If you prefer a more visual take on the topic, also check out the release video.

For a comprehensive overview of all changes in the release, visit the changelog. If you're looking to upgrade your document to Typst 0.13, you can also skip ahead to the Migration section.

Paragraphs and first-line indent

The work on semantic paragraphs is what I'm most proud of in this release, but at the same time it's among the things that are least visible for users. What do I even mean with "semantic paragraphs?"

Let me explain. Up until now, Typst considered every piece of text you wrote as a paragraph — be it a single word in a page header, a figure caption, or a page number. Just like paragraphs, these things can have spacing, break across lines, etc. Layout-wise they are not all that different from paragraphs.

However, there are semantical differences. Only proper paragraphs should be counted when paragraphs are numbered (such as in legal texts). Only proper paragraphs should be announced by a screen reader as such. And even layout-wise there are differences; for instance, that only proper paragraphs should have first-line indent. While the layout routines for "just text" and a paragraph may be very similar, the second order effects of something being a proper paragraph are far-reaching.

In version 0.13, Typst finally gains a better understanding of this distinction. Whether something is a paragraph or just text is decided based on a few simple rules about which you can read in the updated paragraph documentation.

The most visible immediate effect of this work is that first-line-indent can now be applied to all paragraphs instead of just consecutive ones, closing the most upvoted Typst bug. Semantic paragraphs are also crucial for the in-development HTML export and for planned future work on PDF accessibility.

#set block(spacing: 1.2em)
#set par(
  spacing: 0.65em,
  first-line-indent: (
    amount: 1em,
    all: true,
  ),
)

= Chapter 1
In this text, the paragraphs are
all indented, without exception.

With `all: true`, the first
paragraph is affected as well.
Preview

Better-looking outlines

If you've created a table of contents with Typst's outline functionality before, you might remember that it always looked a bit bland. The default style had no indentation and rather tightly dotted leaders (leaders are the filler dots between a title and its page number).

In Typst 0.13, the outline gets a full facelift while also becoming easier to customize. The new automatic indentation nicely aligns all titles and numberings across the whole outline, long titles have better-looking wrapping behavior, and we fixed a number of bugs.

If you have a customized outline, check out the migration section for the outline to learn how to adapt your customization.

#set heading(numbering: "1.i.")

#outline()

= Introduction
= Methods
== Experiments
== Statistics
= Results
== T Experiment
== K Experiment
== V Experiment
== Additional experiments with extra length
= Discussion
= Conclusion
Preview

New Curves

Since Typst 0.2, you could draw Bézier paths with the path function. However, the input format of this function was rather arcane. Rather than specifying pen movements as in an SVG, you had to specify directly points with their two control points. Moreover, the path function had a fatal flaw: You could not close a path and then keep on drawing. This is necessary to draw a shape with cutouts, as depicated below.

The new curve function fixes these flaws. It provides an easier-to-understand and more expressive interface. We also used this opportunity to change the name from path to curve as we plan to repurpose the name path for a file path type in an upcoming release.

#curve(
  fill: blue.lighten(80%),
  fill-rule: "even-odd",
  stroke: blue,
  curve.line((50pt, 0pt)),
  curve.line((50pt, 50pt)),
  curve.line((0pt, 50pt)),
  curve.close(),
  curve.move((10pt, 10pt)),
  curve.line((40pt, 10pt)),
  curve.line((40pt, 40pt)),
  curve.line((10pt, 40pt)),
  curve.close(),
)
Preview

Thanks to @Emm54321 for working on this!

Files and bytes

Various functions in Typst load files, be it images, data loading functions, or plugins. Sometimes though, a little extra flexibility is needed, for example, to preprocess, generate, or inline data into a Typst document.

For this reason, there are also .decode variants on various of the functions, e.g. image.decode or json.decode. However, that approach didn't work so well when a path is expected in a set rule, as in set raw(theme: "light.tmTheme"). It also introduced duplication: All the properties of an image are also spelled out again in image.decode.

Typst 0.13 revamps file handling to improve this unsatisfactory situation. All places where a path is expected now also support raw bytes instead. Typst will always interpret a string as a path and raw bytes as data. When trying to decode an image from a string, thus make sure to first convert it to bytes. Converting to bytes is cheap as Typst will internally reuse the memory from the string. It will even remember that the bytes came from a string to make conversions back to a string cheap as well!

The existing .decode functions are now deprecated as they are not needed anymore. The .encode variants of data loading functions remain unchanged.

See @netwok for details.

#bibliography(bytes(
  ```bib
  @article{netwok,
    title={At-scale impact of the {Net Wok}},
    author={Astley, Rick and Morris, Linda},
    journal={Armenian Journal of Proceedings},
    volume={61},
    pages={192--219},
    year={2020},
    publisher={Automattic Inc.}
  }
  ```.text
))
Preview

Generating images

With the new byte-taking image function (and previously image.decode), you can generate images at runtime. However, the image function expects images in an encoded image exchange format like PNG or JPEG. Producing valid bytes for such a format in pure Typst code is prohibitively complicated. Meanwhile, plugins are unnecessarily bloated and slowed down if they have to include an image encoder.

To streamline image generation workflows, Typst 0.13 thus brings support for loading images from uncompressed raw pixel data. To that end, the format parameter of the image function supports a new form, where the channel encoding and pixel width/height of the image can be specified. This feature is crucial for better scientific visualizations — think things like heatmaps.

#image(
  bytes(range(64).map(x => x * 4)),
  format: (
    encoding: "luma8",
    width: 8,
    height: 8,
  ),
  width: 4cm,
  scaling: "pixelated",
)
Preview

Thanks to @frozolotl for working on this!

Faster plugins

In version 0.8, Typst gained support for WebAssembly plugins — one of the features that would very likely still be a little blue "feature request" label if not for our fabulous open source community. Since then, plugins have become the backbone of various community packages. They're great because they bring the power and package ecosystem of all the languages that compile to WebAssembly right into Typst.

They are also faster to execute than Typst code. Still, with heavy usage the time spent executing plugin code can make up a significant chunk of compile time. A simple way to improve this would've been to switch to a faster WebAssembly runtime (specifically, from wasmi to wasmtime). However, taking on a dependency on a WebAssembly runtime with just-in-time compilation1 wasn't a spot we wanted to put Typst into. It would have reduced portability and security and increased the amount of third-party code Typst depends on by a lot.

There was another way to speed up plugins: Since 0.12, Typst's layout engine is multi-threaded. Plugins didn't profit from this though as they couldn't be easily replicated across threads. This is a limitation we're lifting with 0.13. Typst will now automatically run plugins in multiple threads without any changes from plugin authors. This is possible because we require (and also already required in the past) plugin functions to be pure. This means that we can execute plugin functions out of order without a visible effect. For cases where purity is too limiting, Typst 0.13 introduces the new plugin.transition API, which lets plugin authors deal with stateful operations in a sound way.

The work on speeding up plugins was prioritized through a Typst open-source support contract. If you're using Typst in production at your company and are hitting any road blocks, please reach out to us!

Single-letter strings in math

Since Typst's initial release, $ "hi" $ would generate the letters "h" and "i" in upright style while $ "h" $ would result in an italic "h". It's one of the longest-standing bugs, which is curious because it seems so easy to fix. Unfortunately, it was not. To see why, we need to take a look behind the scenes and understand how Typst views your equations.

In Typst 0.12 and lower, the x in $ x $ is a text element like any other text in your document. A string like "hi" is converted to content by becoming such a text element, too. While different syntactically, $ x $ and $ "x" $ thus used to yield identical content. Since x becomes italic by default, "x" did, too.

Changing that by itself wouldn't have been too hard, but there is a third guest to the party: Symbols. The pi in $ pi $ is a symbol value. Like strings, symbols were up until now converted to content by becoming text elements. This means they work both in math and normal text (as #sym.pi).

For a long time, the issue was thus blocked on finding a general solution to the text element ambiguity — perhaps introducing a new var element for math-y text. That story isn't fully written yet, but for 0.13 we really wanted to fix the issue at hand. For this reason, we attempted to find a minimal solution that fixes the issue while leaving our options for further improvements open.

The solution we came up with: Bare letters and symbols are now converted into an internal symbol element, which has auto-italics in math, but is transparently converted to normal text outside of math. Meanwhile, strings still generate text elements. With the planned unification of types and elements, this symbol element and the existing symbol type will naturally merge into one.

$ a != "a" $
Preview

Thanks to @wrzian for working on this!

Font coverage control

When text in different writing scripts is mixed, it's often important to have precise control over which text is typeset with which font. For example, Latin and Chinese text are almost always typeset with different fonts.

This is quite problematic for CJK (Chinese, Japanese, Korean) Typst users which often have text that mix their native language and English. Typst 0.13 takes a first step to improve this situation. With the new covers functionality, users can specify precisely for which character ranges a font should be used. This can, for example, be used to define in which font punctuation (which is present in both Latin and CJK fonts) is rendered.

The covers feature supports specifying a character set, either as a regular expression or one of the built-in ones. Currently, the only built-in set is "latin-in-cjk", which should be specified for a Latin font that is before a CJK font in the fallback list. In the example below, we can put Inria Serif first in the fallback chain while still having quotes render with Noto Serif CJK SC.

// Mix Latin and CJK fonts.
#set text(font: (
  (
    name: "Inria Serif",
    covers: "latin-in-cjk",
  ),
  "Noto Serif CJK SC"
))

分别设置“中文”和English字体
Preview

Thanks to @peng1999 for working on this!

PDF file embedding

With Typst 0.13's new pdf.embed function, you can attach arbitrary text or binary files to your PDF. These embedded files can then be browsed in PDF viewers or extracted programmatically by third-party tools.

When is this useful? One example, electronic invoicing, is ever more important as new EU legislation just came into force. While electronic invoices are typically in XML-based formats, it's often useful to still have a human-readable and printable invoice.

With PDF file embedding, the XML invoice data can be inserted into the PDF itself, forming a hybrid invoice. Currently, there is still one missing piece in Typst's support for this: The PDF metadata must identify the document as an E-Invoice to other applications. We plan to add support for embedding arbitrary metadata like this in a future Typst release.

Thanks to @NiklasEi for working on this!

A first look at HTML export

Saved for last is a particularly exciting topic: We've been starting work on HTML export! The feature is still very incomplete and only available for experimentation behind a feature flag, but there's already some stuff to see.

What works

Most of the markup and some of the other built-in functions like figure and table already produce the appropriate HTML. Our focus is on producing semantically rich HTML that retains the structure of the input document. The HTML output should be accessible, human-readable, and editable by hand.

The Typst document below

= Introduction
A *document* with some _markup._

- A list
- with elements

#figure(
  [A bit of text],
  caption: [My caption],
)

will produce the following HTML output:

<!-- html, head, and body omitted for brevity -->
<h2>Introduction</h2>
<p>A <strong>document</strong> with some <em>markup.</em></p>
<ul>
  <li>A list</li>
  <li>with elements</li>
</ul>
<figure>
  <p>A bit of text</p>
  <figcaption>Figure 1: My caption</figcaption>
</figure>

Typst cannot always produce the perfect HTML automatically. Instead, it gives you full control by letting you generate raw HTML elements:

#html.elem(
  "div",
  attrs: (style: "background: aqua")
)[
  A div with _Typst content_ inside!
]
<div style="background: aqua">
  A div with <em>Typst content</em> inside!
</div>

To make your document portable across PDF and HTML, we're also introducing a target function that returns the current export format (either "paged" or "html"). It is mainly intended for use in show rules, like below:

#let kbd(it) = context {
  if target() == "html" {
    html.elem("kbd", it)
  } else {
    set text(fill: rgb("#1f2328"))
    let r = 3pt
    box(
      fill: rgb("#f6f8fa"),
      stroke: rgb("#d1d9e0b3"),
      outset: (y: r),
      inset: (x: r),
      radius: r,
      raw(it)
    )
  }
}

Press #kbd("F1") for help.
Preview
<p>Press <kbd>F1</kbd> for help.</p>

The target function is contextual because the export target can vary within one compilation. How? With the html.frame function, you can lay out part of your HTML document as an inline SVG, using Typst's normal layout engine. Within such a frame, the compilation target is "paged" again, so that show rules produce the appropriate layout elements instead of HTML elements.

What's missing

A lot! For instance, currently, Typst will always output a single HTML file. Support for outputting directories with multiple HTML documents and assets, as well as support for outputting fragments that can be integrated into other HTML documents is planned.

Typst currently also doesn't output any CSS, instead focusing fully on emitting semantic markup. You can of course write your own CSS styles and still benefit from sharing your content between PDF and HTML.

In the future, we plan to give you the option of automatically emitting CSS, taking more of your existing set rules into account. More generally, we have a lot of plans for HTML export! Visit the tracking issue to learn more about them.

Try it!

In the CLI, you can experiment with HTML export by passing --features html or setting the TYPST_FEATURES environment variable to html. In the web app, HTML export is not yet available. It will become available once we think it's ready for general use.

You can also use HTML export with typst watch. Typst will then automatically spin up a live-reloading HTTP server that serves your document.

$ typst watch hi.typ --features html --format html --open

watching hi.typ
writing to hi.html
serving at http://127.0.0.1:3001

[20:31:09] compiled with warnings in 1.52 ms

Sponsorship

Work on Typst's HTML export is sponsored by NLNet. We are very grateful for their support! We also want to thank external contributor @01mf02 with whom we've thus far collaborated on HTML export through the NLNet grant. Unfortunately, we and him have since parted ways over technical differences. Nonetheless, we plan to increase the time and resources we put into HTML export, and we are very happy to have NLNet's continued support in this endeavor.

Migrating to Typst 0.13

Typst 0.13 ships with a number of deprecations and breaking changes. The changelog has a full account of all changes, but in this section you'll learn how to deal with the most common kinds of breakage.

Type/string compatibility

In Typst 0.8, types were promoted to proper values. As a result, type(10) directly returns a type instead of a string since then. To make this change less disruptive, we also introduced a temporary compatibility behavior where types could be used like strings in some contexts (e.g., int == "integer" would be true). For implementation reasons, we did not add a warning for this at the time. We're rectifying this now and adding a warning to shake out remaining reliance on this behavior. With Typst 0.14, the compatibility behavior will be fully removed.

// Typst 0.8+ ✅
#{ type(10) == int }

// Typst 0.7 and below ⚠️
#{ type(10) == "integer" }

Decode functions

The image.decode function and the .decode variants of data loading functions are now deprecated. You can instead directly pass bytes to the respective top-level functions instead. Read the section on files and bytes to learn more.

// Typst 0.13+ ✅
#image(bytes("<svg>..</svg>"))

// Typst 0.12 and below ⚠️
#image.decode("<svg>..</svg>")

Outline

The changes to the built-in outline (table of contents) improve the out-of-the-box style and customizability. Unfortunately, they also break some existing outline customizations.

First of all, the fill argument moved from outline to outline.entry. If you get the error "unexpected argument: fill", adjust your code as shown below:

// Typst 0.13+ ✅
#set outline.entry(fill: none)

// Typst 0.12 and below ❌
#set outline(fill: none)

Because the fill property is now on the entry, it can also be configured for individual outline levels, like this:

#show outline.entry.where(level: 1): set outline.entry(fill: none)

In light of the changes to paragraphs, outline entries now show themselves as blocks instead of lines of text. This means spacing is now configured via normal show-set rules for block.spacing.

// Typst 0.13+ ✅
#show outline.entry.where(level: 1): set block(below: 1em)

// Typst 0.12 and below ⚠️
#show outline.entry: it => {
  it
  v(1em, weak: true)
}

In Typst 0.12 and below, outline entries expose a few fields like .body and .page that are useful for writing an outline entry show rule. These fields were derived from other fields for your convenience. Typst 0.13 makes this more explicit and idiomatic by turning them into methods. Read the documentation on outline customization for more details on how to use these methods.

// Typst 0.13+ ✅
#show outline.entry: it => {
  ...
  it.page()
}

// Typst 0.12 and below ❌
#show outline.entry: it => {
  ...
  it.page
}

Paths

The path function is superseded by the new curve function. The curve function has an easier-to-understand interface that's closer to how SVG and the HTML canvas work. Read the curve function's documentation to learn how to express existing paths with the new function.

// Typst 0.13+ ✅
#curve(
  fill: blue.lighten(80%),
  stroke: blue,
  curve.move((0pt, 50pt)),
  curve.line((100pt, 50pt)),
  curve.cubic(none, (90pt, 0pt), (50pt, 0pt)),
  curve.close(),
)

// Typst 0.12 and below ⚠️
#path(
  fill: blue.lighten(80%),
  stroke: blue,
  closed: true,
  (0pt, 50pt),
  (100pt, 50pt),
  ((50pt, 0pt), (40pt, 0pt)),
)
Preview

Patterns

The pattern type was renamed to tiling. To migrate, simply replace pattern with tiling. No further changes are necessary. The name pattern remains as a deprecated alias in Typst 0.13, but will be removed in an upcoming release.

Why the rename? For once, the name pattern was very generic. The name tiling is more closely associated with what it expresses. Secondly, we're considering to repurpose the name pattern for what today are selectors once elements and types are unified.

// Typst 0.13+ ✅
#rect(fill: tiling(..))

// Typst 0.12 and below ⚠️
#rect(fill: pattern(..))

Removals

We also removed a number of things that were already deprecated and warned for in Typst 0.12. This includes the

Refer to the Migration section of the Typst 0.12 announcement post to learn more about how to migrate away from these functions.

Community Call

That's it for Typst 0.13. We hope you're just as excited about the release as we are!

We're hosting a community call on Discord on Friday, March 7th. Join us to share your experiences with the new version and to chat with the community!

1

Just-in-time compilation means that the WebAssembly is compiled to native instructions for your CPU at runtime. While WebAssembly engine authors put a lot of work into making this safe, it's fundamentally more complex, less portable, and more susceptible to exploits than interpreting the WebAssembly code.