typst-rb
Ruby language binding to typst, a new markup-based typesetting system that is powerful and easy to learn.
Rubygems
Source and native gems are provided for the following platforms: aarch64-linux aarch64-linux-musl arm64-darwin x64-mingw-ucrt x86_64-darwin x86_64-linux x86_64-linux-musl. The gems are built from CI gem-push action and can be verified against SHA256SUMS published with the release.
Installation
Add the following to your gemfile and bundle install
gem 'typst', '>= 0.15.1.8'
or install from the command line:
gem install typst
Usage
require "typst"
Hello World
This example compiles a typst string to PDF and writes to a file.
Typst(body: "= Hello World\nThis is your first typst PDF").compile(:pdf).write("hello_world.pdf")
The basics
This example initializes a Typst::Base object t using a string. The t object is basically an environment for your typst input and can take a variety of options suitable for your task which you can see in subsequent examples.
t = Typst(body: "= Hello World\nThis is your first typst PDF")
This step compiles the typst input held in the t object and returns a Typst::Document object doc. In this case we're compiling to PDF so doc is a Typst::PdfDocument object which holds the PDF bytes and is ready to write to a file, output to a buffer, etc.
doc = t.compile(:pdf)
This step writes the output to a local file in your current working directory.
doc.write("hello_world.pdf")
Different ways to setup the typst input
Use a local typst file named example.typ
t = Typst("example.typ")
Use a typst string
t = Typst(body: %{hello world})
Use a zipped typst file
t = Typst(zip: "test/main.typ.zip")
Use a remote typst file
require "open-uri"
URI.open("https://github.com/actsasflinn/typst-rb/raw/refs/heads/main/README.typ") do |u|
Typst(body: u.read).compile(:pdf).write("remote_readme.pdf")
end
Use a remote zipped typst file
require "open-uri"
URI.open("https://github.com/actsasflinn/typst-rb/raw/refs/heads/main/test/hello.typ.zip") do |u|
Tempfile.create do |f|
f.write(u.read)
f.rewind
Typst(zip: f).compile(:pdf).write("remote_zipped.pdf")
end
end
Compiling
Compile to PDF
doc = t.compile(:pdf)
Compile to PDF selecting the typst supported PdfStandard
doc = t.compile(:pdf, pdf_standards: ["2.0"])
Compile to SVG
doc = t.compile(:svg)
Compile to PNG
doc = t.compile(:png)
Compile to PNG and set PPI
doc = t.compile(:png, ppi: 72)
Compile to HTML (using Typst expirmental HTML)
doc = t.compile(:html_experimental)
Output
Return compiled content as an array of bytes
pdf_bytes = Typst("readme.typ").compile(:pdf).bytes
# => [37, 80, 68, 70, 45, 49, 46, 55, 10, 37, 128 ...]
Write compiled output to a file
Note: for multi-page documents using formats other than PDF and HTML, pages write to multiple files, e.g. filename_0.png, filename_1.png
doc.write("filename.pdf")
Return PDF, SVG, PNG or HTML content as an array of pages
Typst("readme.typ").compile(:pdf).pages
# => ["%PDF-1.7\n%\x80\x80\x80\x80\n\n1 0 obj\n<<\n /Type /Pages\n /Count 3\n /Kids [160 0 R 162 ...
Typst("readme.typ").compile(:svg).pages
# => ["<svg class=\"typst-doc\" viewBox=\"0 0 595.2764999999999 841.89105\" ...
Typst("readme.typ").compile(:png).pages
# => ["\x89PNG\r\n\x1A\n\x00\x00\x00\rIHDR\x00\x00\x04\xA7\x00\x00\x06\x94\b\ ...
Typst("readme.typ").compile(:html_experimental).pages
# => ["<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" ...
More advanced setups
Pass values into typst using sys_inputs
sys_inputs_example = %{
#let persons = json(bytes(sys.inputs.persons))
#for person in persons [
#person.name is #person.age years old.\\
]
}
people = [{"name" => "John", "age" => 35}, {"name" => "Xoliswa", "age" => 45}]
data = { "persons" => people.to_json }
Typst(body: sys_inputs_example, sys_inputs: data).compile(:pdf).write("sys_inputs_example.pdf")
Apply inputs to typst to product multiple PDFs
t = Typst(body: sys_inputs_example)
people.each do |person|
t.with_inputs({ "persons" => [person].to_json }).compile(:pdf).write("#{person['name']}.pdf")
end
A more complex example of compiling from string using other dependency typst template, svg and font resources all in memory
main = %{
#import "template.typ": *
#show: template.with()
#lorem(50)
#image("icon.svg")
}
template = %{
#let template(body) = {
set text(12pt, font: "Example")
body
}
}
icon = File.read("icon.svg")
font_bytes = File.read("Example.ttf")
Typst(body: main, dependencies: { "template.typ" => template, "icon.svg" => icon }, fonts: { "Example.ttf" => font_bytes }).compile(:pdf)
Use a zip file with an alternatively named main typst file
Typst(zip: "test/main.typ.zip", main_file: "hello.typ").compile(:pdf)
Use a package from the Typst Universe
Your package_example.typ file...
#import "@preview/wordometer:0.1.5": word-count, total-words
#show: word-count
In this document, there are #total-words words all up.
#word-count(total => [
The number of words in this block is #total.words
and there are #total.characters letters.
])
...compiles just like anything else
Typst("package_example.typ").compile(:pdf).write("package_example.pdf")
Compiler warnings
A compile can succeed and warn. The most common case is an unknown font family: typst substitutes a different face, the document is produced, and nothing tells you it is in the wrong typeface. Every compiled document carries the warnings that came with it.
doc = Typst(body: %{#set text(font: "Not Installed")\n= Hello}).compile(:pdf)
doc.warnings?
# => true
doc.warnings
# => ["warning: unknown font family: \"Not Installed\"\n ┌─ /tmp/.../main.typ:1:0\n ..."]
Each entry is one formatted diagnostic, so they can be counted, filtered or logged individually. A clean compile returns an empty array. Warnings that accompany a compile error are still included in the raised message, as before.
Query a typst document
Typst("readme.typ").query("heading").result
# =>
# [{"func" => "heading",
# "level" => 1,
# "depth" => 1,
# ...
Typst("readme.typ").query("heading", format: "json").result(raw: true)
# => "[\n {\n \"func\": \"heading\",\n \"level\": 1,\n \"depth\": ..."
Typst("readme.typ").query("heading", format: "yaml").result(raw: true)
# => "- func: heading\n level: 1\n depth: 1\n offset: 0\n numbering: ..."
# Query results as JSON string
Typst("test/test.typ").query("heading").to_s
# => "[\n {\n \"func\": \"heading\",\n \"level\": 1,\n \"depth\": 1,\n \"offset\": 0,\n ...
# Query results as YAML string
Typst("test/test.typ").query("heading", format: "yaml").to_s
# => "- func: heading\n level: 1\n depth: 1\n offset: 0\n numbering: null\n supplement:\n ...
clear the compilation cache
# Evict all entries whose age is larger than or equal to `max_age`
max_age = 10
Typst::clear_cache(max_age)
Experimental
Threads and concurrency
A new experimental concurrent flag is added to work around Ruby's global VM lock and speed things up for batch workloads.
Set the option globally
Typst.concurrent = true
Or set the option per call
Typst(body: invoice, concurrent: true)
Compiling releases Ruby's global VM lock, so several threads can compile at the same time and genuinely use several cores.
docs = invoices.map { |invoice| Thread.new { Typst(body: invoice, concurrent: true).compile(:pdf) } }.map(&:value)
Eight threads compiling the same document, measured on a 24-core machine:
| threads | documents/second | cores busy |
|---|---|---|
| 1 | 9.9 | 1.00 |
| 8 | 36.0 | 7.65 |
That is throughput across documents, not speed within one. typst parallelizes page runs, so a document with a single page layout is one run and stays on one core no matter how many threads it is given.
Threads share the compilation cache and the font discovery. Compiling different
sources, with different sys_inputs, font paths and roots, at the same time is
safe.
Three things to know:
- A compile cannot be interrupted.
Thread#killandCtrl-Ctake effect once it returns. - Package storage is not safe to populate concurrently. Threads that need the same not-yet-cached package each download their own copy and untar it straight into the same directory, over each other; on Windows that fails outright rather than clobbering. Compile once to warm the cache before fanning out. Reading a package already on disk from many threads is safe.
clear_cachetakes the cache's write lock, so calling it in a loop while other threads compile will starve them. Measured with four threads compiling and one evicting as fast as it could, the compiles took 400 times longer. Call it between batches of documents, not between documents.
Contributors & Acknowledgements
typst-rb is based on typst-py by messense
clear_cache was contributed by NRicciVestmark
CI improvements were contributed by am1006
Defect resolutions by adam12 and walterdavis
Design suggestions by alec-c4
Compiler warnings were contributed by TheSoloHacker47
Font optimization patches were contributed by dmke
Concurrency patches were contributed by dmke
License
This work is released under the Apache-2.0 license. A copy of the license is provided in the LICENSE file.