Skip to content

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.

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 pages
  • Order is preserved — children appear in input order.
  • No data loss — every child (or split piece) appears in exactly one page group.
  • Height trackingremainingHeight always reflects free vertical space on the current page.

Accurate pagination needs render-based measurement: the engine renders each node to learn its true height.

1. Inject 1px layout markers above/below the node
2. Wrap in a full-width flex container
3. Render to SVG via Satori (width = contentWidth, height = 5000)
4. Parse SVG with Resvg
5. Call getBBox() → bounding box
6. 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.

  • measureRow() — measure each cell at its resolved column width; row height = max cell height. Resolved width is applied last so it overrides user width in cellStyle.
  • measureTableSegment() — header height (if any) + sum of row heights.
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.

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> 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.

When <Text> overflows, Nebula splits at a word boundary using fontkit metrics:

  1. Extract plain text
  2. Compute average character width, line height, chars per line
  3. Estimate how many lines fit in remainingHeight
  4. Snap to a nearby space/newline
  5. Emit two spans: fits and overflow

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.

<Page padding={40}>
<Text style={{ fontSize: 12, lineHeight: 1.4 }}>
{/* Long body — may span multiple pages automatically */}
{longReportBody}
</Text>
</Page>

<Table> emits a table-internal marker intercepted by the layout engine. Tables need special handling because:

  1. Row integrity — a row must never split mid-row
  2. Header repetition — continuing pages can repeat the header (headerRepeat, default on)
  3. Column widths — fixed / % / flex must resolve before measuring rows
  4. Per-row height — wrapping cells change row height
interface TableSegment {
header: boolean;
rows: any[];
resolvedWidths: number[];
}
currentHeight = remainingHeight
currentRows = []
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 segment
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.

  • 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
<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>
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
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