Folio

Print-quality PDF/SVG/PNG from Markdown + Elixir, powered by Typst's layout engine via Rustler NIF.

Why Folio

Data-Driven Documents at Runtime

Typst reads static files. Folio builds content trees from live Elixir data — Ecto queries, API responses, GenServer state. A Phoenix app generates PDFs from the same data it renders in HTML, with zero intermediate files:

def invoice_pdf(order) do
  ~MD"""
  # Invoice #{order.number}

  #{table([gutter: "4pt"], do: [
    table_header([table_cell("Item"), table_cell("Qty"), table_cell("Price")]),
    for item <- order.line_items do
      table_row([table_cell(item.name), table_cell("#{item.quantity}"), table_cell(Money.to_string(item.price))])
    end
  ])}
  """p
end

In plain Typst you'd serialize data to JSON/YAML, write it to disk, reference it from .typ files, and shell out to the CLI. Folio skips all of that.

Composable Document Fragments

DSL functions return plain structs — document pieces are first-class Elixir values. Build reusable components as regular functions, pattern-match on them, store them, pipe them:

defmodule Reports.Components do
  use Folio

  def kpi_card(label, value, trend) do
    block([above: "12pt", below: "12pt"], do: [
      strong(label),
      parbreak(),
      text("#{value} (#{trend})"),
    ])
  end
end

Folio lets you use all of Elixir — GenServers, protocols, behaviours, macros, Hex packages — as your composition layer.

No Typst Language, No Typst Parser, No Typst Evaluator

Folio constructs Typst content trees directly in Rust and feeds them straight to the layout engine. It bypasses Typst's parser, AST, and evaluation VM entirely:

Elixir-Native Concurrency for Batch Generation

With Typst CLI, generating 10,000 invoices means 10,000 process spawns. With Folio on dirty schedulers:

orders
|> Task.async_stream(
  fn order -> Folio.to_pdf(build_invoice(order)) end,
  max_concurrency: System.schedulers_online()
)
|> Stream.each(fn {:ok, pdf} -> upload(pdf) end)
|> Stream.run()

Fonts and layout data are loaded once and shared across compilations.

The Sigil as a Hybrid Format

~MD"""..."""p is a polyglot literal — Markdown for prose, #{} interpolation for dynamic content, DSL functions for layout primitives, $...$ for math, and a single modifier for output format:

~MD"""
#{heading(1, report.title)}

Written on #{Date.to_string(Date.utc_today())}.

#{for section <- report.sections do
  figure(
    image(section.chart_path, width: "200pt"),
    caption: section.title,
  )
end}

The model predicts $R^2 = #{Float.round(report.r_squared, 3)}$.
"""p

No Typst equivalent exists — Typst's templating is powerful but doesn't embed in another language's string interpolation.

Quick start

use Folio

# Markdown → PDF
{:ok, pdf} = Folio.to_pdf("# Hello\n\n**Bold** and $x^2$ math.")

# ~MD sigil with p modifier → {:ok, binary}
{:ok, pdf} = ~MD"""
# Report

Some **bold** content with inline $E = m c^2$ math.

| Metric | Value |
|--------|-------|
| A      | 1     |
| B      | 2     |
"""p

# Content nodes → {:ok, binary}
{:ok, pdf} = Folio.to_pdf([
  heading(1, "Hello"),
  text("Normal "),
  strong("bold"),
  text(" and "),
  emph("italic"),
])

Export formats

{:ok, pdf} = Folio.to_pdf("# Hello")       # PDF binary
{:ok, svgs} = Folio.to_svg("# Hello")      # [String.t()] — one SVG per page
{:ok, pngs} = Folio.to_png("# Hello")      # [binary()] — one PNG per page
{:ok, pngs} = Folio.to_png("# Hello", dpi: 3.0)  # higher resolution

Styles

use Folio imports all style functions, so you can use them without the Folio.Styles. prefix:

{:ok, pdf} = Folio.to_pdf("Hello", styles: [
  page_size(width: 595, height: 842),
  font_family(["Helvetica"]),
  font_size(12),
  text_color("#222222"),
  page_numbering("1"),
])

Page headers and footers

{:ok, pdf} =
  Folio.to_pdf("# Report\n\nBody text.",
    styles: [
      page_header(align("center", [smallcaps("Quarterly Report")])),
      page_footer(align("center", [text("Confidential")])),
      page_numbering("1")
    ]
  )

Heading styling

{:ok, pdf} =
  Folio.to_pdf("# Intro\n\n## Details",
    styles: [
      heading_numbering("1."),
      heading_supplement("Chapter"),
      heading_bookmarked(true),
      heading_outlined(true),
      par_indent(18)
    ]
  )

Document pipeline

doc =
  Folio.Document.new()
  |> Folio.Document.add_style(page_numbering("1"))
  |> Folio.Document.add_style(font_family(["Helvetica"]))
  |> Folio.Document.add_content("# Invoice\n\n...")
  |> Folio.Document.add_content(Folio.parse_markdown!("Terms and conditions."))

