AshTypst (ash_typst v0.2.0)

Copy Markdown View Source

Precompiled Rust NIFs for rendering Typst documents from Elixir.

All rendering goes through a persistent AshTypst.Context, which loads fonts once and keeps the compiled document in memory so you can render pages, export PDFs, or re-compile after a markup change without repeating expensive setup.

Architecture

graph TB
  subgraph Elixir
    direction LR
    C[AshTypst.Code] -->|encode| A[AshTypst.Context] -->|NIF calls| N[AshTypst.NIF]
  end

  N --> W
  N --> VF
  N --> IN
  N --> F

  subgraph "Rust -- TypstContext resource"
    subgraph "SystemWorld (persistent)"
      direction LR
      W[Markup]
      VF[Virtual Files]
      IN[sys.inputs]
      F[Fonts + FontBook]
      FS["File Slots -- disk cache"]
    end

    W  -->|compile| PD
    VF -->|import| PD
    IN -->|sys.inputs| PD
    F  -->|font resolve| PD
    FS -->|import pkg| PD

    subgraph "Compiled Output"
      PD["PagedDocument (cached)"]
    end

    PD -->|render_svg| SVG[SVG string]
    PD -->|export_pdf| PDF[PDF binary]
    W  -->|export_html| HTML[HTML string]
  end

Key points:

  • The TypstContext is a Rust NIF resource held as an opaque reference in Elixir.
  • Fonts are scanned once at context creation and reused for every compile.
  • compile/1 stores a PagedDocument; render_svg/2 and export_pdf/2 read from it without recompiling.
  • export_html/1 performs its own compilation (HTML uses a different document type internally).
  • Virtual files and sys.inputs persist across compiles until explicitly changed.

Quick start

# Create a context (fonts scanned once)
{:ok, ctx} = AshTypst.Context.new(root: "/path/to/templates")

# Set markup and compile
:ok = AshTypst.Context.set_markup(ctx, "= Hello World")
{:ok, %AshTypst.CompileResult{page_count: 1}} = AshTypst.Context.compile(ctx)

# Render any page as SVG
{:ok, svg} = AshTypst.Context.render_svg(ctx, page: 0)

# Export the full document as PDF
{:ok, pdf_binary} = AshTypst.Context.export_pdf(ctx)

Data injection

You can feed Elixir data into templates in two ways:

Virtual files

Create in-memory .typ files that your template can #import:

AshTypst.Context.set_virtual_file(ctx, "data.typ", ~s(#let title = "Q4 Report"))
AshTypst.Context.set_markup(ctx, ~s(#import "data.typ": title\n= \#title))

For large datasets, stream records in batches to keep Elixir memory flat:

AshTypst.Context.stream_virtual_file(ctx, "rows.typ", records_stream,
  variable_name: "rows",
  context: %{timezone: "America/New_York"}
)

sys.inputs

Pass simple string key/value pairs accessible via #sys.inputs in templates:

AshTypst.Context.set_inputs(ctx, %{"theme" => "dark", "locale" => "en"})

Data encoding

The AshTypst.Code protocol converts Elixir values to Typst source syntax. It handles maps, lists, dates, decimals, Ash resources, and more. See AshTypst.Code.encode/2 for the full type mapping.

Live editing

The context is designed for iterative workflows. Only the markup (or virtual file) that changed needs to be re-set before re-compiling; fonts and other state stay hot:

:ok = AshTypst.Context.set_markup(ctx, updated_template)
{:ok, _} = AshTypst.Context.compile(ctx)
{:ok, svg} = AshTypst.Context.render_svg(ctx, page: current_page)

Summary

Functions

List all font families available to Typst.

Functions

font_families(opts \\ %AshTypst.FontOptions{})

@spec font_families(AshTypst.FontOptions.t()) :: [String.t()]

List all font families available to Typst.

This is a standalone operation that does not require a context. For fonts loaded in a context, use AshTypst.Context.font_families/1.