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