Native PDF layout for Elixir.
Rendro is an open-source, Elixir-native PDF layout library for Phoenix teams that need reliable PDFs without Chrome.
Features
- Elixir-native core: Build PDFs from data and components without headless Chrome or wkhtmltopdf.
- Deterministic output: Same input, same binary output when rendered with deterministic options.
- Flow and pagination: Compose fixed-position and flow documents with explicit break semantics.
- Canonical recipes: Use
document/2,page_template/1, andsections/2escape hatches for invoices, statements, receipts, reports, and certificates. - Operational proof: Telemetry, diagnostics, support boundaries, and docs-contract checks keep public claims auditable.
Rendered Recipe Gallery
These previews are rendered by Rendro from curated deterministic recipe fixtures.
Source PDFs and the self-rendered manual are byte-checked by the required docs contract. PNG rasters are regenerated and hash-checked in the pinned pdfium-render advisory lane. pdfium-render rasters are render proof, not GUI-viewer proof. Launch fixtures may use opt-in table polish; canonical recipe defaults remain unchanged.
Self-rendered manual: manual.pdf
SHA-256: a9f1a241c3fb331ad5522d905af8acf26d9848b8862cb9a6f3e4033c3ee1dc94
Guides
- User Flows and Jobs To Be Done — the shortest path to understanding which Rendro workflow fits your SaaS use case.
- Branding — register fonts and logo assets, then use the branded invoice recipe.
- Integrations — optional adapters for Phoenix ecosystem workflows such as Oban, Threadline, Mailglass, and signing.
- Generating PDFs in Elixir without Chrome — an evidence-backed comparison guide for Rendro, ChromicPDF, pdf_generator, and Typst CLI.
- First Invoice Livebook — a zero-friction tutorial for rendering and downloading a deterministic invoice PDF.
- API Stability and Support Boundaries — the canonical support language for trust-sensitive and proof-backed surfaces.
- Adoption Signals - public criteria for counting demand toward the conditional v2.7 text-shaping gate.
Getting Started with the Builder API
The pipeline builder API is the canonical way to compose documents in Rendro. It mirrors the ergonomics of Plug.Conn and Ecto.Changeset: each function takes a %Rendro.Document{} and returns a new one, making it easy to build documents conditionally and dynamically during a request cycle.
import Rendro.Document
doc =
Rendro.Document.new()
|> add_template(
Rendro.page_template(
name: :report,
regions: [
Rendro.region(name: :header, role: :header, anchor: :top, x: 24, y: 24, width: 372, height: 24),
Rendro.region(name: :body, role: :body, anchor: :flow, x: 24, y: 72, width: 372, height: 451),
Rendro.region(name: :footer, role: :footer, anchor: :bottom, x: 24, y: 547, width: 372, height: 24)
]
)
)
|> set_template(:report)
|> add_section(Rendro.section(name: :heading, region: :header, content: [
Rendro.block(Rendro.text("Account Statement", size: 14))
]))
|> add_section(Rendro.section(name: :body_text, region: :body, content: [
Rendro.block(Rendro.text("Summary paragraph here.", size: 12), width: 372)
]))
|> add_section(Rendro.section(name: :footer_text, region: :footer, content: [
Rendro.block(Rendro.text("Generated by Rendro", size: 10))
]))
{:ok, _pdf} = Rendro.render(doc)All content is routed through named regions on a %Rendro.PageTemplate{}. The pipeline builder functions are:
| Function | Purpose |
|---|---|
Rendro.Document.new/0 | Create an empty %Rendro.Document{} |
Rendro.Document.new/1 | Create a document from keyword options |
Rendro.Document.add_template/2 | Append a %Rendro.PageTemplate{} |
Rendro.Document.set_template/2 | Set the active template by name |
Rendro.Document.add_section/2 | Append a %Rendro.Section{} to the document |
Rendro.Document.put_metadata/2 | Replace document metadata |
Rendro.Document.put_options/2 | Merge render options |
Tiered Composition: Canonical Recipes
For serious business documents, Rendro ships canonical recipes that follow the Tiered Composition pattern. Each recipe exposes three levels of composability so you can use the zero-to-one batteries-included mode or inject your own branded components as an escape hatch:
document(data, opts)— Batteries-included. Returns a fully assembled%Rendro.Document{}ready forRendro.render/1. Use this for the common case.page_template(opts)— Layout only. Returns the%Rendro.PageTemplate{}with named regions. Use this to substitute your own corporate template.sections(data, opts)— Content only. Returns the list of%Rendro.Section{}structs. Use this to inject the recipe's content into your own document scaffold.
Canonical Invoice Recipe
Rendro.Recipes.Invoice is the reference recipe for a standard business invoice. It demonstrates the three-tier pattern with :header, :body, and :footer regions:
# Zero-to-one: just pass data and render
data = %{
id: "INV-2026-001",
date: ~D[2026-04-30],
items: [
%{name: "Consulting Services", qty: 10, price: 2_500},
%{name: "Support Plan", qty: 1, price: 500}
]
}
doc = Rendro.Recipes.Invoice.document(data)
{:ok, pdf} = Rendro.render(doc)# Escape hatch: inject a custom branded template, keep the recipe's content
template = Rendro.Recipes.Invoice.page_template(name: :branded_invoice)
sections = Rendro.Recipes.Invoice.sections(data)
doc =
Rendro.Document.new()
|> Rendro.Document.add_template(template)
|> Rendro.Document.set_template(:branded_invoice)
|> then(fn d -> Enum.reduce(sections, d, &Rendro.Document.add_section(&2, &1)) end)
{:ok, pdf} = Rendro.render(doc)The delegating alias Rendro.Recipes.invoice/1 calls Rendro.Recipes.Invoice.document/1 for convenience.
Branded Documents
For documents that combine the canonical recipe with a registered brand font and
logo asset, see Rendro.Recipes.BrandedInvoice and the Branding guide.
Usage Reference
Flow API (Verified Examples)
Verified by the README compile/eval lane in mix docs.contract.
# docs-contract: readme-flow-compile
statement_template =
Rendro.page_template(
name: :statement,
width: 420,
height: 595,
margin_top: 24,
margin_right: 24,
margin_bottom: 24,
margin_left: 24,
regions: [
Rendro.region(name: :header, role: :header, anchor: :top, x: 24, y: 24, width: 372, height: 24),
Rendro.region(name: :body, role: :body, anchor: :flow, x: 24, y: 72, width: 180, height: 420),
Rendro.region(name: :footer, role: :footer, anchor: :bottom, x: 24, y: 540, width: 372, height: 18)
]
)
doc =
Rendro.flow(
[
Rendro.block(
Rendro.text(
"Summary\\nThis paragraph preserves explicit newlines, wraps on whitespace, and hard-wraps overlong single tokens grapheme-by-grapheme with no hyphen insertion.",
size: 12,
line_height: 1.4
),
width: 180
)
],
page_template: :statement,
page_templates: [statement_template],
sections: [
Rendro.section(name: :hd, region: :header, content: [Rendro.block(Rendro.text("Account Statement", size: 14))]),
Rendro.section(name: :ft, region: :footer, content: [Rendro.block(Rendro.text("Generated by Rendro", size: 10))])
]
)
{:ok, _pdf} = Rendro.render(doc)Width-constrained flow text is authored on Rendro.block/2, not on Rendro.text/2.
When width is present on the block, Rendro preserves explicit newlines first,
wraps on whitespace second, and falls back to grapheme-by-grapheme hard wraps for
single tokens that exceed the available width. It does not insert hyphens.
Explicit Break Semantics
Verified by the README compile/eval lane in mix docs.contract.
# docs-contract: readme-flow-breaks-compile
doc =
Rendro.flow([
Rendro.block(Rendro.text("Invoice Header", size: 14), keep_with_next: true),
Rendro.block(Rendro.text("Customer Summary", size: 12), keep_with_next: true),
Rendro.block(Rendro.text("Opening paragraph", size: 12), keep_together: true),
Rendro.block(Rendro.text("Appendix", size: 12), break_before: true),
Rendro.block(Rendro.text("Sign-off", size: 12), break_after: true),
Rendro.block(Rendro.text("Next page content", size: 12))
])
{:ok, _pdf} = Rendro.render(doc)keep_together, keep_with_next, break_before, and break_after are the full
public break surface on Rendro.Block. Consecutive keep_with_next
blocks form one contiguous keep group that ends at the first following block
without keep_with_next: true.
When you want asserted output instead of compile-only validation, use the doctest lane:
iex> doc =
...> Rendro.fixed([
...> Rendro.page(blocks: [Rendro.block(Rendro.text("Receipt", size: 12), x: 36, y: 72)])
...> ])
iex> {:ok, pdf} = Rendro.render(doc)
iex> binary_part(pdf, 0, 4)
"%PDF"Tables
Verified by the README compile/eval lane in mix docs.contract.
# docs-contract: readme-table-compile
rows = [
["Item 1", "10", "$100.00"],
["Item 2", "5", "$50.00"]
]
# Explicit column rules are required. Rendro tables do not auto-size to fit content.
table = Rendro.table(rows,
header: ["Description", "Qty", "Price"],
columns: [{:share, 1}, {:fixed, 50}, {:fixed, 80}]
)
doc = Rendro.flow([Rendro.block(table)])
{:ok, _pdf} = Rendro.render(doc)Rendro tables are intentionally narrow and focused on deterministic data reporting:
- Explicit columns: You must provide
columns:with{:fixed, points}or{:share, weight}. There is no content-based auto-sizing. - Atomic rows: Rows do not fragment across pages. If a single row exceeds the available region height, it produces a layout error instead of silently truncating.
- Repeated headers: If a table splits across pages, the
header:row repeats automatically. - Opt-in rules and shading:
borders:,border_style:, andheader_fill:provide deterministic table polish without changing borderless defaults. - No CSS-like styling DSL: There is no per-cell CSS cascade or browser-style layout model.
- No continuation chrome: There are no automatic "continued on next page" labels.
Fixed-Position API
Verified by the README compile/eval lane in mix docs.contract.
# docs-contract: readme-fixed-compile
page = Rendro.page(blocks: [
Rendro.block(Rendro.text("Fixed Position"), x: 100, y: 100)
])
doc = Rendro.fixed([page])
{:ok, _pdf} = Rendro.render(doc)Inspection and Diagnostics
Verified by the README compile/eval lane in mix docs.contract.
When building documents, you may want to inspect the final laid-out structure or
read warnings generated during rendering. Rendro provides
render_with_diagnostics/2 to return the fully populated document struct
alongside the PDF binary, and Rendro.Inspector.inspect/1 to produce a
human-readable layout tree.
# docs-contract: readme-inspector-compile
doc = Rendro.flow([Rendro.block(Rendro.text("Hello World"))])
{:ok, _pdf, final_doc} = Rendro.render_with_diagnostics(doc)
# Print a human-readable tree of pages, blocks, and dimensions
IO.puts(Rendro.Inspector.inspect(final_doc))
# Access structured diagnostics emitted during the pipeline.
# final_doc.diagnostics is a list of structured maps with stable common keys
# such as :level and :type plus event-specific optional fields.
_diagnostics = final_doc.diagnosticsfinal_doc.diagnostics stays map-based. Stable common keys such as :level and
:type are always present, event-specific optional fields may include
:message, :page_index, :reason, and :keep_rule, and additive future keys
are allowed. This surface is intended for developer-facing layout-debug work,
while telemetry remains the operational render-span surface.
Phoenix Integration
Use the Phoenix adapter to serve PDFs from your controllers:
This controller example is schematic and intentionally outside the executable
docs-contract lane because it depends on your application's Phoenix module and
connection setup. See examples/phoenix_example for a fully runnable implementation.
defmodule MyAppWeb.PDFController do
use MyAppWeb, :controller
alias Rendro.Adapters.Phoenix, as: RendroPhoenix
def show(conn, _params) do
data = %{
id: "INV-001",
date: Date.utc_today(),
items: [%{name: "Consulting", qty: 1, price: 1_500}]
}
doc = Rendro.Recipes.Invoice.document(data)
RendroPhoenix.render_pdf(conn, doc, "invoice.pdf")
end
endEcosystem Integrations
Rendro ships optional adapters for threadline (audit logging),
mailglass (transactional email attachments), and accrue (billing
recipes). None of them are hard dependencies of Rendro — each adapter is
compiled only when its target library is present in your application's
own mix.exs.
See guides/integrations.md for setup steps, verification recipes, and failure-diagnostics reference for each adapter.
Policies
Protect your system from expensive render operations:
Verified by the README compile/eval lane in mix docs.contract.
# docs-contract: readme-policies-compile
_doc = Rendro.flow([], options: %{
policies: [
max_pages: 50,
max_bytes: 1_000_000,
timeout: 5_000
]
})Backward Compatibility Note
Earlier versions of Rendro allowed passing header: and footer: as keyword arguments directly to Rendro.flow/2:
# Legacy style — supported for backward compatibility, not recommended for new code
Rendro.flow(
[Rendro.block(Rendro.text("Body content"))],
header: [Rendro.block(Rendro.text("Header"))],
footer: [Rendro.block(Rendro.text("Footer"))]
)This style is still supported for existing code but mixes doc.header block stacking with the region normalization path, which can produce confusing overlap. For all new documents, use explicit %Rendro.Section{} structs mapped to named %Rendro.PageTemplate{} regions as shown in the builder and recipe examples above.




