Skip to main content

lib/ash_typst.ex

defmodule AshTypst do
  @moduledoc """
  Precompiled Rust NIFs for rendering [Typst](https://typst.app) 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

  ```mermaid
  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)
  """

  @doc """
  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`.
  """

  def font_families(%AshTypst.FontOptions{} = opts \\ %AshTypst.FontOptions{}) do
    AshTypst.NIF.font_families(opts)
  end
end