# Rendro
[](https://github.com/szTheory/rendro/actions/workflows/ci.yml)
[](https://hex.pm/packages/rendro)
[](https://hexdocs.pm/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.Document` API, mirroring `Plug.Conn` ergonomics.
- **Tiered Composition:** Canonical recipes expose `document/2`, `page_template/1`, and `sections/2` for 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.
```elixir-schematic
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 for `Rendro.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:
```elixir-schematic
# 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)
```
```elixir-schematic
# 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](guides/branding.md).
## Usage Reference
### Flow API (Verified Examples)
Verified by the README compile/eval lane in `mix docs.contract`.
```elixir
# 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`.
```elixir
# 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
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`.
```elixir
# 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`.
```elixir
# 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.
```elixir
# 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.
```elixir-schematic
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](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`.
```elixir
# 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`:
```elixir-schematic
# 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.