{:ok, pdf} = Folio.to_pdf(doc)

Images

Attach files to a document for session-scoped isolation, or register them globally:

# Session-scoped (preferred)
doc =
  Folio.Document.new()
  |> Folio.Document.attach_file("chart.png", File.read!("chart.png"))
  |> Folio.Document.add_content("![Chart](chart.png)")
{:ok, pdf} = Folio.to_pdf(doc)

# Global (shared across all compilations)
Folio.register_file("chart.png", File.read!("chart.png"))
{:ok, pdf} = Folio.to_pdf("![Chart](chart.png)")

# Free global file memory when no longer needed
Folio.unregister_file("chart.png")

From DSL:

{:ok, pdf} = Folio.to_pdf([image("chart.png", width: "200pt", fit: "contain")])

Citations and bibliography

Register bibliography files the same way as images:

Folio.register_file("works.bib", File.read!("examples/works.bib"))

{:ok, pdf} =
  Folio.to_pdf([
    text("See "),
    cite("knuth1984", supplement: "p. 7"),
    text(" for details."),
    bibliography("works.bib", title: "References")
  ])

Supported bibliography sources are .bib, .yaml, and .yml files provided through register_file/2.

DSL reference

use Folio imports all builder functions. Every function returns a %Folio.Content.*{} struct.

Text

Function Example
text/1text("hello")
strong/1strong("bold")
emph/1emph("italic")
strike/1strike("deleted")
underline/1underline("underlined")
highlight/2highlight("note", fill: "#FFD700")
superscript/1superscript("2")
subscript/1subscript("2")
smallcaps/1smallcaps("Hello")
raw/2raw("x = 1", lang: "elixir")
link/2link("https://example.com", "click")

Structure

Function Example
heading/2heading(1, "Title")
cite/2cite("knuth1984", supplement: "p. 7")
bibliography/2bibliography("works.bib", title: "References")
blockquote/2blockquote([text("Wisdom")], attribution: "Author")
list/2list(["Apples", "Oranges"])
enum/2enum(["First", "Second"])
term_list/1term_list([{"Term", "Definition"}])
footnote/1footnote(text("A note"))
divider/0divider()
outline/1outline(title: "Contents")
title/1title("Document Title")

Layout

Function Example
columns/3columns(2, do: [text("Col 1"), text("Col 2")])
align/2align("center", [text("Centered")])
block/2block(width: "100%", do: [text("Full width")])
vspace/1vspace("24pt")
hspace/1hspace("20pt")
pagebreak/1pagebreak(weak: true)
colbreak/1colbreak()
parbreak/0parbreak()
pad/2pad([top: "10pt"], do: [text("Padded")])
stack/2stack([dir: "ltr"], do: [rect(...), rect(...)])
hide/1hide(text("Hidden"))
place/2place(text("Absolute"), alignment: "center")

Shapes

Function Example
rect/1rect(width: "100pt", height: "50pt", fill: "#3498DB")
square/1square(fill: "red", width: "40pt")
circle/1circle(fill: "blue", radius: "20pt")
ellipse/1ellipse(fill: "green", width: "80pt", height: "40pt")
line/1line()
polygon/2polygon(["0pt,0pt", "100pt,0pt", "50pt,50pt"], fill: "red")

All shapes accept a body option for inner content.

Tables

table([gutter: "8pt"], do: [
  table_header([table_cell(strong("Name")), table_cell(strong("Age"))]),
  table_row([table_cell("Alice"), table_cell("30")]),
  table_row([table_cell("Bob"), table_cell("25")]),
])

table_cell/2 accepts rowspan:, colspan:, align: options.

Figures

figure(
  circle(fill: "red", radius: "20pt"),
  caption: "A red circle.",
  numbering: "1",
  placement: "bottom",
)

Images

image("photo.png", width: "200pt", height: "100pt", fit: "contain")

Fit options: "cover" (default), "contain", "stretch".

Styles

use Folio imports all style functions. Use them without prefix:

styles = [
  page_header(align("center", [text("Header")])),
  page_footer(align("center", [text("Footer")])),
  page_numbering("1"),
  heading_numbering("1."),
  heading_supplement("Chapter"),
  heading_outlined(true),
  heading_bookmarked(true),
  par_indent(18)
]

Math

Uses Typst's math syntax inside $...$:

# Inline via Markdown
Folio.to_pdf("The equation $E = m c^2$")

# Block via Markdown
Folio.to_pdf("$$integral_0^1 x dif x = 1/3$$")

# Via DSL
math("x^2 + 1", block: true)

Value syntax

Installation

def deps do
  [
    {:folio, "~> 0.1.0", github: "dannote/folio"}
  ]
end

Requires Rust toolchain for NIF compilation.

License

MIT — see LICENSE.md