Mead

Hex.pmDocsCI

Native PDF generation for Elixir, in the style of react-pdf: you write ~PDF templates (HEEx-flavored function components with attr/slot validation), a flexbox engine lays them out, and a pure-Rust backend draws the PDF. No headless browser, no wkhtmltopdf, no LaTeX — just a NIF.

defmodule MyApp.Report do
use Mead.Component
attr :customer, :string, required: true
attr :rows, :list, required: true
def report(assigns) do
~PDF"""
<view gap="12">
<text font_size="24" font_weight="bold">Quarterly Report</text>
<text color="#5A6178">Prepared for {@customer}. All figures in USD.</text>
<table>
<tr repeat background="#283C86">
<td padding="6"><text color="#FFFFFF">Service</text></td>
<td padding="6"><text color="#FFFFFF" text_align="right">Amount</text></td>
</tr>
<tr :for={{service, amount} <- @rows}>
<td padding="6"><text>{service}</text></td>
<td padding="6"><text text_align="right">{amount}</text></td>
</tr>
</table>
</view>
"""
end
def footer(assigns) do
~PDF"""
<text font_size="8" color="#8A90A5" text_align="right">
Page <page_number/> of <page_count/>
</text>
"""
end
end
pdf =
Mead.render(MyApp.Report.report(%{customer: "ACME", rows: rows}),
margin: 50,
footer: MyApp.Report.footer(%{}),
title: "Quarterly Report"
)
File.write!("report.pdf", pdf)

Content that outgrows a page flows onto the next one: tables split with their header rows repeating (repeat), orphan/widow control and break_before="avoid" keep the breaks sensible, and header/footer chrome with live page counters is painted on every page.

Features

Installation

def deps do
[
{:mead_pdf, "~> 0.1.0"}
]
end

Precompiled NIF binaries are downloaded at compile time for common platforms (Linux glibc/musl, macOS, Windows, on x86_64 and aarch64 — see the release artifacts). No Rust toolchain needed.

To compile the NIF from source instead (unsupported platform, or you just prefer to), add {:rustler, "~> 0.38"} to your deps and set MEAD_BUILD=1:

MEAD_BUILD=1 mix deps.compile mead_pdf --force

Fonts

Mead bundles Liberation Sans (regular/bold/italic/bold-italic, SIL OFL) so rendering works out of the box — including in slim container images that ship no fonts at all. Fonts are subset into the output, so PDFs stay small.

Declare your own fonts per family, with an optional fallback chain and a lazy pool for broad script coverage (e.g. the Noto family):

session =
Mead.Session.new(
Mead.FontSet.new(
%{
"Inter" => [
%{path: "priv/fonts/Inter-Regular.ttf"},
%{path: "priv/fonts/Inter-Bold.ttf", weight: 700}
],
"Noto Sans SC" => [%{path: "priv/fonts/NotoSansSC-Regular.otf"}]
},
fallback: ["Noto Sans SC"],
pool: ["/usr/share/fonts/google-noto"]
)
)
Mead.render(doc, session: session)

Reuse the session for repeated rendering — fonts parse once and shaping caches stay warm.

Performance

bench/readme_bench.exs renders a 9-page tabular report (repeating table header, page footer with counters) through a warm session:

Single-threaded14 ms/doc median (p99 20 ms)
Concurrent (12 schedulers)~420 docs/sec

Measured on an AMD Ryzen 5 7640U laptop (Linux, 12 threads); run the script yourself for numbers on your hardware. Rendering runs on dirty schedulers, so it won't starve the rest of your application.

Documentation

Full documentation is on HexDocs. The examples/ directory renders a realistic invoice, a template-driven report, and more (just example invoice).

License

MIT. The Rust dependency tree is entirely permissive (MIT/Apache-2.0/BSD/Zlib/ISC, plus Unicode-3.0 for the ICU4X segmentation data compiled into the binary). Bundled Liberation Sans fonts are under the SIL Open Font License (see priv/fonts/LiberationSans-LICENSE).