Home  ›  Guides  ›  Smart tables

Smart tables

Multi-page-safe tables by default. Long tables wrap on column overflow, slice cleanly across pages and reprint their header on every continuation page; zebra striping, captions, per-cell padding and minimum row heights are one field each on TableBlock. Backward-compatible with v1.1.0 — existing single-page tables produce byte-identical output.

pdfnative's table renderer is planner-driven and multi-page-safe by default. Long tables wrap on column overflow, slice cleanly across pages, and reprint their header on every continuation page — matching the behaviour readers expect from commercial PDF libraries.

This guide documents the TableBlock fields, the planner architecture, the tagged-mode contract, and migration tips.


TL;DR#

import { buildDocumentPDFBytes } from 'pdfnative';

const bytes = buildDocumentPDFBytes({
    title: 'Invoice',
    blocks: [
        {
            type: 'table',
            headers: ['Item', 'Qty', 'Price'],
            // PdfRow[]: e.g. { cells: ['Pro plan', '1', '$49.00'], type: 'default', pointed: false }
            rows: bigInvoiceRows, // any length
            columns: [            // optional ColumnDef[]: f = width fraction, a = align,
                                  // mx / mxH = char caps (data / header), plus minWidth? / maxWidth?
                { f: 0.6, a: 'l', mx: 40, mxH: 20 },
                { f: 0.2, a: 'r', mx: 10, mxH: 8 },
                { f: 0.2, a: 'r', mx: 10, mxH: 8 },
            ],
            autoFitColumns: true,  // content-aware widths (respects minWidth/maxWidth)
            wrap: 'auto',          // ← new (default)
            repeatHeader: true,    // ← new (default)
            zebra: true,           // ← new (opt-in)
            caption: 'Invoice line items',
            minRowHeight: 14,
            cellPadding: 5,
        },
    ],
});

Existing v1.1.0 code with no new fields continues to work and produces byte-identical output on single-page tables.


TableBlock fields (all optional)#

Field Type Default Description
wrap 'auto' | 'always' | 'never' 'auto' Per-cell wrap policy.
repeatHeader boolean true Reprint the header row at the top of each continuation page.
zebra boolean | PdfColor false Alternating data-row fill. true uses '0.969 0.973 0.984'.
caption string undefined Caption printed once above the first slice.
minRowHeight number (points) 12 Minimum visual row height.
cellPadding number (points) 3 Internal cell padding.
cellBorders CellBorders undefined Per-cell vector borders (v1.4.0). Omit for byte-identical pre-1.4.0 output.
cellVAlign 'top' | 'middle' | 'bottom' undefined Vertical alignment of cell text within the row band (v1.4.0).

wrap#

repeatHeader#

zebra#

caption#

minRowHeight / cellPadding#

cellBorders (v1.4.0)#

Draw per-cell vector borders. All sides are off by default — omitting cellBorders entirely keeps the table byte-identical to pre-1.4.0 (header underline + row separators only).

{
    type: 'table',
    headers: ['Item', 'Qty'],
    rows: [/* … */],
    cellBorders: {
        all: true,            // shorthand for top+right+bottom+left
        color: '#cccccc',     // PdfColor — default '0.8 0.8 0.8'
        width: 0.5,           // points — default 0.5
        style: 'solid',       // 'solid' | 'dashed' | 'dotted'
    },
}
Field Type Default Description
top / right / bottom / left boolean false Enable individual edges.
all boolean false Shorthand — enables all four edges.
color PdfColor '0.8 0.8 0.8' Stroke colour (hex / tuple / PDF-rgb).
width number (points) 0.5 Stroke width.
style 'solid' | 'dashed' | 'dotted' 'solid' Stroke style. The dash is reset after each cell so row separators stay solid.

Borders are pure vector strokes (m … l S), so output stays PDF/A-safe.

cellVAlign (v1.4.0)#

Vertically align cell text within the row band. Useful when rows have a tall minRowHeight or wrapped cells of differing height. A per-column ColumnDef.vAlign overrides the table default.

{ type: 'table', headers: [/* … */], rows: [/* … */], minRowHeight: 30, cellVAlign: 'middle' }

'top' | 'middle' | 'bottom'. When omitted, the historic baseline placement is preserved exactly (byte-identical to pre-1.4.0).


ColumnDef.kind field (optional)#

