Rendro

CIHex.pmHexDocs

Pure-Elixir PDF generation with deterministic layout and pagination.

Features

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:

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:

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.