PDF generation for Rails on the Typst engine — the wkhtmltopdf replacement. Invoices, reports, certificates: fast, beautiful, accessible (PDF/UA), and no headless Chrome in your Docker image.
Your Rails app generates PDFs. Your options until now:
| problem | |
|---|---|
| wicked_pdf / pdfkit | built on wkhtmltopdf, which is archived and unmaintained — no security fixes, ancient WebKit |
| grover / Chrome-based | ships a full browser in your production image: hundreds of MB, memory spikes, cold starts |
| prawn | hand-positioning a coordinate DSL for every invoice line |
Typst is the Rust-based successor to LaTeX: a markup language that
compiles to PDF in milliseconds, reads JSON natively, and can emit PDF/UA-1
accessible PDFs — which the European Accessibility Act now effectively requires for
machine-generated customer documents. typstify makes it feel like Rails.
# Gemfile
gem "typstify"bundle && rails g typstify:install && rails g typstify:template invoice# app/controllers/invoices_controller.rb
def show
invoice = Invoice.find(params[:id])
render pdf: "invoices/show",
data: { number: invoice.number,
total: invoice.total.format,
line_items: invoice.line_items.map { |li| { name: li.name, qty: li.qty, amount: li.amount.format } } },
filename: "invoice-#{invoice.number}.pdf"
end// app/views/invoices/show.typ
#let data = json("data.json")
= Invoice #data.number
#table(
columns: (1fr, auto, auto),
[*Item*], [*Qty*], [*Amount*],
..data.line_items.map(i => (i.name, str(i.qty), i.amount)).flatten()
)
#align(right)[*Total: #data.total*]That's a complete, styled, paginated invoice. No browser. No dead binaries.
Notice what the template does: it reads your data as JSON, it doesn't interpolate strings.
Your Ruby data can contain anything — Typst markup, # directives, quotes — and it renders as
literal text, because content never becomes code.
Each render also happens in a fresh temporary directory containing only the template, its
shared/ partials, and data.json. Typst is invoked with that directory as its compile root,
so a template physically cannot read your .env, your credentials, or any other view. Not
because we filter the path — because the file is not there.
spec/security/ asserts both properties directly; they are the specs worth reading first.
Prefer classic ERB? .typ.erb templates work too — pass every dynamic value through the
typ() escaping helper:
= Hello <%= typ(@user.display_name) %>But use data mode. Future-you says thanks.
# Background jobs / plain Ruby
pdf_bytes = Typstify.render(template: "reports/monthly", data: payload)
# Mailers
attachments["report.pdf"] = Typstify.render(template: "reports/monthly", data: payload)
# ActiveStorage
Typstify.render_and_attach(user.documents, template: "certificates/completion",
data: cert_data, filename: "certificate.pdf")Batch-generating 10,000 invoices in a job? Typst's per-document compile time is milliseconds —
see docs/batch-generation.md for a Sidekiq/Solid Queue pattern
and the two settings that make output reproducible.
rails g typstify:template <name> for: invoice · receipt · report ·
certificate. Each comes with sample data and a preview task:
rake typstify:preview[invoices/show]which compiles with the bundled sample_data.json into tmp/previews/, so you can iterate on
design without loading your app's initializers or touching the database. Shared branding (logo,
colours, footer) lives in app/views/shared/branding.typ — change it once, every document
updates.
HTML→PDF pipelines produce untagged PDFs that fail accessibility requirements. Typst supports PDF/UA-1 output; enable it globally:
Typstify.configure { |c| c.pdf_standard = :ua_1 }Also available: :a_1b, :a_2b, :a_3b, :a_4, :pdf_1_7, :pdf_2_0. PDF/UA-1 requires a
document title, so set #set document(title: "…") in the template.
If you invoice EU customers, your compliance team will hug you.
Typst uses system fonts plus any directories you configure. The reliable production setup is vendoring fonts in your app:
c.font_paths = [Rails.root.join("app/assets/fonts")]A missing font family fails loudly in development and test (strict_fonts, on by default
there) instead of silently substituting a different face in production. Inter ships with the
gem, so the starter templates work with no configuration at all.
Once your fonts are vendored, turn off the system font scan. It costs about 50 ms per render — the starter invoice goes from 58 ms to 4 ms:
c.ignore_system_fonts = trueTypst Universe packages can be vendored into a directory for network-free builds — with a
platform caveat that matters. Full guide: docs/fonts-and-docker.md.
# config/initializers/typstify.rb — written by the install generator
Typstify.configure do |c|
c.template_root = Rails.root.join("app/views") # default
c.shared_dir = "shared" # partials namespace
c.font_paths = [Rails.root.join("app/assets/fonts")]
c.ignore_system_fonts = true # false by default; see below
c.package_cache = Rails.root.join("vendor/typst_packages") # vendored Universe packages
c.pdf_standard = :ua_1 # nil by default
c.strict_fonts = Rails.env.local? # the default
c.on_warning = ->(warnings, template) { Rails.logger.warn("#{template}: #{warnings}") }
endThe typst binding used to discard the compiler's warnings whenever compilation succeeded
— they were only formatted into the message when compilation failed. That is fixed upstream in
actsasflinn/typst-rb#10, released in
typst 0.15.1.6.
| binding | what on_warning receives |
|---|---|
| typst ≥ 0.15.1.6 | every compiler warning, from successful and failed compiles alike, with the workspace path rewritten to your template's name |
| typst < 0.15.1.6 | warnings that accompanied a compile error, plus this gem's own missing-font check |
The gem detects which it has by capability, not by version string, so upgrading the binding is
all you need to do. The font pre-check stays either way: it runs before the compiler, which
is what lets strict_fonts fail without paying for a compile. When the binding can report the
same thing itself, the pre-check keeps quiet rather than saying it twice.
| when | |
|---|---|
Typstify::MissingTemplate |
neither .typ nor .typ.erb exists; the message lists both paths tried |
Typstify::CompileError |
Typst rejected the document; carries its annotated diagnostic and the line, with your template's name substituted for the workspace path |
Typstify::FontMissingError |
a template names a font nothing can supply, in strict mode |
Typstify::PathError |
a template name resolved outside template_root |
ArgumentError |
data: contains something JSON cannot represent; the message names the key path, e.g. data.line_items[0].amount |
There's no HTML→Typst converter — you rewrite each PDF view, and that's genuinely fine: most
teams have 2–5 PDF views, and each takes minutes, not hours, in Typst. The side-by-side
walkthrough (a real wicked_pdf invoice vs its Typst version, at a third of the line count):
docs/migrating-from-wicked_pdf.md.
Ruby ≥ 3.1 · Rails 7.1 – 8.1 · Linux & macOS. PDF compilation is done by the excellent
typst gem (Rust binding to the Typst compiler) —
this gem is the Rails layer on top, and ships no compiler of its own.
Note: Rails 8.1 requires Ruby 3.4 or newer in practice, because actionview uses syntax that
older Rubies cannot parse. The CI matrix pairs them accordingly.
typst-rails by David J. Berube covers similar
ground and appeared around the same time; it supports Rage and Sinatra alongside Rails. This
gem is narrower on purpose — Rails only — and spends that focus on the sandboxed workspace, the
data-mode security model, the starter template pack and PDF/UA output. Worth comparing both
against what you need.
git clone https://github.com/TheSoloHacker47/typstify && cd typstify
bin/setup && bundle exec rakeTemplate pack contributions especially welcome — new document types need sample data, a spec, and a golden. See CONTRIBUTING.md.
MIT. Inter is vendored under the SIL Open Font License. Typst itself is Apache-2.0 by the Typst team — go star it.
- scrubber_rb — fast PII and secret redaction for Ruby, with a Rust core
- bundler-overrule — force, ban and swap gem versions in your Gemfile