Document & Label Designer
A client-side WYSIWYG designer for printable documents and labels — invoices,
exam sheets, production notes, shipping/device-serial labels, and more. Route:
/admin/documents. Code lives in web/components/documents/* and
web/lib/documents/*.
It is print-accurate (millimetre layout), supports multi-page documents, variable-data batch printing (serialised labels), N-up label sheets, reusable blocks (Gutenberg-style synced patterns), and starter templates so a new document is never a blank white sheet. It is fully RTL/Arabic-aware.
Data model (web/lib/documents/types.ts) — version 2
Section titled “Data model (web/lib/documents/types.ts) — version 2”DocTemplate { version: 2 name: string page: PageSpec { widthMm, heightMm, marginMm, background } // shared by all pages placeholders: { key, label, sample }[] // drive {{tokens}} + Preview pages: { id, elements: DocElement[] }[] // multi-page sheet?: SheetSpec // saved N-up layout sequence?: SequenceConfig // saved serial-generator settings}DocElement is a union — text | dynamicText | image | barcode | qr | rect | line | blockInstance — sharing ElementCommon { id, x, y, w, h, rotation, z, locked?, hidden?, opacity? }. Geometry is absolute millimetres; the canvas
converts with PX_PER_MM = 96/25.4 and a CSS transform: scale(zoom).
blockInstance is a pointer ({ type:'blockInstance', blockId }) into the
block library — documents never inline-copy block elements, so editing a block
propagates to every instance.
Versioning: storage.ts#migrateTemplate upgrades legacy v1 (a flat
top-level elements array) to v2 (single page). isDocTemplate accepts both;
persisted templates are migrated on read.
Feature map (where things live)
Section titled “Feature map (where things live)”| Area | Files |
|---|---|
| Composition / state / toolbar | document-designer.tsx |
| Interactive canvas (drag/resize/select, snap guides, grid, rulers, readout) | canvas.tsx |
| Element content renderers (text/barcode/image/…) | element-content.tsx, barcode-svg.tsx |
| Shared non-interactive element layer (blocks + print) | element-layer.tsx |
| Palette (add elements, layers, blocks) | palette.tsx |
| Inspector (element / page / data / batch / sheet tabs) | inspector.tsx |
| Print/export render (all pages × rows, N-up) | print-document.tsx |
| Types + presets | lib/documents/types.ts, presets.ts |
| Local persistence + migration | lib/documents/storage.ts |
| Alignment-snap geometry (pure, unit-tested) | lib/documents/geometry.ts |
| Variable-data batch (serial/CSV/JSON) | lib/documents/batch.ts, csv.ts |
| N-up label sheets | lib/documents/sheet.ts |
| Reusable blocks store | lib/documents/blocks.ts |
| Starter templates + starter blocks | lib/documents/starters.ts |
Editing
Section titled “Editing”Undo/redo (coalesced snapshots), multi-select (shift/⌘-click; group
move/delete/nudge/clipboard), align (to the page for one element, to the
selection bounding box for many), distribute, z-order, lock, hide, opacity,
rotation. Precision aids: alignment snap guides (page/element/margin), a 5 mm
grid overlay, mm rulers, and a live W × H mm readout — all toggleable, edit-only.
Variable data (serialised labels)
Section titled “Variable data (serialised labels)”The Batch tab produces one rendered copy per data row from three sources:
a serial/sequence generator (prefix/start/count/step/zero-pad/suffix), a
CSV/TSV upload, or pasted CSV/JSON. Rows layer over the placeholder
sample data, so {{sku}} in a barcode/text resolves per row. Preview pages
through rows; Print emits rows × pages copies.
N-up label sheets
Section titled “N-up label sheets”The Sheet tab tiles many labels onto one physical sheet (Avery-style:
cols × rows, margins, gutters; presets included). Combined with a batch, rows
flow into cells across sheets. sheet.ts holds the pure layout maths.
Reusable blocks (Gutenberg model)
Section titled “Reusable blocks (Gutenberg model)”Select elements → Save as block → insert as a blockInstance pointer.
Edit block (double-click) opens the block in the full editor; Done
writes it back and every instance updates. Detach inlines an independent
copy. Blocks have scope tiers system / personal / tenant / global. Built-in
system starters (company header/footer) ship so the Blocks panel is never
empty (starters.ts#STARTER_BLOCKS).
Blocks may contain blocks (#1186). A letterhead holding the logo block means the logo is stored once and every letterhead follows it when it changes.
- Resolution is
blocks.ts#flattenBlock, which expands to leaves and returns diagnostics alongside the elements. Three things stop an expansion — the block is gone, it is an ancestor of itself, or nesting exceedsMAX_BLOCK_DEPTH— and all three draw nothing, so elements alone cannot tell “empty” from “broken”. The editor shows a marker; print never does. - A cycle is cut, not refused: the branches that resolve still print. One bad pointer must not blank a page.
- Cycles are refused at insertion (
wouldCycle), which is the honest moment to say no. The case built by accident is indirect: A already holds B, and someone drops A into B later. - The server resolves transitively (
DocumentRenderer::resolveBlocks) — a nested reference lives in the parent block’s data, not the template tree, so a single pass would have sent a payload missing the nested block and printed a hole with no error raised. - Deleting is guarded on both holders: a template pointer
(
DocumentTemplateRepository::referencesBlock) and a block pointer (DocumentBlockRepository::referencesBlock). A block used only by another block was invisible to the template scan alone.
Starters (no white sheet)
Section titled “Starters (no white sheet)”Start from… offers ready, editable templates (Invoice, Exam sheet, Production note, Shipping label). These are the seed source the backend will use to seed each tenant, pre-filled with real company info.
Rendering & printing
Section titled “Rendering & printing”- Canvas (
canvas.tsx) is interactive. Print uses a separate off-screenPrintDocumentthat renders every page for every dataset row, tiled N-up when a sheet is enabled;document-designer.tsxinjects@pageCSS sized to the page (or sheet) and callswindow.print(). Both paths share element rendering;blockInstanceresolves viaelement-layer.tsx#BlockInstanceContent. - Security (CodeQL): no
dangerouslySetInnerHTML. Barcodes/QR are bwip-js SVG rendered as an inertdata:URI<img>(no script/fetch). Imagesrcis hardened tohttp(s)only vianew URL().protocol(regex guards were not accepted by CodeQL). - Document mode (#1186) does NOT go through
PrintDocument. Its content reflows and paginates, and that maths lives in the render service, so a flow-mode template renders throughPOST /render/flow:DocumentRendererbranches ontemplate.mode,FlowTemplatePayloadturns the stored template into the payload, andFlowDocumentRenderer(the same one the SDK’sFlowDocumentuses) applies the tenant’s ceilings and makes the call. There is no client-side flowing renderer, so document mode needs the render container — whichdocuments.render_enabledleaves off by default. The stored shape and the wire shape differ: a template storesflow.contents, the service takesfrontMatter: [{kind}], andFlowTemplatePayloadis that seam. - Server-side PDF goes through the optional
whity_rendercontainer (ghcr.io/<repo>/render), which runsPrintDocument— this file, bundled from source at image build time — inside headless Chromium, so an export and the on-screen preview cannot be two renderers. Deploying it, pairing its tag with the app’s, and sizing its (substantial) memory footprint: Document-Render-Service.md · ADR 0012.
RTL / Arabic
Section titled “RTL / Arabic”Text and dynamic-text elements carry a per-text direction (auto|ltr|rtl,
default auto) applied in edit, preview and print — correct for Arabic and
mixed Arabic/Latin (e.g. a Latin serial inside Arabic). The designer chrome
uses logical CSS so it mirrors under RTL; the canvas page keeps physical mm
coordinates on purpose (print geometry must not mirror). See the
Arabic / RTL KB entry. The server-side PDF renderer must ship Arabic fonts.
Persistence & backend contract
Section titled “Persistence & backend contract”Today templates and blocks live in browser localStorage behind a small seam:
storage.ts (listSaved / saveTemplate / deleteSaved) and blocks.ts
(listBlocks / saveBlock / deleteBlock). The durable, tenant-scoped,
RBAC-gated backend store + per-tenant seeding + server-side PDF are
specified in Tasker tasks 58cdd88a (templates + render) and ca1d8c03
(blocks). When the API lands, repoint the seam — the function signatures stay the
same, so the designer itself is unchanged. RBAC rule: the API returns only the
templates/blocks a user may see (server-enforced); the client filters + offers a
permission picker at publish time.
Extending
Section titled “Extending”- New element type: add to the
DocElementunion +ElementType(types.ts); acaseinelement-content.tsx(and the exhaustiveneverguard); an add button inpalette.tsx; a factory instorage.ts#newElement; inspector fields ininspector.tsx. - New starter template/block: add a factory to
starters.ts(STARTER_TEMPLATES/STARTER_BLOCKS). - New barcode symbology: add to
BARCODE_SYMBOLOGIES(types.ts) — bwip-js ids.
Testing
Section titled “Testing”- Unit (jest):
web/__tests__/documents-*.test.tscover the pure libs (geometry, batch, csv, sheet, storage migration). - E2E (Playwright, admin project):
web/e2e/document-designer.spec.ts— mount, barcode/QR render, dynamic-text interpolation, keyboard/drag, multi-page, batch, sheets, blocks + edit-propagate, RTL, grid/rulers, distribute, starters. Run against a live stack viaE2E_PORT=3000.