Layout Engine
The layout engine distributes child VNodes across physical pages using 1D bin-packing: each page is a vertical bin of height contentHeight. Children are placed top-to-bottom until the bin is full, then a new page starts.
Core algorithm
Section titled “Core algorithm”function paginate(children, dimensions): measure every child's rendered height
currentPage = [] remainingHeight = contentHeight
for each child: if child is a Table: run specialized table pagination distribute table segments across pages
else if child fits (height ≤ remainingHeight): add to currentPage remainingHeight -= height
else if child is splittable (Text): split at the boundary add fitting portion to currentPage start new page(s) with overflow
else (atomic — Box, Image): flush currentPage as a completed page start new page with this child
return all pagesInvariants
Section titled “Invariants”- Order is preserved — children appear in input order.
- No data loss — every child (or split piece) appears in exactly one page group.
- Height tracking —
remainingHeightalways reflects free vertical space on the current page.
Measurement system
Section titled “Measurement system”Accurate pagination needs render-based measurement: the engine renders each node to learn its true height.
measureNodeHeight()
Section titled “measureNodeHeight()”1. Inject 1px layout markers above/below the node2. Wrap in a full-width flex container3. Render to SVG via Satori (width = contentWidth, height = 5000)4. Parse SVG with Resvg5. Call getBBox() → bounding box6. Return bbox.height - 2 (subtract markers)Why 5000px height? Unconstrained height so Satori does not clip content.
Why markers? Resvg getBBox() measures painted pixels and ignores CSS padding/margins. Visible 1px markers force the box to include layout padding.
Why render-based? Font-metric estimates cannot account for flex wrap, borders, or nested layout. Measuring through the same Satori path as final output keeps heights honest.
Table measurement
Section titled “Table measurement”measureRow()— measure each cell at its resolved column width; row height = max cell height. Resolved width is applied last so it overrides userwidthincellStyle.measureTableSegment()— header height (if any) + sum of row heights.
Component classification
Section titled “Component classification”| Category | Components | At page boundary |
|---|---|---|
| Atomic | Box, Image |
Entire node moves to the next page |
| Splittable | Text |
Split at overflow; part stays, part moves |
| Specialized | Table |
Row-level pagination + header repeat |
Detection uses displayName / markers (NebulaPdfTable, table-internal, NebulaPdfText). Everything else is treated as atomic.
Box (atomic)
Section titled “Box (atomic)”If a <Box> does not fit in remainingHeight, the entire box moves to the next page. It is never split.
Page 1: Page 2:┌──────────────────┐ ┌──────────────────┐│ Child A │ │ Child C (Box) ││ Child B │ │ moved intact ││ ← no room for C │ └──────────────────┘└──────────────────┘Image (atomic)
Section titled “Image (atomic)”<Image> requires width and height so the engine can reserve space without decoding pixels. Behavior matches Box: never split.
Before layout runs, resolveImages() converts src to base64 data URIs. The layout engine only cares about dimensions.
Text (splittable)
Section titled “Text (splittable)”When <Text> overflows, Nebula splits at a word boundary using fontkit metrics:
- Extract plain text
- Compute average character width, line height, chars per line
- Estimate how many lines fit in
remainingHeight - Snap to a nearby space/newline
- Emit two spans:
fitsandoverflow
Very long text is recursively split across 3+ pages via trySplit().
If splitting fails (empty text, no boundary), the node is treated as atomic and moved to the next page.
Example
Section titled “Example”<Page padding={40}> <Text style={{ fontSize: 12, lineHeight: 1.4 }}> {/* Long body — may span multiple pages automatically */} {longReportBody} </Text></Page>Table (specialized)
Section titled “Table (specialized)”<Table> emits a table-internal marker intercepted by the layout engine. Tables need special handling because:
- Row integrity — a row must never split mid-row
- Header repetition — continuing pages can repeat the header (
headerRepeat, default on) - Column widths — fixed /
%/ flex must resolve before measuring rows - Per-row height — wrapping cells change row height
Segment shape
Section titled “Segment shape”interface TableSegment { header: boolean; rows: any[]; resolvedWidths: number[];}Pagination sketch
Section titled “Pagination sketch”currentHeight = remainingHeightcurrentRows = []
for each row: measure row if row does not fit and currentRows is non-empty: flush segment, start new segment with this row else: append row; subtract heights (including header on first row)
flush final segmentColumn width resolution
Section titled “Column width resolution”| Priority | Type | Example | Resolution |
|---|---|---|---|
| 1 | Fixed (px) | width: 100 |
Exactly 100pt |
| 2 | Percentage | width: '20%' |
20% of contentWidth |
| 3 | Flex | flex: 2 |
Share of remaining space |
| 4 | Default | (none) | Treated as flex: 1 |
All widths are clamped to MIN_DIMENSION (1) to avoid zero-width geometry / Resvg panics.
Segments back into pages
Section titled “Segments back into pages”- First segment → current page (flush if more segments follow)
- Intermediate segments → full pages
- Last segment → starts a new current page so following content can share the page
Example
Section titled “Example”<Page size="A4" padding={40}> <Table columns={[ { header: 'Date', key: 'date', width: 80 }, { header: 'Description', key: 'description', flex: 1 }, { header: 'Amount', key: 'amount', width: 80, align: 'right' }, ]} data={rows} options={{ stripe: true, headerRepeat: true, stripeColor: '#f9f9f9' }} /></Page>Edge cases & safeguards
Section titled “Edge cases & safeguards”| Scenario | Handling |
|---|---|
| Empty page | Returns [[]] |
| Oversized atomic node | Own page; may clip at page height |
| Text that cannot split | Treated as atomic |
| Table with 0 rows | One segment with header only |
| Single row taller than a page | Placed on a new page (never split) |
| Null children | Filtered before measurement |
| Column widths exceed container | Flex may collapse; clamped to MIN_DIMENSION |
| Missing font metrics | Heuristic avgCharWidth ≈ fontSize × 0.55 |
headerRepeat: false |
Header only on first segment |
Glossary
Section titled “Glossary”| Term | Definition |
|---|---|
| VNode | Preact virtual DOM node ({ type, props, key }) |
| PageGroup | VNode[] for one physical PDF page |
| MeasuredNode | { node, height } |
| TableSegment | Header flag + rows + resolved widths for one page |
| contentWidth / contentHeight | Usable area after padding |
| Bin-packing | Filling a fixed-height page with variable-height children |
| Atomic / Splittable | Move whole vs split at boundary |
| Satori / Resvg / pdf-lib | SVG layout, PNG rasterization, PDF assembly |
| MIN_DIMENSION | 1 — minimum width/height |