guides/pdf.md

# PDF Rendering

Accrue renders invoice PDFs from the same `Accrue.Invoices.Components`
that power the transactional emails, but the default invoice renderer is now
native Rendro rather than Chrome. The invoice entry point is
`Accrue.Invoices.render_invoice_pdf/2`, which resolves `:invoice_pdf_adapter`
and renders a PDF without requiring a browser process by default.

The older `Accrue.PDF` behaviour still exists for HTML-to-PDF adapters such as
ChromicPDF or a custom Gotenberg sidecar, but it is no longer the primary
invoice path.

If you only read one section: Rendro is the default. Jump to
**ChromicPDF explicit compatibility path** only if you explicitly want the
old HTML-based path.

## Adapters

Invoice rendering ships with three first-party adapters:

| Adapter | When to use | Returns |
| --- | --- | --- |
| `Accrue.InvoiceRenderer.Rendro` | Production default. Native Elixir invoice PDF rendering with no Chrome dependency. | `{:ok, pdf_binary}` |
| `Accrue.InvoiceRenderer.ChromicPDF` | Optional fallback. Preserves the older HTML → Chrome path via a host-supervised `ChromicPDF` pool. | `{:ok, pdf_binary}` |
| `Accrue.InvoiceRenderer.Null` | PDF-disabled / Chrome-hostile deploys. Returns a typed error without rendering. | `{:error, %Accrue.Error.PdfDisabled{}}` |

The invoice renderer is resolved via `:invoice_pdf_adapter`:

```elixir
# config/config.exs
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.Rendro

# config/test.exs
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.Test
```

If you still need the lower-level HTML seam, `:pdf_adapter` continues to
configure `Accrue.PDF` for ChromicPDF/custom HTML renderers.

## Rendro default

The default path needs no extra supervisor child and no Chrome/Chromium
binary on the host image. That keeps the default install/setup smaller and
easier to maintain.

The main tradeoff is honesty about assets and fonts:

- remote `logo_url` fetching is not part of the Rendro default path
- unsupported glyphs fail explicitly instead of silently degrading
- lazy render semantics are unchanged; Accrue still re-renders from current
  invoice data unless you explicitly store the bytes yourself

## ChromicPDF explicit compatibility path

If you want the previous HTML-based invoice rendering path, switch:

```elixir
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.ChromicPDF
```

Accrue still does **not** start ChromicPDF itself. The host app owns the
supervision tree and supervises the pool.

This is an explicit compatibility path. Invoice rendering only switches when
you set `:invoice_pdf_adapter`; Accrue does not infer invoice behavior from
the lower-level `:pdf_adapter` HTML seam.

```elixir
# lib/my_app/application.ex
children = [
  MyApp.Repo,
  {ChromicPDF, on_demand: true},
  MyAppWeb.Endpoint
]
```

Keep `accrue_mailers` queue concurrency less than or equal to the
ChromicPDF pool size if you attach invoice PDFs from mailer jobs.

If this explicit compatibility path is configured without a running
`ChromicPDF` process, `Accrue.Invoices.render_invoice_pdf/2` returns
`{:error, %Accrue.Error.InvoiceRendererUnavailable{adapter: Accrue.InvoiceRenderer.ChromicPDF, reason: :chromic_pdf_not_started}}`.

## Migration

The seam split is explicit:

- `:invoice_pdf_adapter` owns invoice rendering.
- `:pdf_adapter` remains the lower-level `Accrue.PDF` HTML seam.

Invoice rendering does not infer behavior from `:pdf_adapter`. If you are
upgrading, use the host state that matches your app:

### 1. No custom PDF config

If you never customized Accrue's PDF settings, **no action needed**.
Rendro is now the default invoice renderer, so invoice PDFs render without
Chrome on the normal path.

### 2. You only set `:pdf_adapter`

If your host only set `config :accrue, :pdf_adapter, ...`, invoice PDFs no
longer follow that key. Set `:invoice_pdf_adapter` explicitly if you want the
legacy Chrome-backed invoice path:

```elixir
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.ChromicPDF
```

Keep `:pdf_adapter` only if you also still use the lower-level HTML seam.

### 3. You use a custom HTML seam

If your host uses a custom `Accrue.PDF` adapter, keep `:pdf_adapter` for that
HTML seam. Invoice rendering remains on the default Rendro path unless you
also set `:invoice_pdf_adapter` explicitly.

That means a host can keep a custom HTML renderer for non-invoice callers
without changing invoice behavior, and a host can choose
`Accrue.InvoiceRenderer.ChromicPDF` only when it intentionally wants the old
invoice path back.

## Null graceful degradation {#null-adapter}

`Accrue.InvoiceRenderer.Null` is the escape hatch for PDF-disabled deploys.
It returns a typed error without rendering:

```elixir
iex> Accrue.Invoices.render_invoice_pdf(invoice)
{:error, %Accrue.Error.PdfDisabled{reason: :adapter_disabled, docs_url: "..."}}
```

The mailer path treats `%Accrue.Error.PdfDisabled{}` as terminal and falls
through to the hosted invoice URL instead of retrying forever.

## `@page` CSS warning (Chromic fallback only)

ChromicPDF does **not** interpret `@page` CSS rules. Setting page
size, margins, or paper dimensions via a stylesheet has no effect —
the output will silently use ChromicPDF's defaults.

**Wrong:**

```css
/* Ignored by ChromicPDF — do NOT do this. */
@page {
  size: A4 portrait;
  margin: 20mm 15mm;
}
```

**Right:** pass paper options through the adapter `opts`:

```elixir
Accrue.PDF.render(html,
  size: :a4,
  paper_width: 8.27,
  paper_height: 11.69,
  margin_top: 0.5,
  margin_bottom: 0.5,
  margin_left: 0.4,
  margin_right: 0.4
)
```

For explicit page breaks inside content, use the CSS `page-break-*`
properties (`page-break-before: always;` works as expected inside the
printed document, just not `@page` at the top level).

## Font strategy

Webfonts via `<link rel="stylesheet" href="https://fonts.googleapis.com/...">`
are unreliable under headless Chromium — the fetch may race the render
deadline. The recommended pattern is to base64-embed the font bytes
directly into the HTML via `@font-face` `src: url(data:...)`:

```html
<style>
  @font-face {
    font-family: "Inter";
    font-weight: 400;
    src: url(data:font/woff2;base64,d09GMgABAAAAA...) format("woff2");
  }
  body { font-family: "Inter", sans-serif; }
</style>
```

Keep the embedded font files small — a single weight is usually
enough for an invoice. If you do not need a custom typeface, the
default `Accrue.Config.branding/0` `:font_stack`
(`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`)
renders cleanly on every platform Chromium ships on, with zero
embedding overhead. That is the recommended default for v1.0.

## See also

- `Accrue.PDF` — behaviour + facade module docs
- `Accrue.PDF.ChromicPDF` — production adapter
- `Accrue.PDF.Null` — disabled adapter
- `Accrue.Error.PdfDisabled` — tagged error returned by `Null`
- `Accrue.Storage` — storage behaviour for persisted PDFs