Rendro

CIHex.pmHexDocs

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

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.

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.

Rendered invoice PDF showing invoice header, line-item table, and thank-you footer.Rendered branded invoice PDF showing Rendro logo, embedded brand font, and invoice table.Rendered account statement PDF showing dated transaction rows, signed amounts, and a page-numbered footer.Rendered sales receipt PDF showing itemized line items with a subtotal, tax, and total.Rendered landscape certificate PDF showing recipient text and geometry-derived keyline border.Rendered payslip PDF showing employer and employee details, earnings and deductions, and the net pay figure.Rendered event ticket PDF showing the event title, seat placement grid, and a human-readable reference code.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.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.

Self-rendered manual: manual.pdf

SHA-256: 0597045e4410719efe6db27005245b2a35544282e765629d7b6f667c7c68020b

Guides

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:

FunctionPurpose
Rendro.Document.new/0Create an empty %Rendro.Document{}
Rendro.Document.new/1Create a document from keyword options
Rendro.Document.add_template/2Append a %Rendro.PageTemplate{}
Rendro.Document.set_template/2Set the active template by name
Rendro.Document.add_section/2Append a %Rendro.Section{} to the document
Rendro.Document.put_metadata/2Replace document metadata
Rendro.Document.put_options/2Merge 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:

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:

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.