# Printing

This page covers building PDF documents with `@nxgt/material/print`, turning
them into a download, a new tab or a server response in one call, and
shipping them from a browser or from a server bundle that must not crash.

```bash
bun add @react-pdf/renderer@^4.9 react-pdf-tailwind
```

Both are **optional peers**: only the app that prints installs them. Since
2.2.0 the renderer's floor is 4.9.0; an older copy already in your lockfile
makes the install complain (see
[troubleshooting](../troubleshooting.md#conflicting-peer-dependency-react-pdfrenderer)).

```tsx
import {
  formatAmount,
  formatDate,
  PrintDocument,
  PrintHeader,
  PrintInfoRow,
  PrintSection,
  PrintTable,
  PrintTableCell,
  PrintTableHeader,
  PrintTableRow,
  PrintTotalRow,
  PrintTotals,
} from '@nxgt/material/print';

type Line = { label: string; quantity: number; unitPrice: number };
type Invoice = { number: string; issuedAt: string; customer: string; currency: string; lines: Line[] };

export function InvoiceDocument({ invoice, language }: { invoice: Invoice; language?: string }) {
  const total = invoice.lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0);

  return (
    <PrintDocument
      size="A4"
      title={`Invoice ${invoice.number}`}
      language={language}
      pageNumbers
      header={<PrintHeader title="Invoice" reference={invoice.number} logoText="ACME" />}
    >
      <PrintSection title="Details">
        <PrintInfoRow label="Issued" value={formatDate(invoice.issuedAt)} />
        <PrintInfoRow label="Customer" value={invoice.customer} />
      </PrintSection>
      <PrintTable>
        <PrintTableHeader>
          <PrintTableCell isHeader flex={3}>Item</PrintTableCell>
          <PrintTableCell isHeader textAlign="right">Qty</PrintTableCell>
          <PrintTableCell isHeader textAlign="right">Amount</PrintTableCell>
        </PrintTableHeader>
        {invoice.lines.map((line, index) => (
          <PrintTableRow key={line.label} isAlt={index % 2 === 1}>
            <PrintTableCell flex={3}>{line.label}</PrintTableCell>
            <PrintTableCell textAlign="right">{line.quantity}</PrintTableCell>
            <PrintTableCell textAlign="right">
              {formatAmount(line.quantity * line.unitPrice, invoice.currency)}
            </PrintTableCell>
          </PrintTableRow>
        ))}
      </PrintTable>
      <PrintTotals>
        <PrintTotalRow label="Total" value={formatAmount(total, invoice.currency)} last />
      </PrintTotals>
    </PrintDocument>
  );
}
```

The lines, the details and the total can also come from column definitions
instead of cells written by hand: see
[Tables from columns and rows](#tables-from-columns-and-rows). To save it,
open it or return it from a server route, pass it to `downloadPdf`, `openPdf`
or `pdfResponse` ([Rendering the document](#rendering-the-document)); to
write the same document as plain data instead of JSX, see
[Documents as data](#documents-as-data).

## The page shell: `PrintDocument`

`PrintDocument` takes the place of react-pdf's `Document` and `Page`: hand it
to [`downloadPdf`, `pdfResponse`](#rendering-the-document) and the other
output functions, or to `pdf()`, `renderToBuffer()` or `PDFViewer` the same way. Its children flow
over as many pages as they need, and what should appear on every page is a
prop.

```tsx
import { PrintDocument, PrintHeader } from '@nxgt/material/print';

<PrintDocument
  size="A4"
  orientation="portrait"
  margins={{ top: 36, x: 32 }}
  title="Invoice INV-42"
  author="ACME"
  language="fr"
  header={<PrintHeader title="Facture" reference="INV-42" logo={logoDataUrl} />}
  footer={({ pageNumber, totalPages }) => `INV-42 · ${pageNumber}/${totalPages}`}
  pageNumbers={{ align: 'right' }}
  watermark="BROUILLON"
>
  {/* the body */}
</PrintDocument>
```

| Prop | Default | Effect |
| --- | --- | --- |
| `size` | `'A4'` | `'A3'`, `'A4'`, `'A5'`, `'LETTER'`, `'LEGAL'`, or `[width, height]` in points. |
| `orientation` | `'portrait'` | `'landscape'` swaps width and height. |
| `margins` | `{ top: 21, right: 24, bottom: 21, left: 24 }` | Points: one number for every side, or `top` / `right` / `bottom` / `left` / `x` / `y` (a side wins over its axis). |
| `header` | — | Repeated at the top of every page, in the flow, so the body starts below it on each page. |
| `footer` | — | Repeated at the bottom of every page, in a band of `footerHeight` points above the bottom margin. A function returns the text for each page (see below). |
| `footerHeight` | `24` | Height of that band. Raise it for a footer of several lines. |
| `pageNumbers` | off | `true` prints "Page 2 of 5" centred in the footer band, in the document's language. `{ align: 'left' \| 'center' \| 'right', format: 'page-of' \| 'page' }`; `'page'` prints "Page 2". |
| `watermark` | — | Large rotated text behind the content: a string, or `{ text, opacity (0.08), angle (35), color, fontSize (72) }`. |
| `title`, `author`, `subject`, `keywords` | — | Document information, as PDF readers show it. |
| `creator` | `'@nxgt/material'` | The application that made the file. |
| `producer` | `creator` | The software that wrote the PDF. |
| `language` | the `I18nProvider` around the document, then `'en'` | The built-in strings ("Page 2 of 5") and the PDF's language. |
| `locale`, `timeZone` | `language`, the runtime's | How the data blocks format amounts and dates. |

Nothing but `children` is required: `<PrintDocument>{…}</PrintDocument>` is an
A4 page with the margins the blocks were designed for.

### Translating built-in strings in a PDF

The strings `PrintDocument` and `PrintDataTable` print themselves come from
this package's `material.print.*` keys, in English and French. `pdf()`,
`renderToBuffer()` and the [output functions](#rendering-the-document) render
a tree of their own, so **your app's `I18nProvider` does not reach the
document**. Give the language one of two ways:

```tsx
import { pdf } from '@react-pdf/renderer';
import { I18nProvider } from '@nxgt/material/i18n';

// a prop: works the same on a server
await pdf(<InvoiceDocument invoice={invoice} language="fr" />).toBlob();

// or a provider inside the pdf() call
await pdf(
  <I18nProvider initialLanguage="fr">
    <InvoiceDocument invoice={invoice} />
  </I18nProvider>,
).toBlob();
```

(`InvoiceDocument` passes its `language` on to `PrintDocument`.) A language
the bundle does not have prints the English text; a regional tag reads its
language (`'fr-CA'` prints French). To translate one of the package's keys
yourself outside a component, `translateMaterial(language, key, fallback,
values)` from `@nxgt/material/i18n` does what `useMaterialTranslation()` does,
without a hook.

### Headers and footers

`header` and a `footer` element are rendered once and repeated on each page;
they can hold any print block, or be a plain string (printed as text, since
react-pdf drops a string outside `<Text>`). A **`footer` function** is different: react-pdf
calls it for each page while it lays the pages out, outside React. It returns
text, and it cannot call a hook (`Invalid hook call`): compute what it needs in
your component and use it from the function.

```tsx
function InvoiceDocument({ invoice }: { invoice: Invoice }) {
  const issued = formatDate(invoice.issuedAt, { locale: 'fr-FR' }); // here, not in the footer
  return (
    <PrintDocument footer={({ pageNumber, totalPages }) => `${invoice.number} · ${issued} · ${pageNumber}/${totalPages}`}>
      {/* … */}
    </PrintDocument>
  );
}
```

The footer and the page number share one row in that band, so they never
draw over each other: the footer takes the free width and the number follows
it, or comes first with `pageNumbers={{ align: 'left' }}`. Alone, the number
is placed by `align` (centred by default). A footer taller than
`footerHeight` runs up into the body; raise `footerHeight` for a footer of
several lines.

## The building blocks

These render `@react-pdf/renderer` primitives (`View`, `Text`), not DOM, so
they only work inside a `PrintDocument` (or a react-pdf `<Document>`).
`PrintDataTable`, `PrintKeyValue` and `PrintMetric` take the language and
locale of the `PrintDocument` around them; under a plain `<Document>` they
print English strings ("Total", "No rows") and `en` amounts and dates.

| Component | Props | Renders |
| --- | --- | --- |
| `PrintHeader` | `title: string`, `subtitle?: string`, `reference?: string`, `referencePrefix?: string` (`'#'`), `logo?: string \| { src, width?, height? }`, `logoText?: string`, `qrDataUrl?: string \| null` | The title block: the logo (a PNG or JPEG URL or data URL, 72×48 pt unless you size it) or `logoText` in a box on the left, the title, subtitle and `#reference` centred, the QR image on the right. A side you leave empty draws nothing, and the title stays centred |
| `PrintSection` | `title: string`, `className?: string`, `children` | A titled block (`className` is a `tw` string, default `mb-3.5`) |
| `PrintInfoRow` | `label: string`, `value?: ReactNode` | A label / value line. A `string` or `number` value is wrapped in `Text`; anything else is rendered as-is |
| `PrintCard` | `className?: string`, `children` | A bordered row of fields |
| `PrintCardField` | `label: string`, `value?: string \| null` | A label over a value. **Renders nothing when `value` is empty** |
| `PrintTable`, `PrintTableHeader` | `children` | Table section and its header row |
| `PrintTableRow` | `isAlt?: boolean`, `children` | A row; `isAlt` shades it |
| `PrintTableCell` | `isHeader?: boolean`, `flex?: number` (`1`), `textAlign?: 'left' \| 'right' \| 'center'` (`'left'`) | A cell. Empty `children` print `—` |
| `PrintTotals`, `PrintTotalRow` | `label: string`, `value: string`, `last?: boolean` | The totals block; `last` is the bold grand-total row |
| `PrintBadge` | `color?: string`, `className?: string`, `children` | A pill; `color` is a CSS colour for its background |
| `PrintDataTable` | `columns`, `rows`, `currency?`, `totalLabel?`, `striped?`, `repeatHeader?`, `keepRowsWhole?`, `emptyText?`, `hideWhenEmpty?` | A whole table from column definitions: header repeated on each page, rows kept whole, a totals row. See [Tables from columns and rows](#tables-from-columns-and-rows) |
| `PrintKeyValue` | `items`, or `data` + `fields` (+ `currency?`); `columns?: 1 \| 2`, `keepEmpty?` | Label and value pairs in one or two columns; empty values skipped. See [`PrintKeyValue`](#printkeyvalue-one-record-as-label-and-value-pairs) |
| `PrintMetric`, `PrintMetrics` | `label`, `value`, `format?`, `currency?`, `hint?`, `delta?` | One figure with its label, hint and change; `PrintMetrics` lines several up. See [`PrintMetric`](#printmetric-and-printmetrics-figures) |

**Changed in 2.2.0:** `PrintHeader` no longer draws a grey `LOGO`
placeholder when you pass neither `logo` nor `logoText`, nor an empty box
where the QR code goes when you pass no `qrDataUrl`. Pass `logoText="LOGO"` to
keep the placeholder.

`tw` is a `react-pdf-tailwind` instance for your own `style`s, with the colours
the components use (`primary`, `secondary`, `muted`, `border`, `headerBg`,
`headerText`, `rowAlt`, `white`, `logoBg`, `qrBg`) and pdfkit's built-in fonts
(`font-sans` = Helvetica, `font-serif` = Times-Roman, `font-mono` = Courier).
`commonPrintStyles` holds the `tw` class strings the components are built
from, so your own blocks can match them.

### `formatAmount` and `formatDate`

```ts
function formatAmount(
  value: number | null | undefined,
  currency?: string | null,
  options?: { locale?: string; fractionDigits?: number },
): string;
function formatDate(
  value: string | number | Date | null | undefined,
  options?: { locale?: string; timeZone?: string; dateStyle?: 'full' | 'long' | 'medium' | 'short'; timeStyle?: 'full' | 'long' | 'medium' | 'short' },
): string;
```

```ts
import { formatAmount, formatDate } from '@nxgt/material/lib';

formatAmount(1234.5, 'EUR', { locale: 'fr-FR' });   // '1 234,50 €'
formatAmount(1234.5, 'JPY', { locale: 'en-US' });   // '¥1,235' (the yen has no decimals)
formatAmount(1.5, 'KWD', { locale: 'en-US' });      // 'KWD 1.500' (three)
formatAmount(1234.5, 'JPY', { fractionDigits: 2 }); // '¥1,234.50', two decimals whatever the currency
formatAmount(1234.5);                               // '1,234.50' (no currency: two decimals)
formatAmount(null);                                 // '—'
formatAmount(1234.5, 'EURO');                       // '1234.50 EURO' (malformed currency code)

formatDate('2026-03-01', { locale: 'en-US' });      // 'Mar 1, 2026', in every time zone
formatDate('2026-03-01T23:30:00Z', { locale: 'fr-FR', timeZone: 'Europe/Paris' }); // '2 mars 2026'
formatDate(new Date(), { timeStyle: 'short' });     // date and time
formatDate(undefined);                              // '—'
formatDate('not a date');                           // 'not a date'
```

| Option | Default | Effect |
| --- | --- | --- |
| `locale` | the runtime's locale | BCP 47 tag (`'fr-FR'`), for both functions. |
| `fractionDigits` | the currency's own (ISO 4217), or 2 without a currency | `formatAmount` decimals, clamped to 0–20 (the range every `Intl` accepts; engines before ES2023 throw above 20). `NaN` or `Infinity` counts as not given, as in `CurrencyField`. Pass `2` to keep the pre-2.2.0 output. |
| `timeZone` | the runtime's time zone | `formatDate`, for values with a time. A date-only string (`YYYY-MM-DD`) always keeps its calendar day. |
| `dateStyle` / `timeStyle` | `'medium'` / none | `formatDate` styles; `timeStyle` is ignored for a date-only value. |

They are pure `Intl` helpers, also exported from `@nxgt/material` and
`@nxgt/material/lib`, so a screen that formats amounts does not need this
entry.

**Server rendering.** Without `locale` and `timeZone` they use the runtime's,
so a server and a French browser print different strings. Pass both, or bind
them once:

```ts
// a server route, or any code outside React
import { createFormatters } from '@nxgt/material/lib';

const { formatAmount, formatDate } = createFormatters({ locale: 'fr-FR', timeZone: 'Europe/Paris' });
formatAmount(1234.5, 'EUR'); // '1 234,50 €'
```

```tsx
// a component: bound to the I18nProvider language ('en' without a provider)
import { useFormatters } from '@nxgt/material/hooks';

function InvoiceTotal({ total, issuedAt }: { total: number; issuedAt: string }) {
  const { formatAmount, formatDate } = useFormatters({ timeZone: 'Europe/Paris' });
  return <p>{formatAmount(total, 'EUR')} · {formatDate(issuedAt)}</p>;
}
```

A `@react-pdf/renderer` document is rendered outside your React tree (by
`pdf()` or `renderToBuffer`), so it does not see your `I18nProvider`: call
`createFormatters` there, or pass the formatted strings in as props (see
[Translating built-in strings in a PDF](#translating-built-in-strings-in-a-pdf)).
Amounts formatted for French keep printing correctly: the narrow no-break
space `Intl` puts in `1 234,50 €` is not in the PDF standard fonts, and the
print blocks replace it with one that is.

**Changed in 2.2.0:** amounts use their currency's own decimals (JPY and KRW
show none, KWD, BHD and OMR show three) where every currency used to get two,
and a date-only string keeps its day instead of showing the previous day west
of UTC. Pass `{ fractionDigits: 2 }` to keep two decimals.

## Tables from columns and rows

`PrintDataTable` prints a list of plain objects through a list of columns, so
you describe the table once instead of writing every cell. New in 2.2.0; the
`PrintTable` blocks above keep working as they are.

```tsx
import { definePrintColumns, PrintDataTable, PrintDocument } from '@nxgt/material/print';

type Line = { description: string; quantity: number; unitPrice: number; amount: number };

const lineColumns = definePrintColumns<Line>([
  { key: 'description', label: 'Item', flex: 3 },
  { key: 'quantity', label: 'Qty', format: 'number', width: 40, total: 'sum' },
  { key: 'unitPrice', label: 'Unit price', format: 'amount' },
  { key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
]);

export function InvoiceLines({ lines, language }: { lines: Line[]; language: string }) {
  return (
    <PrintDocument language={language} pageNumbers>
      <PrintDataTable columns={lineColumns} rows={lines} currency="EUR" />
    </PrintDocument>
  );
}
```

With `language="fr"`, an amount of 1200 prints `1 200,00 €`, the last row
reads `Total` followed by the summed quantity and amount, and the header row
is printed again at the top of each page the table runs onto.

### The table's props

| Prop | Type | Default | Effect |
| --- | --- | --- | --- |
| `columns` | `PrintColumn<T>[]` | — | Required. What each column shows; see [the column fields](#the-column-fields) |
| `rows` | `T[]` | — | Required. Plain objects, one per line |
| `currency` | `string` | — | ISO 4217 code for `format: 'amount'` columns that name none |
| `totalLabel` | `string` | `material.print.table.total` ("Total") | Text in the first cell of the totals row, when the first column has no `total` of its own |
| `striped` | `boolean` | `true` | Shades every other row |
| `repeatHeader` | `boolean` | `true` | Prints the header row again at the top of each page the table crosses |
| `keepRowsWhole` | `boolean` | `true` | A row moves to the next page rather than splitting across two |
| `emptyText` | `string` | `material.print.table.empty` ("No rows") | With no rows: the header, then one muted row with this text |
| `hideWhenEmpty` | `boolean` | `false` | With no rows, print nothing at all, header included |

The totals row appears only when a column has a `total`, and it is kept in
one piece after the last row. It prints for an empty table too, at zero;
pass `hideWhenEmpty` if an empty table should leave no trace:

```tsx
<PrintDataTable columns={lineColumns} rows={[]} emptyText="Nothing ordered yet" />
<PrintDataTable columns={lineColumns} rows={[]} hideWhenEmpty />
<PrintDataTable columns={lineColumns} rows={lines} totalLabel="Total due" striped={false} />
```

The built-in texts follow the `PrintDocument`'s `language` (`Aucune ligne`
in French); their keys are listed in [i18n](i18n.md#the-material-namespace).

### The column fields

`definePrintColumns<T>()` returns its argument unchanged; it is there so
TypeScript checks every `key` against `T`.

| Field | Type | Default | Effect |
| --- | --- | --- | --- |
| `key` | `keyof T & string` | — | The row property to print. Its name is also the column's id |
| `id` + `accessor` | `string` + `(row: T, index: number) => unknown` | — | Instead of `key`: a computed value, such as `quantity * unitPrice` |
| `label` | `string` | — | Required. The header text, and the label in a [`PrintKeyValue`](#printkeyvalue-one-record-as-label-and-value-pairs) |
| `width` | `number \| '<n>%'` | — | A fixed width in points (`40`) or a share of the table (`'25%'`) |
| `flex` | `number` | `1` | Share of the width the fixed columns leave. Ignored when `width` is set |
| `align` | `'left' \| 'center' \| 'right'` | `'right'` for `amount`, `number` and `percent`, else `'left'` | Header and cells |
| `format` | `'amount' \| 'date' \| 'number' \| 'percent' \| (value, row) => string` | none | How the value prints (below) |
| `currency` | `string \| (row: T) => string \| null \| undefined` | the table's `currency` | ISO 4217 code for `format: 'amount'`, for the column or per row |
| `total` | `'sum' \| (rows: T[]) => number \| string \| null` | none | A figure in the totals row: the sum of the column, or your own |

What each `format` prints, in the document's locale:

| `format` | Value you pass | Prints (English) |
| --- | --- | --- |
| none | `'ACME'`, `1234.5` | `ACME`, `1,234.5` |
| `'amount'` | `1234.5` with currency `'EUR'` | `€1,234.50`, in the currency's own decimals |
| `'number'` | `1234.5678` | `1,234.568` (at most three decimals) |
| `'percent'` | `0.2` (a fraction) | `20%` |
| `'date'` | `'2026-03-01'`, an ISO date-time, epoch milliseconds, or a `Date` | `Mar 1, 2026` |
| a function | anything | exactly the string it returns |

`null`, `undefined` and `''` print `—`, whatever the format.

```tsx
type Order = {
  reference: string;
  placedAt: string;
  quantity: number;
  unitPrice: number;
  discount: number;
  currency: string;
  status: 'paid' | 'open';
};

const orderColumns = definePrintColumns<Order>([
  { key: 'reference', label: 'Order', width: '20%' },
  { key: 'placedAt', label: 'Placed', format: 'date' },
  {
    id: 'total',
    label: 'Total',
    accessor: (order) => order.quantity * order.unitPrice,
    format: 'amount',
    currency: (order) => order.currency, // one currency per row
  },
  { key: 'discount', label: 'Discount', format: 'percent' },
  { key: 'status', label: 'Status', format: (status) => (status === 'paid' ? 'Paid' : 'Open'), align: 'center' },
  {
    key: 'quantity',
    label: 'Paid qty',
    format: 'number',
    total: (orders) => orders.filter((o) => o.status === 'paid').reduce((n, o) => n + o.quantity, 0),
  },
]);
```

A few things the totals do that you may not expect:

- **`'sum'` adds numbers only.** A cell whose value is not a finite `number`
  counts as zero. Amounts must be numbers: a string such as `'12.50'` prints
  `—` in its cell and adds nothing to the total (see
  [troubleshooting](../troubleshooting.md#an-amount-column-prints--instead-of-the-amount)).
  Convert decimal strings from your API with `Number()` first.
- **The sum is floating point, rounded when printed.** `0.1 + 0.2` is
  `0.30000000000000004`; an `amount` or `number` column prints it rounded
  (`€0.30`), but the figure is not an exact decimal sum. When the total must
  match your accounting to the cent, compute it yourself in integer cents and
  return it from a `total` function (below).
- **A per-row currency is not converted.** When the rows of an `amount`
  column name more than one currency, `'sum'` prints `—` rather than a figure
  in the wrong currency. Keep one currency per table, or compute your own
  totals and return a string from `total`.
- **A `format` function formats the total too**, called with the total and
  the first row. A `total` function that returns a string prints it as is.

```tsx
const centColumns = definePrintColumns<{ label: string; cents: number }>([
  { key: 'label', label: 'Item' },
  {
    id: 'amount',
    label: 'Amount',
    accessor: (row) => row.cents / 100,
    format: 'amount',
    total: (rows) => rows.reduce((sum, row) => sum + row.cents, 0) / 100,
  },
]);
```

**Page breaks.** `keepRowsWhole` moves a row to the next page rather than
cut it, which works for any row shorter than a page. A single row taller than
a whole page, such as a cell holding several paragraphs, cannot stay whole;
shorten that cell or print its text below the table. With `repeatHeader`,
the header comes back on each page the table crosses, and is not left alone
at the foot of a page.

### From a screen `DataTable`

The TanStack column definitions of a `DataTable` on screen are not reused:
they carry React cells, sorting and filters that mean nothing on paper. Copy
the key and the header text across:

```ts
// on screen
const screenColumns = [
  { accessorKey: 'reference', header: 'Order' },
  { accessorKey: 'placedAt', header: 'Placed' },
];

// on paper
const printColumns = definePrintColumns<Order>([
  { key: 'reference', label: 'Order' },
  { key: 'placedAt', label: 'Placed', format: 'date' },
]);
```

### `PrintKeyValue`: one record as label and value pairs

The detail block above a table ("Customer", "Issued", "Due"), in one or two
columns. Pass `items`, or pass `data` with `fields`, which are the same
column definitions as a `PrintDataTable`:

```tsx
import { PrintKeyValue } from '@nxgt/material/print';

type Customer = { name: string; since: string; email: string | null; balance: number };

<PrintKeyValue
  data={customer}
  columns={2}
  currency="EUR"
  fields={[
    { key: 'name', label: 'Name' },
    { key: 'since', label: 'Customer since', format: 'date' },
    { key: 'email', label: 'Email' },
    { key: 'balance', label: 'Balance', format: 'amount' },
  ]}
/>

<PrintKeyValue
  items={[
    { label: 'Invoice', value: 'INV-42' },
    { label: 'Due', value: { date: '2026-04-01' } },
    { label: 'Total', value: { amount: 1234.5, currency: 'EUR' } },
  ]}
/>
```

| Prop | Type | Default | Effect |
| --- | --- | --- | --- |
| `items` | `{ label: string; value: PrintValue }[]` | — | The pairs, already built. A value is text, a number, or `{ amount, currency }`, `{ date }`, `{ number }` |
| `data` + `fields` | `T` + `PrintColumn<T>[]` | — | Instead of `items`: one record, read through column definitions (`width`, `flex`, `align` and `total` are ignored) |
| `currency` | `string` | — | With `data`: the currency of `amount` fields that name none |
| `columns` | `1 \| 2` | `1` | Two columns fill the left one first, then the right |
| `keepEmpty` | `boolean` | `false` | Print a pair whose value is empty, as `—`, instead of skipping it |

With every value empty and no `keepEmpty`, it prints nothing.

### `PrintMetric` and `PrintMetrics`: figures

A figure with its label, and optionally a hint and a change: the printed
counterpart of a `StatCard`. `PrintMetrics` lines several up, sharing the width.

```tsx
import { PrintMetric, PrintMetrics } from '@nxgt/material/print';

<PrintMetrics>
  <PrintMetric label="Revenue" value={12400} format="amount" currency="EUR" delta={4} hint="Q3" />
  <PrintMetric label="Orders" value={87} delta={-2.5} />
  <PrintMetric label="Conversion" value={0.031} format="percent" />
</PrintMetrics>
```

| Prop | Type | Default | Effect |
| --- | --- | --- | --- |
| `label` | `string` | — | Required. Above the figure |
| `value` | `PrintValue` | — | Required. A number, text, or `{ amount, currency }` / `{ date }` / `{ number }` |
| `format` | `'amount' \| 'date' \| 'number' \| 'percent'` | — | Applied when `value` is a number (or an ISO string for `'date'`), as for a column |
| `currency` | `string` | — | ISO 4217 code for `format="amount"` |
| `hint` | `string` | — | A caption under the figure |
| `delta` | `number` | — | A change **in percent**: `4` prints `+4%` in the success colour, `-2.5` prints `-2.5%` in the error colour, in the document's locale (`+4 %` in French) |

### Your own blocks

The helpers behind these blocks are exported from `@nxgt/material/print`, for
a block of your own that reads the same column definitions:

```ts
function definePrintColumns<T>(columns: PrintColumn<T>[]): PrintColumn<T>[];
function resolvePrintTable<T>(
  columns: PrintColumn<T>[],
  rows: T[],
  options?: { currency?: string; totalLabel?: string },
): {
  columns: { id: string; label: string; align: 'left' | 'center' | 'right'; width?: number | `${number}%`; flex?: number }[];
  rows: PrintValue[][];
  footer: PrintValue[][]; // one totals row, or none
  empty: boolean;
};
function resolvePrintFields<T>(fields: PrintColumn<T>[], row: T, options?: { currency?: string }): { label: string; value: PrintValue }[];
function toPrintValue<T>(column: PrintColumn<T>, value: unknown, row: T | undefined, options?: { currency?: string }): PrintValue;
function formatPrintValue(value: PrintValue, options?: { locale?: string; timeZone?: string; empty?: string }): string;
function isEmptyPrintValue(value: PrintValue): boolean;
```

A cell comes out as a `PrintValue`, still data (`{ amount: 12, currency: 'EUR' }`),
and `formatPrintValue` turns it into text. Your block does not see the
`PrintDocument`'s locale, so pass it:

```tsx
import { Text, View } from '@react-pdf/renderer';
import { formatPrintValue, type PrintColumn, resolvePrintTable } from '@nxgt/material/print';

export function PrintCompactList<T>({ columns, rows, locale }: { columns: PrintColumn<T>[]; rows: T[]; locale: string }) {
  const table = resolvePrintTable(columns, rows);
  return (
    <View>
      {table.rows.map((cells, index) => (
        <Text key={index}>{cells.map((cell) => formatPrintValue(cell, { locale })).join(' · ')}</Text>
      ))}
    </View>
  );
}
```

French amounts from `formatPrintValue` carry a narrow no-break space the PDF
fonts lack; replace it as shown in
[troubleshooting](../troubleshooting.md#french-amounts-print-with-a-slash-as-in-123450)
before printing them in your own `Text`.

## Rendering the document

One call turns a document into a file, a download, a new tab or a server
response. Each function takes a react-pdf `<Document>` element, such as a
`<PrintDocument>`, or a [document model](#documents-as-data):

```tsx
import { downloadPdf } from '@nxgt/material/print';
import { Button } from '@nxgt/material/components';
import { InvoiceDocument } from './invoice-document';
import type { Invoice } from './types';

export function DownloadInvoice({ invoice }: { invoice: Invoice }) {
  return (
    <Button variant="outlined" onClick={() => downloadPdf(<InvoiceDocument invoice={invoice} />, { fileName: invoice.number })}>
      Download PDF
    </Button>
  );
}
```

| Function | Returns | Where |
| --- | --- | --- |
| `downloadPdf(doc \| blob, options?)` | `Promise<void>`: the file is saved | browser |
| `openPdf(doc \| blob, options?)` | `Promise<void>`: the file opens in a new tab | browser |
| `usePdfFile(doc \| null, options?)` | `{ blob, url, loading, error, refresh }` | browser (a React hook) |
| `pdfResponse(doc, options?)` | `Promise<Response>` with the PDF and its headers | Node 18+, Bun |
| `renderPdfBuffer(doc, options?)` | `Promise<Uint8Array>` | browser, Node 18+, Bun |
| `renderPdfBlob(doc, options?)` | `Promise<Blob>` of type `application/pdf` | browser, Node 18+, Bun |
| `renderPdfStream(doc, options?)` | `Promise<ReadableStream<Uint8Array>>` (a Web stream) | Node 18+, Bun |

They render outside your React tree, like `pdf()`: give a `PrintDocument` its
`language` as a prop (see
[Translating built-in strings in a PDF](#translating-built-in-strings-in-a-pdf)).
When one fails, the promise rejects with react-pdf's error; `usePdfFile`
returns it as `error`.

### In the browser: `downloadPdf` and `openPdf`

`downloadPdf` renders the document and saves it; `openPdf` opens it in a new
tab, where the browser's own viewer shows it. Call both from a click handler.

```tsx
import { downloadPdf, openPdf } from '@nxgt/material/print';
import { Button, ButtonGroup } from '@nxgt/material/components';
import { InvoiceDocument } from './invoice-document';
import type { Invoice } from './types';

export function InvoiceActions({ invoice }: { invoice: Invoice }) {
  const doc = <InvoiceDocument invoice={invoice} language="fr" />;
  return (
    <ButtonGroup>
      <Button onClick={() => openPdf(doc, { fileName: invoice.number })}>Open</Button>
      <Button onClick={() => downloadPdf(doc, { fileName: invoice.number })}>Download</Button>
    </ButtonGroup>
  );
}
```

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `fileName` | `string` | `'document.pdf'` | The saved file's name, and the name the tab offers when saving; `.pdf` is added when missing (`'INV-42'` saves `INV-42.pdf`) |
| `custom` | `Record<string, renderer>` | — | Renderers for a model's custom nodes; see [Custom nodes](#custom-nodes) |

- **A `Blob` is saved as it is**, without rendering again: pass the `blob`
  from `usePdfFile` or `renderPdfBlob`. A `File` keeps its own name when you
  pass no `fileName`.
- **`openPdf` opens the tab first**, before the rendering starts, while the
  browser still counts the click as the user's; a popup blocker then lets it
  through. When a blocker stops it all the same, **the file is downloaded
  instead**. If the rendering fails, the empty tab is closed and the promise
  rejects.
- **Nothing leaks.** The object URL behind a download or a tab is revoked 60
  seconds later: long enough for Firefox and Safari, which read it after the
  click returns.

```tsx
// a user action: tell them when it fails
async function onDownload() {
  try {
    await downloadPdf(<InvoiceDocument invoice={invoice} />, { fileName: invoice.number });
  } catch (error) {
    console.error(error); // or your toast
  }
}
```

### Showing it before it is saved: `usePdfFile`

`usePdfFile` renders the document in the browser and keeps the file current:
the `Blob`, an object URL for it, and the loading and error states. Build the
element with `useMemo`: a JSX element is a new value on every render, and the
hook renders again for every new value.

```tsx
import { useMemo } from 'react';
import { PdfReader, Spinner } from '@nxgt/material/components';
import { usePdfFile } from '@nxgt/material/print';
import { InvoiceDocument } from './invoice-document';
import type { Invoice } from './types';

export function InvoicePreview({ invoice }: { invoice: Invoice }) {
  const doc = useMemo(() => <InvoiceDocument invoice={invoice} />, [invoice]);
  const { blob, loading, error, refresh } = usePdfFile(doc);

  if (error) return <button onClick={refresh}>Try again</button>;
  if (loading && !blob) return <Spinner />;
  return (
    <div className="h-[600px]">
      <PdfReader file={blob} fileName={`${invoice.number}.pdf`} />
    </div>
  );
}
```

| Field | Type | What it is |
| --- | --- | --- |
| `blob` | `Blob \| null` | The rendered PDF; the previous one stays while a new render runs |
| `url` | `string \| null` | An object URL of `blob`, for an `<iframe>` or a link; revoked when it is replaced or the component unmounts |
| `loading` | `boolean` | `true` while a render runs |
| `error` | `Error \| null` | Why the last render failed; `blob` and `url` are then `null` |
| `refresh` | `() => void` | Renders the same document again |

Pass `null` to render nothing (all fields empty, `loading` false). A render
that a newer document overtook is dropped, so a slow render of an older
invoice never replaces a newer one. Without the reader, the browser's viewer
shows the `url`:

```tsx
const { url } = usePdfFile(doc);
return url ? <iframe src={url} title="Invoice" className="h-[600px] w-full" /> : null;
```

`PdfReader` opens the `Blob` as it is; see
[PdfReader](../components/media-and-files.md#pdfreader). react-pdf's own
`PDFViewer` still takes a `PrintDocument` too, but it cannot render a model.

### On a server: `pdfResponse`

`pdfResponse` renders the document into a Web `Response`: the bytes, with
`Content-Type: application/pdf`, a `Content-Length` and a
`Content-Disposition` carrying the file name. Return it from anything that
answers with a `Response`.

```tsx
// app/routes/invoices.$id[.pdf].tsx: a React Router resource route (no component)
import { pdfResponse } from '@nxgt/material/print';
import { InvoiceDocument } from '../invoice-document';
import { getInvoice } from '../invoices.server';

export async function loader({ params }: { params: { id: string } }) {
  const invoice = await getInvoice(params.id);
  return pdfResponse(<InvoiceDocument invoice={invoice} language="fr" />, { fileName: invoice.number });
}
```

```tsx
// app/invoices/[id]/pdf/route.tsx: a Next.js route handler
import { pdfResponse } from '@nxgt/material/print';
import { InvoiceDocument } from '../../../invoice-document';
import { getInvoice } from '../../../invoices.server';

export const runtime = 'nodejs'; // not 'edge': see Runtimes below

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const invoice = await getInvoice((await params).id);
  return pdfResponse(<InvoiceDocument invoice={invoice} />, { fileName: invoice.number, disposition: 'inline' });
}
```

A Next.js route handler also needs its `next.config.ts` wrapped in
`withNxgtPrint` from `@nxgt/material/next`, which adds
`serverExternalPackages: ['@nxgt/material']` and, for `output: 'standalone'`,
a trace of pdfkit's fonts: see
[Next.js](#nextjs-serverexternalpackages-and-output-standalone).

```tsx
// Hono
import { Hono } from 'hono';
import { pdfResponse } from '@nxgt/material/print';
import { InvoiceDocument } from './invoice-document';
import { getInvoice } from './invoices.server';

const app = new Hono();
app.get('/invoices/:id/pdf', async (c) => {
  const invoice = await getInvoice(c.req.param('id'));
  return pdfResponse(<InvoiceDocument invoice={invoice} />, { fileName: invoice.number });
});
```

```tsx
// Bun.serve
import { pdfResponse } from '@nxgt/material/print';
import { InvoiceDocument } from './invoice-document';
import { getInvoice } from './invoices.server';

Bun.serve({
  routes: {
    '/invoices/:id/pdf': async (request) => {
      const invoice = await getInvoice(request.params.id);
      return pdfResponse(<InvoiceDocument invoice={invoice} />, { fileName: invoice.number });
    },
  },
});
```

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `fileName` | `string` | `'document.pdf'` | The name in `Content-Disposition`; `.pdf` is added when missing |
| `disposition` | `'inline' \| 'attachment'` | `'attachment'` | `'inline'` shows the PDF in the browser's viewer (or a `PdfReader` given the URL), `'attachment'` saves it |
| `headers` | `HeadersInit` | — | More headers, such as `Cache-Control`. `Content-Type`, `Content-Length` and `Content-Disposition` are always the PDF's |
| `custom` | `Record<string, renderer>` | — | Renderers for a model's [custom nodes](#custom-nodes) |

```ts
const response = await pdfResponse(doc, {
  fileName: 'Facture n°42',
  disposition: 'inline',
  headers: { 'Cache-Control': 'private, no-store' },
});
response.headers.get('Content-Disposition');
// inline; filename="Facture n_42.pdf"; filename*=UTF-8''Facture%20n%C2%B042.pdf
```

The file name is written twice (RFC 6266): as ASCII, which every client reads,
and encoded as UTF-8, which browsers prefer, so accents survive.
`contentDisposition(disposition, fileName)` builds that header for a response
of your own. A `PdfReader` in the browser opens the route by its URL, with
`httpHeaders` when it needs a token:

```tsx
<PdfReader file={{ url: `/invoices/${id}/pdf`, httpHeaders: { Authorization: `Bearer ${token}` } }} fileName={`${number}.pdf`} />
```

### Bytes and streams: `renderPdfBuffer`, `renderPdfStream`

For anything that is not a response (an email attachment, an upload to object
storage, a file on disk), `renderPdfBuffer` returns the bytes as a
`Uint8Array`, which `new Response()`, `Blob`, `Bun.write` and Node's
`fs.writeFile` all take as they are:

```tsx
import { writeFile } from 'node:fs/promises';
import { renderPdfBuffer } from '@nxgt/material/print';

const bytes = await renderPdfBuffer(<InvoiceDocument invoice={invoice} />);
await writeFile(`invoices/${invoice.number}.pdf`, bytes);
```

`renderPdfStream` returns a Web `ReadableStream`, so a long document starts
reaching the client before the last page is written. A `Response` takes it
directly; Node's own streams, such as Express's `res`, take it through
`Readable.fromWeb`:

```tsx
import { Readable } from 'node:stream';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
import { contentDisposition, renderPdfStream } from '@nxgt/material/print';

app.get('/reports/:id/pdf', async (req, res) => {
  const stream = await renderPdfStream(<ReportDocument id={req.params.id} />);
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', contentDisposition('attachment', 'report.pdf'));
  Readable.fromWeb(stream as NodeReadableStream).pipe(res);
});
```

(The cast is only for TypeScript, whose DOM and Node stream types differ;
with Bun's types in the project, cast through `unknown` first.) A
streamed response has no `Content-Length`: use `pdfResponse` when the client
needs one. `renderPdfBlob` returns a `Blob`, for an API that asks for one,
such as `FormData`.

### Runtimes

| Runtime | Renders | Notes |
| --- | --- | --- |
| Browser | yes | Everything but `renderPdfStream` and `pdfResponse` |
| Node 18+ | yes | Everything but `downloadPdf`, `openPdf` and `usePdfFile`, which need a page |
| Bun | yes | As Node |
| Edge runtimes (Vercel Edge, Cloudflare Workers, Deno Deploy) | **no** | react-pdf needs Node APIs these do not have. Render on a Node or Bun runtime ([troubleshooting](../troubleshooting.md#pdf-rendering-fails-on-an-edge-runtime)) |

A server bundle must load react-pdf's Node build: a bundler that picks its
browser build for a server prints `renderToBuffer is a Node specific API`
(see [troubleshooting](../troubleshooting.md#rendertobuffer-is-a-node-specific-api)).

### The signatures

```ts
type PdfInput = ReactElement<DocumentProps> | PdfDocument; // a <Document> element, or a model
type PdfRenderInputOptions = { custom?: Record<string, PdfCustomRenderer> };
type PdfFileNameOptions = { fileName?: string };
type PdfOutputOptions = PdfFileNameOptions & PdfRenderInputOptions;
type PdfResponseOptions = {
  fileName?: string;
  disposition?: 'inline' | 'attachment';
  headers?: HeadersInit;
};

function renderPdfBlob(input: PdfInput, options?: PdfRenderInputOptions): Promise<Blob>;
function renderPdfBuffer(input: PdfInput, options?: PdfRenderInputOptions): Promise<Uint8Array<ArrayBuffer>>;
function renderPdfStream(input: PdfInput, options?: PdfRenderInputOptions): Promise<ReadableStream<Uint8Array>>;
function downloadPdf(input: PdfInput | Blob, options?: PdfOutputOptions): Promise<void>;
function openPdf(input: PdfInput | Blob, options?: PdfOutputOptions): Promise<void>;
function pdfResponse(input: PdfInput, options?: PdfResponseOptions & PdfRenderInputOptions): Promise<Response>;
function contentDisposition(disposition: 'inline' | 'attachment', fileName: string): string;
function usePdfFile(input: PdfInput | null, options?: PdfRenderInputOptions): PdfFileState;

type PdfFileState = {
  blob: Blob | null;
  url: string | null;
  loading: boolean;
  error: Error | null;
  refresh: () => void;
};
```

`PdfInput`, `PdfOutputOptions`, `PdfResponseOptions`, `PdfRenderInputOptions`,
`PdfFileNameOptions`, `PdfFileState` and `PdfFileSnapshot` (the state without
`refresh`) are exported as types. So are `PdfCustomRenderer` and
`PdfRenderContext` (a [custom node](#custom-nodes)'s renderer and its second
argument), `PdfTheme` with `resolvePdfTheme(overrides?)` and
`resolvePdfColor(color, theme)` (the theme a document renders with, and a
colour name resolved against it), and the components' props:
`PrintDocumentProps`, `PrintPageInfo`, `PrintHeaderProps`,
`PrintDataTableProps`, `PrintKeyValueProps` and `PrintMetricProps`.

### With react-pdf directly

`pdf()`, `renderToBuffer()`, `renderToStream()` and `PDFViewer` from
`@react-pdf/renderer` keep taking a `PrintDocument`, for an option the
functions above do not have:

```tsx
import { renderToBuffer } from '@react-pdf/renderer';

const buffer = await renderToBuffer(<InvoiceDocument invoice={invoice} />); // a Node Buffer
```

## Documents as data

A document can also be plain data: a JSON-serialisable object, written with a
few builder functions, that the functions above render like a
`PrintDocument`. Build it on a server, send it as JSON, render it in the
browser; or keep your templates as functions from your data to a document.

> **Experimental.** The model, its builders and `version: 1` may still
> change in a minor release until they are frozen, which happens once
> another renderer draws the same documents. Pin a minor version if you store
> documents.

```ts
import { downloadPdf, heading, keyValue, page, pdfDocument, table, text, totals } from '@nxgt/material/print';

const doc = pdfDocument({
  meta: { title: 'Invoice INV-42' },
  language: 'fr',
  locale: 'fr-FR',
  pages: [
    page({
      pageNumbers: true,
      body: [
        heading('Facture INV-42'),
        keyValue([
          { label: 'Client', value: 'ACME SAS' },
          { label: 'Émise le', value: { date: '2026-03-01' } },
        ]),
        table({
          columns: [{ label: 'Article', flex: 3 }, { label: 'Montant', align: 'right' }],
          rows: [['Design', { amount: 800, currency: 'EUR' }]],
        }),
        totals([{ label: 'Total', value: { amount: 800, currency: 'EUR' }, emphasis: true }]),
        text('Payable à 30 jours.', { variant: 'muted' }),
      ],
    }),
  ],
});

await downloadPdf(doc, { fileName: 'INV-42' }); // "Page 1 sur 1", 800,00 €
```

### The model

```ts
type PdfDocument = {
  version: 1;
  meta?: { title?: string; author?: string; subject?: string; keywords?: string; creator?: string; producer?: string };
  language?: string; // the built-in strings ('en', 'fr') and the PDF's language; 'en' by default
  locale?: string;   // how amounts, dates and numbers print; `language` by default
  timeZone?: string;
  theme?: { colors?: Partial<Record<PdfThemeColor, string>>; fonts?: Partial<Record<'sans' | 'serif' | 'mono', string>> };
  pages: PdfPage[];
};

type PdfPage = {
  size?: 'A3' | 'A4' | 'A5' | 'LETTER' | 'LEGAL' | [number, number];
  orientation?: 'portrait' | 'landscape';
  margins?: number | { top?: number; right?: number; bottom?: number; left?: number; x?: number; y?: number };
  footerHeight?: number;
  pageNumbers?: boolean | { align?: 'left' | 'center' | 'right'; format?: 'page-of' | 'page'; scope?: 'document' | 'group' };
  watermark?: string | { text: string; opacity?: number; angle?: number; color?: string; fontSize?: number };
  header?: PdfNode[]; // repeated at the top of every page
  footer?: PdfNode[]; // repeated at the bottom of every page
  body: PdfNode[];
};
```

The page options are `PrintDocument`'s props, with the same defaults (see
[The page shell](#the-page-shell-printdocument)); a document may hold several
page shells, each starting a new page. The file's creator is
`@nxgt/material` unless `meta.creator` names another.

Each node is an object with a `type`, and each has a builder that returns
it:

| Node | Builder | Prints |
| --- | --- | --- |
| `heading` | `heading(text, level?)` | A title, level `1`, `2` or `3` |
| `text` | `text(content, { variant?, style? })` | A paragraph: a string, or runs `{ text, bold?, italic?, color?, href? }`; `variant` is `'body'`, `'muted'`, `'caption'` or `'label'` |
| `stack` / `row` | `stack(children, { gap?, style? })`, `row(children, { gap?, align?, justify?, style? })` | Children one under the other, or side by side |
| `section` | `section(title, children)` | A titled block, as `PrintSection` |
| `table` | `table({ columns, rows, footer?, labelTotals?, striped?, repeatHeader?, keepRowsWhole?, emptyText?, hideWhenEmpty? })`, or `dataTable(columns, rows, options?)` from [column definitions](#tables-from-column-definitions-datatable-and-keyvalueof) | A table of `PrintValue`s, as `PrintDataTable`; a row or a cell may carry a `style`, see [Row and cell styles](#row-and-cell-styles-in-a-table); with `labelTotals`, an empty first cell of a `footer` row reads "Total" in the document's language |
| `keyValue` | `keyValue(items, { columns?, keepEmpty? })`, or `keyValueOf(fields, data, options?)` from column definitions | Label and value pairs, as `PrintKeyValue` |
| `totals` | `totals(rows)` | Label and value rows; `emphasis` makes one bold |
| `metric` | `metric({ label, value, hint?, delta? })` | A figure, as `PrintMetric` |
| `badge` | `badge(text, color?)` | A pill |
| `header` | `header({ title, subtitle?, reference?, referencePrefix?, logo?, logoText?, qrDataUrl? })` | The title block, as `PrintHeader`; `referencePrefix` is printed before `reference`, `'#'` by default |
| `image` | `image(src, { width?, height?, fit? })` | A PNG or JPEG, from a URL or a data URL |
| `link` | `link(href, text)` | A link |
| `spacer`, `divider`, `pageBreak` | `spacer(points)`, `divider()`, `pageBreak()` | Space, a rule, a new page |
| `pageNumber` | `pageNumber({ format?, align?, scope? })` | "Page 2 of 5", where you put it (in a `footer`, say); `scope: 'group'` counts within the page group, see [Page groups](#page-groups-numbering-that-restarts) |
| `custom` | none: `{ type: 'custom', name, props? }` | Your own renderer; see [Custom nodes](#custom-nodes) |

A colour is a theme name (`'primary'`, `'muted'`, `'success'`, `'error'`, …)
or a CSS colour (`'#0b7a4b'`); `theme.colors` overrides a name, and
`defaultPdfTheme` holds the defaults. A `style` takes a JSON-safe subset of
react-pdf's (margins, padding, sizes, `fontSize`, `fontWeight`, `color`,
borders), in points.

**Amounts and dates stay data.** A value in a `table`, `keyValue`, `totals` or
`metric` is a `PrintValue`: text, a number, `{ amount, currency }`,
`{ date }` or `{ number, style: 'percent' }`. It is formatted only when the
document is rendered, with the document's `locale` and `timeZone`, so a
document built on a server in one locale prints in the reader's without
being rebuilt:

```ts
table({
  columns: [{ label: 'Item' }, { label: 'Due' }, { label: 'Amount', align: 'right' }],
  rows: [['Design', { date: '2026-04-01' }, { amount: 1234.5, currency: 'EUR' }]],
});
// locale 'fr-FR': 1 avr. 2026 · 1 234,50 €     locale 'en-US': Apr 1, 2026 · €1,234.50
```

### Page groups: numbering that restarts

A document made of one entry of `pages` per person (a report card per
student, an invoice per customer) can number each entry on its own. An entry of
`pages` is a group: its pages are the ones its body flows onto. With
`pageNumbers: { scope: 'group' }`, or `pageNumber({ scope: 'group' })` in a
`footer`, "Page 1 of 2" restarts at each group and ends at that group's own
last page. The default, `scope: 'document'`, counts every page of the file.

```ts
const students = [{ name: 'Ada' }, { name: 'Grace' }];

const doc = pdfDocument({
  pages: students.map((student) =>
    page({
      pageNumbers: { scope: 'group' }, // Ada: Page 1 of 2, Page 2 of 2. Grace: Page 1 of 3, ...
      body: [heading(student.name) /* , a table that may run onto a second page */],
    }),
  ),
});
```

This is react-pdf's own `subPageNumber` and `subPageTotalPages`, which count
the pages one `Page` wraps onto; no page is added or moved. To restart
without the footer row, put `pageNumber({ scope: 'group', format: 'page' })`
in the page's `footer` ("Page 2"). The scope is typed `PdfPageNumberScope`
(`'document' | 'group'`), exported from `@nxgt/material-core/pdf/model`.

### Row and cell styles in a table

A `rows` or `footer` entry is a list of values, or `{ cells, style? }`; a
cell is a value, or `{ value, style? }`. The style takes `fontWeight`
(`'normal'` or `'bold'`), `backgroundColor` (a theme name such as `'rowAlt'`
or `'headerBg'`, or a CSS colour) and `borderTop` (`true` for a 1 point line in
the theme's `border` colour, or `{ width?, color? }`). A cell's style wins
over its row's; a cell can add or change a top border, not remove its row's.
A row's background replaces the zebra stripe. The types are `PdfTableStyle`,
`PdfTableRow` and `PdfTableCell`, exported from
`@nxgt/material-core/pdf/model`.

```ts
table({
  columns: [{ label: 'Subject' }, { label: 'Mark', align: 'right' }],
  rows: [
    ['Maths', { number: 14 }],
    ['Physics', { number: 12 }],
    {
      style: { fontWeight: 'bold', backgroundColor: 'rowAlt', borderTop: true },
      cells: ['Science average', { value: { number: 13 }, style: { backgroundColor: '#fef3c7' } }],
    },
  ],
});
```

### Tables from column definitions: `dataTable` and `keyValueOf`

`dataTable` builds a `table` node from the same
[column definitions](#tables-from-columns-and-rows) `PrintDataTable` takes, and
`keyValueOf` builds a `keyValue` node from one object through them, so a
`definePrintColumns` list describes a table once for both a component and a
model:

```ts
import { dataTable, definePrintColumns, keyValueOf, page, pdfDocument } from '@nxgt/material/print';

type Line = { item: string; amount: number };

const columns = definePrintColumns<Line>([
  { key: 'item', label: 'Item', flex: 3 },
  { key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
]);
const lines: Line[] = [
  { item: 'Design', amount: 1000 },
  { item: 'Build', amount: 234.5 },
];

const doc = pdfDocument({
  language: 'fr',
  pages: [
    page({
      body: [
        keyValueOf(columns, lines[0], { currency: 'EUR', columns: 2 }),
        dataTable(columns, lines, { currency: 'EUR' }),
      ],
    }),
  ],
});
// the table ends on a totals row: "Total  1 234,50 €"
```

```ts
function dataTable<T>(
  columns: PrintColumn<T>[],
  rows: T[],
  options?: DataTableOptions,
): Extract<PdfNode, { type: 'table' }>;
function keyValueOf<T>(
  fields: PrintColumn<T>[],
  data: T,
  options?: { currency?: string; columns?: 1 | 2; keepEmpty?: boolean },
): Extract<PdfNode, { type: 'keyValue' }>;

type DataTableOptions = {
  currency?: string;
  totalLabel?: string;
  striped?: boolean;
  repeatHeader?: boolean;
  keepRowsWhole?: boolean;
  emptyText?: string;
  hideWhenEmpty?: boolean;
};
```

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `currency` | `string` | — | ISO 4217 code for `format: 'amount'` columns that name none |
| `totalLabel` | `string` | "Total", in the document's language | Text in the first cell of the totals row, when the first column has no `total` of its own |
| `striped`, `repeatHeader`, `keepRowsWhole`, `emptyText`, `hideWhenEmpty` | | as the `table` node | Copied onto the node |

Without `totalLabel`, `dataTable` leaves the first footer cell empty and sets
`labelTotals: true` on the node, so the word is chosen when the document is
rendered, in its `language` (`material.print.table.total`, see
[Translating built-in strings](#translating-built-in-strings-in-a-pdf)); a
document built on a server in English still reads "Total" in the reader's
language. With `totalLabel`, that text is written into the cell as it is:

```ts
dataTable(columns, lines, { currency: 'EUR', totalLabel: 'Sum' }).footer;
// [['Sum', { amount: 1234.5, currency: 'EUR' }]]
```

There is no footer, and no `labelTotals`, when no column has a `total`. The
cells are `PrintValue`s, so amounts and dates are still formatted at render
time. `keyValueOf`'s `columns` is the node's layout (1 or 2 columns of pairs),
not the column definitions. `DataTableOptions` is exported as a type.

### Templates: `definePdfDocument`

`definePdfDocument` types a template, a function from your data to a
document, and stamps `version` on what it returns. Annotate the data
parameter; extra parameters may take defaults (`language = 'en'`). The
template's own signature is the type: do not pass it as a generic
(`definePdfDocument<Invoice>(…)` does not compile). Left unannotated,
the data parameter is `unknown`: reading a property of it fails to compile in
the template (`'invoice' is of type 'unknown'`), so annotate it or narrow it
first.

```ts
import { definePdfDocument, header, heading, keyValue, page, table, text, totals } from '@nxgt/material/print';

type Invoice = {
  number: string;
  issuedAt: string;
  customer: string;
  currency: string;
  lines: { label: string; amount: number }[];
};

export const invoicePdf = definePdfDocument((invoice: Invoice, language = 'en') => ({
  meta: { title: `Invoice ${invoice.number}` },
  language,
  pages: [
    page({
      pageNumbers: { align: 'right' },
      header: [header({ title: 'Invoice', reference: invoice.number, logoText: 'ACME' })],
      footer: [text('ACME SAS · 12 Main Street', { variant: 'caption' })],
      body: [
        heading(invoice.customer, 2),
        keyValue([{ label: 'Issued', value: { date: invoice.issuedAt } }]),
        table({
          columns: [{ label: 'Item', flex: 3 }, { label: 'Amount', align: 'right' }],
          rows: invoice.lines.map((line) => [line.label, { amount: line.amount, currency: invoice.currency }]),
        }),
        totals([
          {
            label: 'Total',
            value: { amount: invoice.lines.reduce((sum, line) => sum + line.amount, 0), currency: invoice.currency },
            emphasis: true,
          },
        ]),
      ],
    }),
  ],
}));
```

The same template feeds every function, and survives a JSON round trip:

```tsx
// a server route
return pdfResponse(invoicePdf(invoice, 'fr'), { fileName: invoice.number });

// or an API that sends the document, and a browser that renders it
return Response.json(invoicePdf(invoice, 'fr'));
const doc: PdfDocument = await (await fetch(`/api/invoices/${id}/document`)).json();
await downloadPdf(doc, { fileName: id });
```

`pdfDocument(options)` does the same for one document, and `page(options)`
returns its argument, typed.

### Custom nodes

A block the model has no node for is a `custom` node: a name and JSON
props, drawn by a renderer you pass as `custom` to whichever function renders
the document. The renderer returns react-pdf primitives, and gets the
document's formatter and theme:

```tsx
import { Text, View } from '@react-pdf/renderer';
import { page, pdfDocument, pdfResponse, type PdfRenderInputOptions } from '@nxgt/material/print';

const custom: PdfRenderInputOptions['custom'] = {
  signature: (props, { format, theme }) => (
    <View style={{ marginTop: 32, borderTopWidth: 1, borderColor: theme.colors.border, width: 180 }}>
      <Text>{String(props.name)}</Text>
      <Text style={{ color: theme.colors.muted }}>{format({ date: String(props.date) })}</Text>
    </View>
  ),
};

const doc = pdfDocument({
  pages: [page({ body: [{ type: 'custom', name: 'signature', props: { name: 'Jane Doe', date: '2026-03-01' } }] })],
});

return pdfResponse(doc, { custom });
```

The renderer's second argument also holds `language`, `locale`, `timeZone`,
`t` (the built-in strings) and `renderNodes(nodes)` for child nodes. Keep the
`custom` object stable (a module constant, or `useMemo`) when you pass it to
`usePdfFile`, which renders again when it changes.

### What a model rejects

Rendering a model throws, before any page is drawn, on:

| Error | Why |
| --- | --- |
| ``[pdf] No renderer for the custom node "signature": pass it in `custom` when rendering.`` | A `custom` node whose `name` is not in the `custom` option |
| `[pdf] Unknown node type "chart".` | A `type` this version does not know: a typo in hand-written JSON, or a document from a newer version |
| `[pdf] Unsupported document version 2: this version renders version 1.` | `version` is not `1` |

## Why it is a separate entry

`@react-pdf/renderer` depends on `pdfkit`, and pdfkit loads its fourteen
standard fonts at runtime with
`createRequire(import.meta.url)('#standard-fonts/<name>')`. That specifier is a
package `imports` entry private to pdfkit, and it is a runtime call, so no
bundler resolves it. Bundle pdfkit into a server and the literal survives into
the artifact, where `createRequire` is rooted at your build directory. The
server then throws on **every request**, `/health` included, and a container
healthcheck never goes healthy:

```text
error: Cannot find module '#standard-fonts/Helvetica' from '…/build/server/index.js'
```

Up to 1.x, `import '@nxgt/material'` alone pulled pdfkit into every consumer.
Since 2.0.0 only `@nxgt/material/print` reaches it, and the package's own build
fails if any other entry does. An app that never imports `/print` cannot hit
this.

## Bundling print into an SSR server

If your server imports `@nxgt/material/print` and you bundle this package into
it (`ssr.noExternal`), choose by one fact: does the runtime image keep a
`node_modules`?

| Runtime image | What to do |
| --- | --- |
| Keeps `node_modules` | Leave the library external: `@nxgt/material` and `@nxgt/material-core` **out** of `ssr.noExternal`, and `--external @nxgt/material --external @nxgt/material-core` in the bundle step. pdfkit then loads from `node_modules` as designed, and ~12 MB of it stays out of the artifact |
| Ships the bundle alone | `nxgtMaterial({ pdf: { bundle: true } })` from `@nxgt/material/vite`. It adds the standard-fonts transform, which rewrites those calls into static imports, so the font data is inlined and the artifact is self-contained, and puts the library, core, the renderer, pdfkit and the renderer's runtime dependencies into `ssr.noExternal` (`react` and your app's own dependencies are yours to bundle) |

```ts
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { nxgtMaterial } from '@nxgt/material/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [...nxgtMaterial({ pdf: { bundle: true } }), reactRouter()],
});
```

`pdfkitStandardFonts()` stays exported for a hand-written setup, but the
`ssr.noExternal` list it needs is the renderer's whole dependency tree, which is
why `bundle: true` reads it from your installed packages. `pdf` is on by default but only does something when pdfkit goes through the
bundle: `pdf: false` turns it off.

`@nxgt/material-core` goes with it: the library's built code imports it by
name, so a bundle that inlines one and not the other fails at boot with
[`Cannot find package '@nxgt/material-core'`](../troubleshooting.md#cannot-find-package-nxgtmaterial-core).

```ts
function pdfkitStandardFonts(): import('vite').Plugin; // enforce: 'pre'
```

- The plugin only touches modules whose id contains `pdfkit`. It **fails the
  build** if a `#standard-fonts/` reference survives the rewrite, so a pdfkit
  release that changes the shape of those calls breaks the build instead of
  the first request.
- `@nxgt/material/vite` has no runtime dependency on `vite` (it only imports a
  type), so it is safe in any config file.

Two changes look like fixes and are not:

- **`resolve.alias`** from `#standard-fonts/` to `pdfkit/standard-fonts/`. An
  alias rewrites specifiers the resolver is asked about. This one is a string
  handed to `createRequire` at runtime, which the resolver never sees.
- **`--external pdfkit`**. pdfkit is a transitive dependency, so the runtime
  cannot resolve it from your app's directory:
  `Cannot find package 'pdfkit'`.

### Next.js: `serverExternalPackages` and `output: 'standalone'`

A route handler that imports `@nxgt/material/print` needs two keys of
`next.config.ts`. `withNxgtPrint` from `@nxgt/material/next` (since 2.4.0)
adds both:

```ts
// next.config.ts
import { withNxgtPrint } from '@nxgt/material/next';

export default withNxgtPrint({
  output: 'standalone',
  // the rest of your config, as it was
});
```

Checked with Next.js 16.3 against `.next/standalone/server.js` run alone,
with both Turbopack and `next build --webpack`, under npm and pnpm.

- **`serverExternalPackages`** gets `'@nxgt/material'`. Route handlers are
  bundled with React's `react-server` build, which has no `createContext`,
  and `/print` calls it when it loads; external, the library and React load
  from `node_modules`. Without it, `next build` stops at `Collecting page
  data` with
  [`TypeError: (0 , t.createContext) is not a function`](../troubleshooting.md#typeerror-0--tcreatecontext-is-not-a-function-in-a-nextjs-route-handler).
- **`outputFileTracingIncludes`** gets, for every route (`'/**'`), every
  `.cjs` file under pdfkit's `js/standard-fonts/`: pdfkit `require()`s its
  fonts at runtime, which the tracer does not follow. Without it, a webpack
  build passes and `next start` works, but the standalone server answers
  `500` on the PDF route with
  [`Cannot find module '…/node_modules/pdfkit/js/standard-fonts/Helvetica.cjs'`](../troubleshooting.md#cannot-find-module-node_modulespdfkitjsstandard-fontshelveticacjs).
  Turbopack's trace included the files; the glob is added anyway, whatever
  the `output` and the bundler, because it costs nothing.

What it does, precisely:

- **It merges, and never overwrites.** Your `serverExternalPackages` and
  `outputFileTracingIncludes` keep what they had, other routes' globs
  included; the two additions are deduplicated. Every other key passes
  through.
- **pdfkit is found where your app installs it.** It resolves
  `@react-pdf/renderer` from the project directory, then the pdfkit it
  depends on and the one `@react-pdf/font` depends on, to their real
  directories, and writes each as a glob relative to the project directory:
  `./node_modules/pdfkit/js/standard-fonts/**/*.cjs` under npm (checked end
  to end), `./node_modules/.pnpm/pdfkit@<version>/node_modules/pdfkit/js/standard-fonts/**/*.cjs`
  under pnpm (checked end to end), `./node_modules/.bun/pdfkit@<version>/…`
  under Bun's isolated linker, `../../node_modules/…` in a monorepo that
  hoists it above the app.
- **Without `@react-pdf/renderer`** (an optional peer), it adds no fonts and
  says nothing. Any other failure to resolve them prints one
  `[@nxgt/material/next]` warning; the config is still returned, and the
  manual globs below are the fallback.
- **The project directory is `process.cwd()`**, which is right when
  `next build` runs in the project. For `next build <dir>` run from
  elsewhere, pass it: `withNxgtPrint(config, { dir: import.meta.dirname })`
  (checked with Next.js 16.3's `next.config.ts`; `__dirname` in a CommonJS
  `next.config.js`). A relative `dir` is taken from the working directory.
- **The function form of `next.config` works too**:
  `withNxgtPrint((phase, { defaultConfig }) => ({ ... }))` returns a function
  Next.js calls as usual.
- It runs in Node at config time, imports only `node:*` built-ins and
  `@nxgt/material-core/pdfkit-fonts` (already installed with the library),
  and needs no dependency on `next`: its parameter accepts any `NextConfig`, and it
  returns the type you passed in. Its keys are not checked against
  `NextConfig`, so a misspelt one passes: write
  `withNxgtPrint({ ... } satisfies NextConfig)` to have them checked. The
  types it is written with are exported too, should you wrap it:
  `WithNxgtPrintOptions` (`{ dir }`), `NextConfigLike`, `NextConfigFunction`
  and `WithPrint`.
- The package is ESM. A CommonJS `next.config.js` `require()`s it, which
  needs Node 20.19+ or 22.12+; `next.config.ts` and `next.config.mjs` import
  it.

**By hand**, when you would rather not use the helper, the same config is:

```ts
// next.config.ts
import type { NextConfig } from 'next';

export default {
  output: 'standalone',
  // Route handlers are bundled with React's react-server build, which has no
  // createContext: load the library from node_modules instead.
  serverExternalPackages: ['@nxgt/material'],
  // pdfkit require()s its fonts' .cjs files at runtime; the tracer copies
  // only the .mjs ones. Only needed with output: 'standalone'.
  outputFileTracingIncludes: {
    '/**': ['./node_modules/pdfkit/js/standard-fonts/**/*.cjs'],
  },
} satisfies NextConfig;
```

Under pnpm the glob is
`./node_modules/.pnpm/pdfkit@*/node_modules/pdfkit/js/standard-fonts/**/*.cjs`.

`pdfkitStandardFonts()` is a Vite plugin and does not apply to Next.js.

More on what a server bundle evaluates in [SSR](ssr.md).

## Printing one element of the page

`RecoveryCodes`' "Print" button prints its card and nothing else, with
`window.print()`, no inline style and no new window. The same helper prints
any element of yours: `printOnly(element, name?)` from
`@nxgt/material/lib` marks `<html data-nxgt-print="<name>">` and
the element (`data-nxgt-print-target`), opens the print dialog, and removes
both once printing is over (on `afterprint`, or as soon as `print()` returns
where it blocks). The rules that hide the rest of the page are in the
package's `styles.css` (`print-only.css`), under `@media print`: everything but the element,
what holds it and what it holds is taken out of the flow, and the element
starts at the top left of the first sheet.

```tsx
import { printOnly } from '@nxgt/material/lib';

const stop = printOnly(document.getElementById('ticket') as HTMLElement, 'ticket');
// When the element unmounts before the dialog closed (a browser whose
// print() returns at once and never fires afterprint):
stop();
```

Hide the controls inside the element with Tailwind's `print:hidden`. If the
rest of the page still prints, an `!important` print rule of the app's wins
over the package's: see
[troubleshooting](../troubleshooting.md#recoverycodes-print-button-prints-the-whole-page).
