Rendro
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, certificates, payslips, and tickets. - Operational proof: Telemetry, diagnostics, support boundaries, and docs-contract checks keep public claims auditable.
Phoenix first invoice
Copy the theme source, build the invoice document, then serve it through the Phoenix adapter. This short route uses the public package; for integration and API depth, see HexDocs.
Install
Add Rendro to your Phoenix application's mix.exs and fetch dependencies:
{:rendro, "~> 1.3"}
Select
Start with Invoice / Swiss / #2C6BED / light. Browse the bounded choices
in the preset guide or static configurator.
Light is the print-oriented first path; dark output remains screen-oriented and
is not a print, accessibility, PDF/UA, or WCAG claim.
Customize
Use the formatter-owned source from the preset guide or configurator for theme construction and explicit font registration. Those generated sources are the authority; this README deliberately does not duplicate the theme snippet.
Serve
Keep invoice mapping and document construction in your application, then hand
the completed document to Rendro.Adapters.Phoenix.render_pdf/3 from a thin
controller. The adapter owns the PDF response headers and error boundary.
Verify
Request the controller route and verify an HTTP 200 PDF response whose body
begins %PDF-. See the Phoenix example README
for the broader runnable reference, not clean-room authority.
Choose a preset
Curated presets are strong starting points; adjust them for your document and review the result. Choose a preset direction, browse and copy a static configurator selection, or render an invoice in Livebook.
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: 0597045e4410719efe6db27005245b2a35544282e765629d7b6f667c7c68020b
Guides
- Choose a preset direction — compare six neutral document directions, then follow the adoption route that fits your job.
- Static configurator — browse bounded catalog evidence and copy the requested selection's canonical Elixir.
- 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 global 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)
Rendro.Recipes.invoice/1 (and /2) delegates to Rendro.Recipes.Invoice.document/2, threading opts through.
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.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.
Brand
Rendro's brand system — logo, design tokens, typography, voice, and copy — lives in brand/. Open brand/index.html locally for the full brand book, or start with the kit index and the logo files. Brand assets are source-controlled but excluded from the published Hex package.