Customising

Change the built-ins, hide fields, hide interface, fill slots, and make the chrome look like your product.

@storvexa/pdf-builder Chapter 7 of 14
Documentation

Images: saving to your own API

The package uploads nothing itself. Two functions connect it to your storage — one to put a file somewhere, one to turn what you stored back into something a browser can show.

The PHP endpoint

#[Route('/api/uploads', methods: ['POST'])] public function upload(Request $request): JsonResponse { $file = $request->files->get('file'); // YOUR rules — the package enforces no size or type limit of its own. if (!$file || !in_array($file->getMimeType(), ['image/png', 'image/jpeg', 'image/webp'], true)) { return $this->json(['error' => 'Unsupported file'], 415); } $name = bin2hex(random_bytes(8)) . '.' . $file->guessExtension(); $folder = $request->request->get('folder', 'documents'); $file->move($this->getParameter('kernel.project_dir') . "/public/uploads/$folder", $name); // Return a PATH, never a URL. The document stores this string verbatim. return $this->json(['path' => "$folder/$name"]); }

The two props that use it

<PdfBuilder imageStoragePath={`documents/${documentId}`} onUploadImage={async (file, ctx) => { const body = new FormData() body.append('file', file) body.append('folder', ctx.storagePath) const res = await fetch('/api/uploads', { method: 'POST', body }) if (!res.ok) throw new Error('Upload failed') // throwing surfaces an error to the user const { path } = await res.json() return { path } // only `path` is required }} resolveImageUrl={(path) => `/uploads/${path}`} />
Why a path and not a URL? Move to a CDN tomorrow and you change resolveImageUrl — one line — and every document ever saved follows. Store URLs and you would be rewriting historical rows.

The full set of image props, including deletion and a picker for previous uploads, is in Images & data.

Labels: change any word in the interface

Every visible string is a key — 191 of them. Nothing is hardcoded inside a component.

Getting the list

import { LABEL_KEYS, en } from '@storvexa/pdf-builder' Object.keys(LABEL_KEYS).length // 191 en['toolbar.save'] // 'Save' — the English default // Print every key with its current wording, to find the one you want: Object.entries(en).forEach(([k, v]) => console.log(k.padEnd(34), v))

Overriding

<PdfBuilder labels={{ 'toolbar.save': 'Save draft', 'block.lineItems.name': 'Order lines', 'panel.blocks': 'Components', }} />
A custom block has to bring its own labels. Its labelKey resolves through the same mechanism, so a key nothing resolves renders as itself — which is how you notice a missing label instead of shipping a blank control. The live demo does exactly this; the code is in Custom blocks.

Colours: two separate things

This is the distinction that causes the most confusion, so plainly: the document and the editor around it are coloured separately, and changing one never changes the other.

To changeUseAffects
Colours on the page — headings, rules, table bandstokensWhat prints
Colours of the editor — buttons, panels, borderstheme / --sb-*Only the interface

The document's own colours

createDocument({ tokens: { primaryColor: '#0054a6', // headings, rules, table header bands secondaryColor: '#60b0ff', textColor: '#1a2b45', fontFamily: "'Inter', sans-serif", }, })

The editor's buttons and chrome

<PdfBuilder theme={{ '--sb-accent': '#0054a6', // every primary button, selection outline, active tab '--sb-accent-hover': '#003f7f', // their hover state '--sb-accent-bg': '#eaf2fb', // tinted fills: palette icon tiles, drop targets '--sb-accent-fg': '#ffffff', // text ON an accent fill '--sb-radius-control': '4px', // squarer buttons and inputs }} />
Button colour is --sb-accent. One value moves every primary button, the selected-block outline, the active inspector tab, the drag handles and the drop indicators together — because they are all the same idea.
Do not target the internal class names. They are CSS-module hashes and change between releases. Custom properties and slots are the supported surface; nothing else is.

Changing built-in blocks

Four methods, in increasing order of violence. Each returns the registry, so they chain.

MethodDoes
register(def)Adds a new block.
extend(type, patch)Merges a partial patch into an existing definition. The gentlest option.
addLayout(type, layout)Adds one design to an existing block.
override(def)Replaces a definition wholesale.

And to read it: get(type), has(type), list(), clone().

import { createBuiltinRegistry } from '@storvexa/pdf-builder' const registry = createBuiltinRegistry() // Change what a new signature block starts with. .extend('signatureArea', { defaultContent: { signerTitle: 'Authorised signatory' }, }) // Add one of your brand's designs to a built-in. .addLayout('documentTitle', { id: 'acme-banner', nameKey: 'design.acme.banner', category: 'bold', style: { background: '#0054a6', color: '#fff', padding: { top: 20, bottom: 20 } }, }) // Cap how many can exist. .extend('coverPage', { maxInstances: 1 }) <PdfBuilder registry={registry} />
Prefer extend over override. extend keeps everything you did not mention — including designs and schema improvements that arrive in later versions. override freezes your copy in time.

Removing a block from the palette

Build the registry from a filtered list rather than deleting from it. That way the definition still exists for documents that already contain one, and an old document does not lose its content.

import { BlockRegistry, builtinBlocks } from '@storvexa/pdf-builder' const registry = new BlockRegistry( builtinBlocks.filter((b) => b.category !== 'products'), )
Removing a block type does not remove it from saved documents. Blocks of an unregistered type are dropped when the document is normalised. If real documents already use it, hide it from the palette instead of removing the definition.

Hiding and relabelling fields

The fields prop controls individual inspector fields without touching the block definition. The key is '{blockType}.{tab}.{fieldKey}', and * is a wildcard at any position.

<PdfBuilder fields={{ // Hide one field on one block. 'lineItems.content.taxColumn': { hidden: true }, // Show it but do not let anyone change it. 'documentMeta.content.number': { disabled: true }, // Rename it to your product's vocabulary. 'billingAddress.content.company': { label: 'Account name' }, // Hide a field everywhere it appears. '*.style.letterSpacing': { hidden: true }, // Lock down a whole tab on one block. 'signatureArea.style.*': { disabled: true }, }} />
hiddenThe field does not render.
disabledVisible, greyed out, not editable.
labelReplaces the resolved label with your literal string.

What each one looks like

fields={{ 'documentMeta.content.number': { label: 'Ref no.' }, 'documentMeta.content.poNumber': { disabled: true }, 'documentMeta.content.dueDate': { hidden: true }, }}
Inspector · Document details
Ref no.
INV-0042
PO number
PO-9981
Issue date
12 March 2026
Due date is not here at all — hidden removes it.
disabled and hidden answer different questions. Use disabled when the user should see the value but not change it — an invoice number your system owns. Use hidden when the field is irrelevant to them and its presence is just noise.
The most specific rule wins — not the last one. 'lineItems.content.tax' beats 'lineItems.content.*', which beats '*.content.*'. Order in the object is irrelevant, so you can list rules however reads best.
The tab segment is content, style or template. template is the Designs tab. 'x.designs.y' matches nothing.

Hiding interface

The ui prop takes three lists of element names. Names are hierarchical, and a parent implies its children.

<PdfBuilder ui={{ hide: ['toolbar.theme', 'panel.properties'], disable: ['toolbar.save'], }} />
NameWhat it is
toolbarThe whole toolbar.
toolbar.saveThe Save button.
toolbar.undo / toolbar.redoHistory buttons.
toolbar.themeLight/dark switch.
toolbar.templatesThe template picker.
toolbar.swapSwap the panel sides.
toolbar.aiThe AI design button — present only when onAiDesign is passed; this hides it anyway.
panel.blocksThe left blocks panel.
panel.propertiesThe right inspector.
show beats hide at equal or greater specificity. So hide: ['toolbar'] with show: ['toolbar.save'] keeps the toolbar present with only Save on it — the container survives because something inside it was explicitly kept.

Slots and replacements

Put your own nodes into named positions.

<PdfBuilder ui={{ slots: { toolbarEnd: <MyShareButton />, canvasEmptyState: <MyGettingStarted />, }, components: { Toolbar: MyToolbar, // replace ours entirely }, }} />
SlotWhere
toolbarStart / toolbarEndEither end of the toolbar.
blocksPanelHeaderAbove the block palette.
propertiesPanelHeaderAbove the inspector.
canvasEmptyStateShown on an empty document.

Replaceable components: Toolbar and EmptyState.

Replacing the Toolbar means owning it. Save, undo, redo, zoom, preview, export and the AI button all live there. Prefer a slot.

Fonts

Typography exists at three levels, and knowing which one you want saves a lot of confusion — they look similar and do different jobs.

LevelSet withAffects
The document defaulttokens.fontFamilyEvery block that has not chosen its own.
One blockThe font field in the Style tabThat block only. Empty means "follow the document".
The editor's own UI--sb-font-uiPanels, buttons, labels. Not the document.

Which typefaces users can pick

The fonts prop is the list offered in every font picker.

<PdfBuilder fonts={[ "'Inter', sans-serif", "'Source Serif 4', Georgia, serif", "'JetBrains Mono', monospace", ]} />
fonts REPLACES the default list — it does not extend it. Pass three and your users see three. If you want the built-ins plus yours, say so explicitly:
const BUILT_IN = [ 'Helvetica, Arial, sans-serif', 'Arial, sans-serif', 'Georgia, serif', 'Times New Roman, serif', 'Courier New, monospace', 'system-ui, sans-serif', ] <PdfBuilder fonts={[...BUILT_IN, "'Inter', sans-serif"]} />

Setting the document's default typeface

// on a new document createDocument({ tokens: { fontFamily: "'Inter', sans-serif", baseFontSize: 11 } }) // or as the default for every document made under a theme <PdfBuilder themes={[{ id: 'acme', name: 'Acme', tokens: { fontFamily: "'Inter', sans-serif" } }]} theme="acme" />

Loading the actual font files

The package does not load webfonts — it has no network access of any kind. Load them the way you load any other font in your application, and the builder will use them.

/* your own stylesheet, or a <link> in your document head */ @font-face { font-family: 'Inter'; src: url('/fonts/inter.woff2') format('woff2'); font-display: swap; }
Use the same CSS font-family string everywhere. The string in fonts, in tokens.fontFamily and in your @font-face must match. A mismatch does not error — the browser silently falls back, and the document quietly renders in something else.
Custom fonts do not survive into the PDF unchanged. The built-in export maps typefaces to the 14 standard PDF families, so a custom webfont is approximated rather than embedded. Choose faces that substitute convincingly, or render server-side with onRenderPdf. See Preview & PDF.
Locking typography down entirely. Pass a single-entry fonts list, or hide the control outright with fields={{ '*.style.fontFamily': { hidden: true } }} — useful when brand compliance matters more than user choice.

Theming the chrome

The builder's own interface — not the document — is styled with public --sb-* custom properties. Override them and your host CSS never needs !important.

<PdfBuilder theme={{ '--sb-accent': '#0054a6', '--sb-surface-1': '#ffffff', '--sb-border': '#e9ecef', '--sb-text-primary': '#1a2b45', }} />

Or in your own stylesheet, scoped to the builder's root:

.sb-root { --sb-accent: #0054a6; --sb-radius-card: 12px; --sb-font-ui: 'Inter', sans-serif; }

The token families you can rely on:

--sb-accent, -hover, -bg, -fgThe accent colour and its variants.
--sb-surface-0-1Panel and background surfaces.
--sb-text-primary, --sb-border, --sb-border-strongText and edges.
--sb-canvas-bg, --sb-page-bg, --sb-page-shadowThe canvas and the page sheet.
--sb-radius-card, -control, -panelCorner radii.
--sb-space-1-12The spacing scale.
--sb-font-ui, --sb-font-size-bodyInterface typography.
--sb-success, --sb-danger, --sb-danger-bgState colours.
--sb-shadow-sm, -md, -lgElevation.
Do not style internal class names. They are CSS-module hashes and they change between releases. Custom properties and slots are the supported surface; everything else is private.

Pass a whole theme object, or several for the user to pick from:

<PdfBuilder themes={[ { id: 'acme', name: 'Acme', colorScheme: 'light', cssVars: { '--sb-accent': '#0054a6' }, tokens: { primaryColor: '#0054a6' } }, // defaults for NEW documents ]} theme="acme" />
cssVars styles the editor; tokens styles the document. Two different things that both look like "theme". tokens only seeds new documents — it never rewrites one a user already made.

Panel layout

<PdfBuilder layout={{ mode: 'split', side: 'end' }} onLayoutChange={(cfg) => remember(cfg)} persistKey="acme-builder" // opt in to browser storage — nothing is stored without this />
Nothing is written to browser storage unless you pass persistKey. No exceptions, no silent local caching. If you want the layout remembered, name the key; if you would rather store it on your server, use onLayoutChange.

Read-only mode

<PdfBuilder document={doc} readOnly />

A viewer rather than an editor: no palette, no inspector, no drag-and-drop, no editing affordances inside blocks. Useful for approval screens and audit views where the document must be seen exactly as it will print.