AshTypst (ash_typst v0.4.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.

Refresh font discovery after fonts are installed or removed.

Functions

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

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.

refresh_fonts()

Refresh font discovery after fonts are installed or removed.

Font scan results are cached process-wide (per font configuration) and reused by every new context, so font changes on the system are not visible automatically. Calling this clears that cache and drops all pooled render contexts — new contexts and render actions then re-scan.

Contexts you are still holding keep the fonts they were created with; recreate them to pick up the changes.