Packages

Pure-Elixir, Phoenix-first PDF/document generation with deterministic layout and pagination

Current section

Files

Jump to
rendro README.md
Raw

README.md

# Rendro
[![CI](https://img.shields.io/github/check-runs/szTheory/rendro/main?name=ci-success)](https://github.com/szTheory/rendro/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/rendro.svg)](https://hex.pm/packages/rendro)
[![HexDocs](https://img.shields.io/badge/hex--docs-2C6BED.svg)](https://hexdocs.pm/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`, and `sections/2` escape 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](https://hexdocs.pm/rendro).
### Install
Add Rendro to your Phoenix application's `mix.exs` and fetch dependencies:
```elixir-schematic
{:rendro, "~> 1.3"}
```
### Select
Start with **Invoice / Swiss / `#2C6BED` / light**. Browse the bounded choices
in the [preset guide](guides/presets.md) or [static configurator](assets/rendro/configurator/index.html).
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](examples/phoenix_example/README.md)
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](guides/presets.md), [browse and
copy a static configurator selection](assets/rendro/configurator/index.html),
or [render an invoice in Livebook](guides/livebook/first_invoice.livemd).
<!-- rendro-launch-artifacts-start -->
## 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.
<p>
<a href="assets/rendro/gallery/invoice.png"><img src="assets/rendro/gallery/invoice.png" alt="Rendered invoice PDF showing invoice header, line-item table, and thank-you footer." width="150"></a>
<a href="assets/rendro/gallery/branded_invoice.png"><img src="assets/rendro/gallery/branded_invoice.png" alt="Rendered branded invoice PDF showing Rendro logo, embedded brand font, and invoice table." width="150"></a>
<a href="assets/rendro/gallery/statement.png"><img src="assets/rendro/gallery/statement.png" alt="Rendered account statement PDF showing dated transaction rows, signed amounts, and a page-numbered footer." width="150"></a>
<a href="assets/rendro/gallery/receipt_report.png"><img src="assets/rendro/gallery/receipt_report.png" alt="Rendered sales receipt PDF showing itemized line items with a subtotal, tax, and total." width="150"></a>
<a href="assets/rendro/gallery/certificate.png"><img src="assets/rendro/gallery/certificate.png" alt="Rendered landscape certificate PDF showing recipient text and geometry-derived keyline border." width="150"></a>
<a href="assets/rendro/gallery/payslip.png"><img src="assets/rendro/gallery/payslip.png" alt="Rendered payslip PDF showing employer and employee details, earnings and deductions, and the net pay figure." width="150"></a>
<a href="assets/rendro/gallery/ticket.png"><img src="assets/rendro/gallery/ticket.png" alt="Rendered event ticket PDF showing the event title, seat placement grid, and a human-readable reference code." width="150"></a>
<a href="assets/rendro/gallery/invoice_dark.png"><img src="assets/rendro/gallery/invoice_dark.png" alt="Rendered invoice PDF in dark mode, showing the themed dark background applied to the header, line-item table, and thank-you footer. Dark mode is screen-oriented, not recommended for print." width="150"></a>
<a href="assets/rendro/gallery/invoice_brand.png"><img src="assets/rendro/gallery/invoice_brand.png" alt="Rendered invoice PDF themed with a teal brand accent color via from_brand, showing the accent applied to the dominant Total Due figure. No logo or brand font assets are used." width="150"></a>
</p>
Self-rendered manual: [manual.pdf](assets/rendro/manual.pdf)
SHA-256: `0597045e4410719efe6db27005245b2a35544282e765629d7b6f667c7c68020b`
<!-- rendro-launch-artifacts-end -->
## Guides
- [Choose a preset direction](guides/presets.md) — compare six neutral document directions, then follow the adoption route that fits your job.
- [Static configurator](assets/rendro/configurator/index.html) — browse bounded catalog evidence and copy the requested selection's canonical Elixir.
- [User Flows and Jobs To Be Done](guides/user_flows_and_jtbd.md) — the shortest path to understanding which Rendro workflow fits your SaaS use case.
- [Branding](guides/branding.md) — register fonts and logo assets, then use the branded invoice recipe.
- [Integrations](guides/integrations.md) — optional adapters for Phoenix ecosystem workflows such as Oban, Threadline, Mailglass, and signing.
- [Generating PDFs in Elixir without Chrome](guides/comparison.md) — an evidence-backed comparison guide for Rendro, ChromicPDF, pdf_generator, and Typst CLI.
- [First Invoice Livebook](guides/livebook/first_invoice.livemd) — a zero-friction tutorial for rendering and downloading a deterministic invoice PDF.
- [API Stability and Support Boundaries](guides/api_stability.md) — the canonical support language for trust-sensitive and proof-backed surfaces.
- [Adoption Signals](ADOPTION.md) - 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.
```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)
```
`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](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.
- **Opt-in rules and shading:** `borders:`, `border_style:`, and `header_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`.
```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.
## Brand
Rendro's brand system — logo, design tokens, typography, voice, and copy — lives in [`brand/`](https://github.com/szTheory/rendro/tree/main/brand). Open [`brand/index.html`](https://github.com/szTheory/rendro/blob/main/brand/index.html) locally for the full brand book, or start with the [kit index](https://github.com/szTheory/rendro/blob/main/brand/README.md) and the [logo files](https://github.com/szTheory/rendro/tree/main/brand/logo). Brand assets are source-controlled but excluded from the published Hex package.