Core concepts

The document model, where a style actually comes from, and why the canvas and the PDF can never disagree.

@storvexa/pdf-builder Chapter 4 of 14
Documentation

Rows, columns, blocks

Three levels. That is the entire layout model, and it does not get more complicated later.

document.rows = [ { id, columns: [ // a ROW is a horizontal band across the page { id, width, block }, // a COLUMN is a vertical share of that band… { id, width, block }, // …and holds exactly ONE block ]}, ]

A row spans the printable width, and its columns split that width. To put a logo left and an address right, you make one row with two columns.

A column holds exactly one block — Column.block, singular. Not a list. To stack two things vertically you use two rows, not two blocks in one column. This is the single thing people get wrong when they build a document in code.
Rows hold up to three columns in this version. The document model itself allows more — width is a string — but the editor offers 1, 2 or 3 equal shares. Four-, six- and twelve-column rows and asymmetric splits are planned; do not hand-craft JSON that relies on them yet.

Header, footer and cover page

These are not special block slots — they are the same rows-and-columns structure again. That is deliberate: a header with a logo left and document details right is authored exactly the way the body is, with the same blocks and the same drag-and-drop.

document.header = { id, rows: [ … ] } | null document.footer = { id, rows: [ … ] } | null document.coverPage = { id, rows: [ … ], align } | null

They collapse to null when emptied, so an empty band never reserves space on the page. Whether they print is a page setting, not a document-structure question — see showHeader and headerPages below.

What a block is

{ id: 'blk_a1b2', type: 'documentTitle', // which registry definition renders it kind: 'static', // 'static' | 'databound' content: { … }, // WHAT IT SAYS — words, values, image paths style: { … }, // block-level style layoutId: 'boxed', // which design is applied overrides: { … }, // what the user changed BY HAND in the Style tab pageBehavior: { … }, // keepTogether, pageBreakBefore, … }
The most important line in the model: content and presentation never mix. content is what the block says. style, layoutId and overrides are how it looks. Applying a design changes presentation only — the words your user typed are never touched. That guarantee is why a user can try 14 designs on a paragraph without fear.

Page behaviour

Optional per-block hints controlling how it meets a page break. Absent means "flow normally", and only flags set to true are stored, so saved JSON stays small.

keepTogetherNever split this block across two pages.
keepWithNextKeep it on the same page as the block after it — a heading with its paragraph.
pageBreakBefore / pageBreakAfterForce a new page either side.
avoidBreakInsidePrefer not to split, but allow it rather than leave a large gap.

These live in a small "Page behaviour" group in the inspector rather than in the main list, because most blocks never need them.

Where a style comes from

When a block is blue, four different things could have made it blue. They are stacked, and the one nearest the top wins.

Think of it like getting dressed. The document sets the dress code, the block type has its usual outfit, a design changes that outfit, and the user can still change one button by hand. The hand change is the one you end up seeing.

Stacked · weakest at the bottom
4 · What the user changed by hand
Style tab — always wins
3 · The chosen design
Designs tab
2 · How this kind of block normally looks
Built into the block
1 · The document's own settings
Colours, font, spacing
A worked example

The document says text is dark grey.

A heading block normally uses the accent colour, so it is blue.

The user picks the "Dark band" design — now white on navy.

Then the user hand-picks yellow in the Style tab.

You see yellow. And if they switch design again, it stays yellow — a hand change outranks a design.

In code, that stack is one merge:

// weakest ───────────────────────────────────────────────► strongest resolvedStyle = deepMerge( documentTokens, // 1 blockTypeDefaultStyle, // 2 selectedDesignStyle, // 3 block.overrides, // 4 )
This explains the most common confusion: "I applied a design and nothing happened." Almost always, that property had already been changed by hand — so layer 4 is winning. Nothing is broken. Click Reset style on the block and the design appears.
The builder asks before discarding hand changes. Apply a design to a block that has them and you get a choice: keep them, or reset them. It never silently throws away someone's work.

Two details for later: merging goes into nested values, so setting only the top padding leaves the other three sides inherited. Lists and single values replace completely. And blocks receive the finished style — they never work it out themselves.

Design tokens

Document-wide defaults. Change one and every block that has not overridden it follows — the fastest way to make a document feel like a brand.

TokenWhat it does
primaryColorAccent — headings, rules, table header bands.
secondaryColorSupporting accent.
textColorBody text.
fontFamilyDefault typeface. A block only stores a font when it deliberately differs.
baseFontSizeThe size everything else is relative to.
paddingDefault inner spacing, as a four-sided object.
spacingThe rhythm between rows.
localeBCP 47 tag — how dates, numbers and money are written.
currencyISO 4217 code. Stored as a code, never a symbol.
currencyDisplaysymbol, narrowSymbol, code or name.
minimumFractionDigits
maximumFractionDigits
Override the currency's normal decimal rules.
useGroupingThousands separators on or off.

Page settings

SettingValues
sizeA4, Letter or Legal.
orientationportrait | landscape
marginPhysical margins in millimetres — this is what the PDF uses.
showHeader / showFooter / showCoverPageWhether each band prints at all.
headerPagesevery | except-first | first-only
showPageNumbersWith pageNumberPosition (header/footer), pageNumberAlign (start/center/end) and pageNumberFormat.
pageNumberFormatA template — 'Page {page} of {pages}'.
backgroundColor / backgroundImagePathOne visual layer: the colour paints under the image.
backgroundPages / watermarkPagesall | cover | body
Background images are stored as paths, never URLs — like every other image in the document. See Images & data.

Versioning & migration

Every saved document carries a number called version. It is worth understanding, because two of the rules below look arbitrary until you know what it is for.

It is not the version of your invoice. It is the version of the FORMAT. It records which shape the document was written in — where the header lived, what the page settings were called. Nothing to do with drafts or revisions of the content.

The format has changed twice

VersionWhat that shape looked like
1 The original. header, footer and coverPage each held one single block.
2 Those became regions with rows and columns — so a header could finally have a logo on the left and document details on the right, instead of just one thing.
3 today headerFirstPageOnly (a yes/no) became headerPages: 'every' | 'except-first' | 'first-only', because two options were not enough. backgroundPages and watermarkPages were added.

What happens when an old document loads

// A document saved a year ago { version: 1, header: { …one block… }, … } ↓ loaded migrate: 1 → 2 header becomes a region migrate: 2 → 3 headerFirstPageOnly becomes headerPages validate + normalise ↓ { version: 3, header: { rows: [ … ] }, … } ← then it renders
What the user experiences

They open an invoice they made a year ago.

It opens. Everything is where they left it.

They never learn any of this happened — which is the point.

You never write a migration. We do, inside the package. Your job is only this: store the JSON exactly as you were given it, and hand it back exactly as you stored it. Everything above happens on its own.

The two rules, and why they exist

Never edit version by hand. Change a 1 to a 3 and the package believes the document is already up to date, so it skips both upgrade steps. You are left with a version-1 header being read by version-3 code — and the header breaks or disappears. The number is a statement of fact, not a setting.
Never strip keys you do not recognise before saving. Some code "tidies" objects — keeping only the fields it knows about. Do that here and the upgrade has nothing left to convert: headerFirstPageOnly is gone, so version 3 cannot work out whether the header should print on the first page. Save the whole object, unknown fields and all.

If you want to run it yourself

Rarely needed — the builder does this on load — but it is available:

import { migrate } from '@storvexa/pdf-builder' const current = migrate(anythingYouStored) // safe on any version, including none at all

A document with no version field at all is treated as version 1, not as broken — so documents from the earliest days still open.