Rendro
Pure-Elixir PDF generation with deterministic layout and pagination.
Features
- Pure Elixir: No external dependencies like headless Chrome or wkhtmltopdf.
- Deterministic: Same input produces the same binary output (ID, timestamps, dictionary order).
- Builder API: Compose documents via a pipeable
Rendro.DocumentAPI, mirroringPlug.Connergonomics. - Tiered Composition: Canonical recipes expose
document/2,page_template/1, andsections/2for zero-to-one and advanced escape-hatch use. - Production-Ready: Built-in telemetry, structured diagnostics, and policies.
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.
- API Stability and Support Boundaries — the canonical support language for trust-sensitive and proof-backed surfaces.
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. - No styling DSL: There is no border, shading, or CSS-like styling DSL on the table struct itself.
- 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.diagnostics
final_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
end
Ecosystem 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.