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).

{: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:

Rendering untrusted HTML

Two engine policies matter when the HTML isn't yours:

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).