AshTypst
Precompiled Rust NIFs for rendering Typst templates via an extensible data-encoding protocol with built-in Ash support. Compile markup to SVG, PDF, or HTML with persistent contexts that keep fonts and compiled state in memory for fast, iterative rendering.
Features
- Persistent context — fonts are scanned once and reused across compiles
- Multi-page rendering — compile once, render any page as SVG
- PDF export — proper binary output with page ranges, PDF/A standards, and document IDs
- HTML export — via
typst-html - Virtual files — inject data as in-memory
.typfiles your templates can#import - Streaming — feed large datasets from Elixir streams into virtual files in constant memory
sys.inputs— pass simple string parameters accessible via#sys.inputsin templates- Rich diagnostics — compile errors include line/column numbers
- Data encoding — the
AshTypst.Codeprotocol converts Elixir types (maps, lists, dates, decimals, Ash resources) to Typst syntax - Timezone-aware encoding — dates and times are automatically shifted to a configured timezone when encoding to Typst
- Ash Resource Extension - define template-rendering actions inside your resources via DSL
Installation
Add ash_typst to your dependencies in mix.exs:
def deps do
[
{:ash_typst, "~> 0.0.1"}
]
endPrecompiled NIF binaries are downloaded automatically for common targets. To
compile from source, add {:rustler, "~> 0.35"} as an optional dependency and
set RUSTLER_PRECOMPILATION_EXAMPLE_FORCE_BUILD=1.
Quick start
# 1. Create a context — fonts loaded once, reused for all operations
{:ok, ctx} = AshTypst.Context.new(root: "/path/to/templates")
# 2. Set the main template
:ok = AshTypst.Context.set_markup(ctx, """
#import "data.typ": records
= Invoice \#sys.inputs.at("invoice_id")
#for r in records [- \#r.name: \#r.amount]
""")
# 3. Inject data
AshTypst.Context.set_inputs(ctx, %{"invoice_id" => "INV-42"})
AshTypst.Context.stream_virtual_file(ctx, "data.typ", line_items,
variable_name: "records"
)
# 4. Compile
{:ok, %AshTypst.CompileResult{page_count: n}} = AshTypst.Context.compile(ctx)
# 5. Render
{:ok, svg} = AshTypst.Context.render_svg(ctx, page: 0)
{:ok, pdf_binary} = AshTypst.Context.export_pdf(ctx, pages: "1-3", pdf_standards: [:pdf_a_2b])
{:ok, html} = AshTypst.Context.export_html(ctx)Context API
All rendering is done through AshTypst.Context.
| Function | Purpose |
|---|---|
new/1 | Create a context with root path and font options |
set_markup/2 | Set the main Typst template (invalidates compiled doc) |
compile/1 | Compile markup into a paged document |
render_svg/2 | Render a page as SVG |
export_pdf/2 | Export the document as a PDF binary |
export_html/1 | Export as HTML (separate compilation pass) |
set_virtual_file/3 | Set an in-memory file importable by templates |
stream_virtual_file/4 | Stream an enumerable into a virtual file |
append_virtual_file/3 | Append a chunk to a virtual file |
clear_virtual_file/2 | Remove a virtual file |
set_input/3 | Set a single sys.inputs entry |
set_inputs/2 | Replace all sys.inputs entries |
font_families/1 | List fonts loaded in this context |
Data encoding
The AshTypst.Code protocol converts Elixir values into Typst source syntax:
| Elixir type | Typst type |
|---|---|
Map | dictionary |
List | array |
Integer | int(n) |
Float | float(n) |
Decimal | decimal(n) |
String | "str" |
DateTime / NaiveDateTime / Date / Time | datetime(...) |
true / false | true / false |
nil | none |
| Ash resource | dictionary of public fields |
Implement AshTypst.Code for your own structs to control how they serialize.
Ash Resource Extension
AshTypst.Resource is a Spark DSL extension that lets you declare Typst templates
and render actions directly on your Ash resources. Each render action becomes a
standard Ash generic action that returns an AshTypst.Document struct.
defmodule MyApp.Invoice do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshTypst.Resource]
typst do
root "priv/typst"
template :invoice do
source "invoice.typ"
inputs %{"company" => "Acme Corp"}
end
template :receipt do
# ~TYPST sigil is auto-imported inside template blocks
markup ~TYPST"""
#import "data.typ": record, args
= Receipt \#args.receipt_number
*Customer:* \#record.name
"""
end
render :generate_pdf do
template :invoice
format :pdf
argument :invoice_id, :string, allow_nil?: false
read :one do
filter expr(id == ^arg(:invoice_id))
load [:line_items, :customer]
end
pdf_options do
pdf_standards [:pdf_a_2b]
end
end
end
endCall the action like any other Ash generic action:
input = Ash.ActionInput.for_action(MyApp.Invoice, :generate_pdf, %{invoice_id: "123"})
{:ok, %AshTypst.Document{format: :pdf, data: pdf_binary}} = Ash.run_action(input)Data is injected into a virtual file (data.typ by default) that your template can
#import. Depending on the read cardinality, your template receives record (single),
records (list), and/or args (action arguments).
For the complete DSL reference, see the AshTypst.Resource DSL cheatsheet.
Live editing
The context is designed for iterative workflows. After the initial setup, only the changed markup or data needs to be re-set before re-compiling:
# Initial render
:ok = AshTypst.Context.set_markup(ctx, template_v1)
{:ok, _} = AshTypst.Context.compile(ctx)
{:ok, svg} = AshTypst.Context.render_svg(ctx)
# User edits template — only re-set what changed
:ok = AshTypst.Context.set_markup(ctx, template_v2)
{:ok, _} = AshTypst.Context.compile(ctx)
{:ok, svg} = AshTypst.Context.render_svg(ctx)Fonts, virtual files, and sys.inputs all persist across re-compilations.