ExHtmltopdf
HTML → PDF for Elixir, natively. An Elixir NIF wrapper for sghtmltopdf, a PDF rendering engine written in Rust on Servo components (html5ever, Stylo, Taffy).
- No headless browser. No Chrome, no wkhtmltopdf binary, no ports or System.cmd — the engine runs in-process on a dirty scheduler.
- Modern CSS. Flexbox, Grid, custom properties,
@pagerules, repeating table headers across page breaks. - Precompiled. Ships prebuilt NIFs for macOS/Linux (arm64 + x86_64) via
rustler_precompiled— users need no Rust toolchain. - Fast. Upstream benchmarks ~21× faster than wkhtmltopdf and ~53× faster than headless Chrome on large documents.
{:ok, pdf} = ExHtmltopdf.render("<h1>Hello</h1><p>from Elixir</p>")
:ok =
ExHtmltopdf.render_to_file(html, "invoice.pdf",
page_size: "A4",
margin_top: "20mm",
footer_center: "Page [page] of [topage]"
)
Installation
def deps do
[
{:ex_htmltopdf, "~> 0.1"}
]
end
Usage
# To a binary (send it, store it, no temp files):
{:ok, pdf} = ExHtmltopdf.render(html, page_size: "A4")
# Raising variant:
pdf = ExHtmltopdf.render!(html)
# Straight to a file — written atomically (temp file + rename), a failed
# render never leaves a truncated PDF behind:
:ok = ExHtmltopdf.render_to_file(html, "out.pdf", grayscale: true)
Options
sghtmltopdf exposes one option surface — its CLI flags — shared by the CLI,
HTTP server, Ruby gem, and this library. Options are the flag names as
underscored atoms; values follow a few simple rules
(see ExHtmltopdf.Options):
page_size: "A4" # --page-size A4
orientation: "landscape" # --orientation landscape
margin_top: "20mm" # --margin-top 20mm (all four sides available)
grayscale: true # bare flag; false/nil omits it
dpi: 300
zoom: 1.25
title: "Q3 Report" # PDF metadata (also author/subject/keywords)
header_center: "Confidential" # simple headers/footers (+ _left/_right)
footer_center: "Page [page] of [topage]"
header_html: "header.html" # full HTML headers/footers
toc: true # table of contents
cover: "cover.html"
user_style_sheet: "print.css" # may repeat: pass a list
minimum_font_size: 9
encoding: "shift_jis"
base_url: "https://example.com/" # resolve relative <img>/<link> paths —
# rendering from a string has no document
# directory, so without this (or an inline
# <base href>) relative assets resolve to
# nothing, silently (see below)
font: "/fonts/NotoSansJP.ttf" # embed fonts; .ttc faces:
font: %{path: "/fonts/Hiragino.ttc", index: 1}
gothic_font: "/fonts/NotoSansJP.ttf" # pin CSS generic families
See the upstream documentation for the full flag list — anything the CLI accepts works here, including new flags after a dependency bump, with no wrapper changes.
Unsupported wkhtmltopdf options (JavaScript execution, forms, …) return a
clear {:error, %ExHtmltopdf.Error{kind: :usage}} explaining why, exactly
like the CLI does.
Errors
Render functions return {:error, %ExHtmltopdf.Error{kind: kind, message: message}} (bang variants raise it). kind mirrors the CLI's exit-code
classes: :usage (bad options), :input (missing file/font, unwritable
output), :render (engine constraint), :timeout, plus :panic for a
contained native bug (the VM survives; please report upstream).
Messages are upstream's text verbatim — often Japanese (clap's parse errors
are English). Match on kind, never on message.
Missing assets, warnings, and runaway documents
Three operational behaviors worth knowing before production:
- Missing assets degrade silently by default. A broken
<img>, dead@import, or unreadable font still returns{:ok, pdf}— upstream prints a warning to raw stderr (bypassingLogger; not suppressible via options) and renders without the asset. Passload_media_error_handling: "abort"to get{:error, %Error{}}instead. - Renders are uninterruptible. A render occupies one dirty CPU scheduler
until the engine finishes; the BEAM cannot kill a running NIF, and
Task.shutdownabandons the caller without stopping the work. Bound your concurrency (e.g. a fixed-sizeTask.Supervisorpool) — a deadline option is planned (PORTING.md phase 3). - Concurrent
render_to_file/3calls are safe, including to the same path (atomic per-call temp + rename; last writer wins).
Rendering untrusted HTML
Two engine policies matter when the HTML isn't yours:
- Remote assets are off by default.
<img src="https://…">and remote stylesheets are only fetched withallow_remote_assets: true(upstream guards fetches against SSRF/private addresses). - Local file access is ON by default (upstream CLI compatibility):
<img src="/etc/anything">andfile://references read from the host filesystem. For untrusted input, passdisable_local_file_access: true, orallow: ["/safe/dir"]to restrict reads to specific directories.
ExHtmltopdf.render(untrusted_html, disable_local_file_access: true)
Phoenix
Render any HTML your app can produce — a controller action for PDF invoices:
def invoice(conn, %{"id" => id}) do
html =
Phoenix.Template.render_to_string(MyAppWeb.InvoiceHTML, "show", "html",
invoice: Invoices.get!(id)
)
{:ok, pdf} = ExHtmltopdf.render(html, page_size: "A4")
conn
|> put_resp_content_type("application/pdf")
|> put_resp_header("content-disposition", ~s(attachment; filename="invoice-#{id}.pdf"))
|> send_resp(200, pdf)
end
Development
Requires a Rust toolchain. EXHTMLTOPDF_BUILD=1 forces building the NIF
from source instead of downloading a precompiled one:
EXHTMLTOPDF_BUILD=1 mix test # or: just test
just fmt # mix format + cargo fmt
The upstream checkout is expected at ../sghtmltopdf; UPDATE_PROCEDURE.md
documents how to bump the pinned revision, and PORTING.md the project's
design and staged plan.
License
MIT. sghtmltopdf itself is MIT (© yo_waka).