README.md

# Rendro

[![CI](https://github.com/szTheory/rendro/actions/workflows/ci.yml/badge.svg)](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-purple.svg)](https://hexdocs.pm/rendro)

Pure-Elixir PDF generation with deterministic layout and pagination.

## Features

- **Pure Elixir:** No external dependencies like headless Chrome or wkhtmltopdf.
- **Deterministic:** Same input produces the same binary output (ID, timestamps, dictionary order).
- **Builder API:** Compose documents via a pipeable `Rendro.Document` API, mirroring `Plug.Conn` ergonomics.
- **Tiered Composition:** Canonical recipes expose `document/2`, `page_template/1`, and `sections/2` for zero-to-one and advanced escape-hatch use.
- **Production-Ready:** Built-in telemetry, structured diagnostics, and policies.

## 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)
```

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](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.
- **No styling DSL:** There is no border, shading, or CSS-like styling DSL on the table struct itself.
- **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.