guides/pdf.md

# PDF Rendering

Accrue renders invoice PDFs from the same `Accrue.Invoices.Components`
that power the transactional emails, via the `Accrue.PDF` behaviour.
The default adapter drives ChromicPDF (headless Chrome) in-process on
the host app. Two alternate adapters ship for test and Chrome-hostile
environments, and the behaviour is open so hosts can add their own
(for example, a Gotenberg sidecar).

If you only read one section: jump to **ChromicPDF setup** for the
production wiring, or **`Accrue.PDF.Null` graceful degradation** if
your deployment target cannot run Chromium.

## Adapters

Three adapters ship with v1.0:

| Adapter | When to use | Returns |
| --- | --- | --- |
| `Accrue.PDF.ChromicPDF` | Production default. Renders HTML → PDF via a host-supervised `ChromicPDF` pool. | `{:ok, pdf_binary}` |
| `Accrue.PDF.Test` | Test env. Sends `{:pdf_rendered, html, opts}` to `self()` and returns a `"%PDF-TEST"` stub. Chrome-free. | `{:ok, "%PDF-TEST"}` |
| `Accrue.PDF.Null` | Chrome-hostile deploys (minimal Alpine, locked-down containers). Returns a typed error without rendering. | `{:error, %Accrue.Error.PdfDisabled{}}` |

The adapter is resolved via `:storage_adapter`'s sibling config key:

```elixir
# config/config.exs
config :accrue, :pdf_adapter, Accrue.PDF.ChromicPDF

# config/test.exs
config :accrue, :pdf_adapter, Accrue.PDF.Test
```

All three adapters implement `@behaviour Accrue.PDF`, so hosts that
need a custom backend can follow the same shape — see **Custom
adapter: Gotenberg sidecar** below.

## ChromicPDF setup

Accrue does **not** start ChromicPDF itself. The host app owns
the supervision tree and supervises the pool. Pick the right shape for
the environment:

```elixir
# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    {Phoenix.PubSub, name: MyApp.PubSub},
    chromic_pdf_child(),
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

# Dev + test: lazy, one-shot browser session per render.
defp chromic_pdf_child do
  if Application.get_env(:my_app, :env) in [:dev, :test] do
    {ChromicPDF, on_demand: true}
  else
    {ChromicPDF, session_pool: [size: 3]}
  end
end
```

### Performance posture — keep Oban concurrency ≤ pool size

ChromicPDF's `session_pool[:size]` caps the number of concurrent
Chromium sessions. If Accrue's `accrue_mailers` Oban queue concurrency
exceeds that cap, workers will block on `:poolboy` checkouts and
silently balloon job runtimes.

**Rule:** the `accrue_mailers` queue concurrency MUST be less
than or equal to the ChromicPDF `session_pool[:size]`. Start at
`session_pool[:size]: 3` and `accrue_mailers: 3`; scale both together.

```elixir
# config/runtime.exs
config :my_app, Oban,
  queues: [
    accrue_webhooks: 10,
    accrue_mailers: 3  # matches ChromicPDF session_pool[:size]
  ]
```

### Docker / container notes

ChromicPDF requires Chrome or Chromium on the host image (Chrome ≥ 91
for full-page screenshot features; core rendering works on older).
For PDF/A archival output, Ghostscript is additionally required.
On Alpine, install `chromium` and `ghostscript` in the image; on
Debian-slim, `chromium` + `fonts-liberation` gets you sane defaults.

If your target image cannot ship Chromium (smallest Alpine,
distroless, some serverless platforms), use `Accrue.PDF.Null` and
fall back to the Stripe-hosted invoice URL path described below.

## `Accrue.PDF.Null` graceful degradation {#null-adapter}

`Accrue.PDF.Null` is the escape hatch for Chrome-hostile deploys.
It implements `@behaviour Accrue.PDF` but never renders:

```elixir
iex> Accrue.PDF.render("<html/>", [])
{:error, %Accrue.Error.PdfDisabled{reason: :adapter_disabled, docs_url: "..."}}
```

### How the invoice email worker handles it

The invoice email worker (`Accrue.Workers.Mailer` with
`Accrue.Emails.InvoicePaid`) pattern-matches on the tagged error and
falls through to appending the Stripe `hosted_invoice_url` as a link
in the email body instead of attaching a rendered binary:

```elixir
case Accrue.PDF.Invoice.render(invoice_id) do
  {:ok, pdf_binary} ->
    email
    |> Swoosh.Email.attachment(
      Swoosh.Attachment.new(
        {:data, pdf_binary},
        filename: "invoice-#{invoice.number}.pdf",
        content_type: "application/pdf"
      )
    )

  {:error, %Accrue.Error.PdfDisabled{}} ->
    # Expected, terminal — NOT a transient retry. Log at :debug,
    # attach the hosted link instead.
    Swoosh.Email.assign(email, :invoice_link, invoice.hosted_invoice_url)
end
```

The adapter logs the skip at `:debug` only. Oban workers must NOT
treat `%Accrue.Error.PdfDisabled{}` as a transient failure — it is
stable configuration, not an outage.

## Custom adapter: Gotenberg sidecar

When ChromicPDF is not viable (no Chromium in the image, locked-down
container, hard size budget), the idiomatic alternative is to run
[Gotenberg](https://gotenberg.dev) as a sidecar service and POST HTML
to its REST API from a custom adapter. The following example is
illustrative — **Gotenberg is not a first-party adapter in v1.0**.
Copy-paste, adjust to your HTTP client and endpoint shape, and point
`:pdf_adapter` at your module.

```elixir
defmodule MyApp.PDF.Gotenberg do
  @moduledoc """
  Illustrative, not first-party. `@behaviour Accrue.PDF` adapter that
  POSTs HTML to a Gotenberg sidecar and returns the rendered PDF
  binary. Useful when the host image cannot ship Chromium.
  """

  @behaviour Accrue.PDF

  @finch MyApp.Finch
  @endpoint "http://gotenberg:3000/forms/chromium/convert/html"

  @impl true
  def render(html, opts) when is_binary(html) and is_list(opts) do
    boundary = "gotenberg-#{System.unique_integer([:positive])}"

    body =
      [
        {"files", html, {"form-data", [{"name", "index.html"}, {"filename", "index.html"}]},
         [{"content-type", "text/html"}]}
      ]
      |> multipart(boundary)

    headers = [{"content-type", "multipart/form-data; boundary=#{boundary}"}]

    case Finch.build(:post, @endpoint, headers, body) |> Finch.request(@finch) do
      {:ok, %{status: 200, body: pdf}} -> {:ok, pdf}
      {:ok, %{status: status, body: err}} -> {:error, {:gotenberg, status, err}}
      {:error, reason} -> {:error, {:gotenberg_transport, reason}}
    end
  end

  defp multipart(_parts, _boundary), do: "..." # host-specific encoding
end
```

Wire it in:

```elixir
# config/runtime.exs
config :accrue, :pdf_adapter, MyApp.PDF.Gotenberg
```

When to choose Gotenberg over ChromicPDF:

- Host image cannot bundle Chromium (smallest Alpine, distroless).
- Locked-down containers that forbid `execve` of subprocess browsers.
- A central PDF service already exists in the fleet.
- You want horizontal PDF rendering separated from BEAM capacity.

When to stay on ChromicPDF:

- Standard Phoenix deployments with control over the base image.
- Single-node or small-fleet SaaS where the sidecar cost is pure
  overhead.
- Latency-sensitive renders (ChromicPDF persistent pool ≈ 50ms;
  Gotenberg adds a network hop).

## `@page` CSS warning (Pitfall 6)

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