Field Type Default Description
kind 'amount' undefined Semantic hint. When set to 'amount', data cells in this column render in Helvetica-Bold with credit/debit colouring driven by row.type. Replaces the pre-1.2.0 hardcoded i === 3 heuristic in renderTable.
{
  type: 'table',
  headers: ['Date', 'Description', 'Status', 'Amount'],
  columns: [
    { f: 0.20, a: 'l', mx: 12, mxH: 12 },
    { f: 0.45, a: 'l', mx: 60, mxH: 60 },
    { f: 0.20, a: 'l', mx: 20, mxH: 20 },
    { f: 0.15, a: 'r', mx: 18, mxH: 18, kind: 'amount' }, // ← opt-in bold + credit/debit colour
  ],
  rows: [
    { cells: ['2026-05-01', 'Salary', 'Cleared', '+3 000.00'], type: 'credit', pointed: false },
    { cells: ['2026-05-03', 'Rent',   'Pending', '-1 250.00'], type: 'debit',  pointed: false },
  ],
}

Behaviour change for document-builder tables without kind. Previously the renderer applied Helvetica-Bold + credit/debit colour to whichever column happened to be at index 3. The current renderer removes that heuristic — opt in explicitly via kind: 'amount'. The legacy buildPDF() (financial-statement) path keeps the historical heuristic for byte-identical v1.0/v1.1 output.


How multi-page tables are sliced#

pdfnative uses a two-phase pipeline:

  1. Plan phaseplanTable() (src/core/pdf-renderers.ts) measures the entire table once: resolves columns (including autoFit), word-wraps each cell according to wrap, computes per-row heights, and produces a TablePlan containing every row's exact pixel height.
  2. Slice phase_paginateBlocks() in src/core/pdf-document.ts walks the plan greedily: it packs rows onto the current page until the next row would overflow, then emits a TableSlice ( { fromRow, toRow, drawCaption, drawHeader, isFinalSlice }) and starts a new page. The caption is emitted once (on the first slice); the header is emitted on every slice when repeatHeader: true.

renderTable() is page-lifecycle-free — it accepts an optional slice parameter and renders exactly the rows the paginator asked for. There is no recursive "if I overflow, start a new page" inside the renderer; pagination decisions are deterministic and centralised.

Edge cases handled#


Tagged-mode / PDF/UA#

When the document is built with tagged: true (or any explicit PDF/A mode), the table emits the following structure tree:

/Table
├── /Caption       (only when caption is present)
├── /TR  ← header
│   ├── /TH
│   ├── /TH
│   └── /TH
├── /TR  ← data row 1
│   ├── /TD
│   ├── /TD
│   └── /TD
└── /TR  ← data row N
    └── …

The structure is single even when the table spans multiple pages. _paginateBlocks() shares a tableStructAccum array across all slices of the same table; the final slice commits it as { type: 'Table', children: tableStructAccum }. Each /TR carries the correct /StructParents for its page so screen readers reconstruct the logical reading order correctly (ISO 14289-1 §7.10.6).

Repeated headers in repeatHeader: true mode are not re-emitted in the structure tree — they are visual continuations only. The single /TR for the header sits at the top of the /Table element.


Tagged-mode + zebra#

Zebra fills are decorative — they do not appear in the structure tree. PDF/UA conformance is preserved.

⚠️ PDF/A-1b note. PDF/A-1b forbids transparency (ISO 19005-1 §6.4). Zebra fills are opaque solid rectangles, so they are safe under PDF/A-1b, but avoid combining zebra with pdfa1b watermarks that rely on /ExtGState. Default tagged: true (PDF/A-2b) has no such restriction.


Migration from v1.1.0#

One unconditional fix. Right- and centre-aligned bold header cells now use Helvetica-Bold metrics for width measurement (Adobe AFM), where pre-1.2.0 they were measured with Helvetica-Regular. This corrects a 2–5pt overshoot per cell that visually clipped the trailing glyph (e.g. the t in Amount). The fix shifts header glyph positioning by 2–5pt vs v1.1.0 — a genuine correctness improvement, not a regression. There is no opt-out.

You want… Setting
Exact byte-identical v1.1.0 multi-page body output wrap: 'never', repeatHeader: false (header positioning still corrected)
Modern default (recommended) Omit all new fields — defaults are correct.
Invoice / report parity with commercial libs wrap: 'auto', repeatHeader: true, zebra: true, caption: '…'
Uniform row heights regardless of content wrap: 'always', minRowHeight: 18
Maximum information density wrap: 'auto', cellPadding: 2, minRowHeight: 10

Samples shipped#

Run npm run test:generate to produce:

Generator: scripts/generators/document-table-parity.ts.


Reference#

Internal contracts (for contributors)#


See also#