CI Hex.pm HexDocs

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, and certificates.
  • Operational proof: Telemetry, diagnostics, support boundaries, and docs-contract checks keep public claims auditable.

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 transaction rows, running balances, and Page 1 of 2 footer. Rendered receipt report PDF showing repeated table header, line items, totals, and Page 1 of 2 footer. Rendered landscape certificate PDF showing recipient text and geometry-derived keyline border.

Self-rendered manual: manual.pdf

SHA-256: a9f1a241c3fb331ad5522d905af8acf26d9848b8862cb9a6f3e4033c3ee1dc94

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:

  • 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:

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

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