A Crystal-Reports-style print engine, built from scratch as a study project. It turns a declarative JSON document plus a data object into paginated, print-ready output (currently HTML/PDF via the browser).
The design goal is a renderer-agnostic core: the pagination and data-binding logic knows nothing about the DOM. Rendering lives entirely in a swappable adapter. Today there is one adapter (HTML); the architecture is meant to allow others (canvas, PDF, etc.) without touching the core.
PrintDocument (JSON) + data
│
│ resolve() ← binds data, expands repeat/group, evaluates fields & style expressions
▼
ResolvedDocument (header? / body / footer?) ← a pure structural tree, no expressions left
│
│ paginate() ← splits the body into pages, measures via an injected Measurer
▼
PaginatedDocument (header? / pages[] / footer?)
│
│ renderPages() ← HTML adapter: one A4 sheet per page, header/footer repeated
▼
HTML / PDF
The document JSON is the single source of truth. It can be hand-written or produced by a future visual designer — the engine neither knows nor cares which. This separation is deliberate: the engine is usable today without a designer.
A document that groups lab analyses by ward, prints a colored header per group, lists each analysis as a table row, and shows a per-group sample subtotal:
import { resolve, paginate } from '@print-engine/core';
import { TsExpressionEngine } from '@print-engine/expr';
import { renderPages, DomMeasurer } from '@print-engine/adapter-html';
import type { PrintDocument } from '@print-engine/schema';
const doc: PrintDocument = {
schemaVersion: 1,
page: { size: 'A4', orientation: 'portrait', margin: '15mm' },
body: {
type: 'group',
dataSource: '$.analyses',
groupBy: '$item.ward',
// header colored from a data field via a style expression
groupHeader: {
type: 'field',
bind: '$group.key',
prefix: 'Ward ',
style: { background: '=$group.items[0].color', color: '#fff', weight: 700, padding: '2px 6px' },
},
// one table row per analysis
detail: {
type: 'stack',
direction: 'row',
breakInside: 'avoid', // never split a row across pages
style: { gap: '2mm', padding: '1px 4px' },
children: [
{ type: 'field', bind: '$item.time', style: { width: '18mm', weight: 700 } },
{ type: 'field', bind: '$item.exam', style: { grow: 1 } },
{ type: 'field', bind: '$item.samples', suffix: ' cmp', style: { width: '20mm', align: 'right' } },
],
},
// aggregate over the current group's records
groupFooter: {
type: 'field',
bind: 'SUM($group.items.samples)',
prefix: 'Total: ',
format: 'number:0',
style: { weight: 700, align: 'right', borderTop: '1px solid #ccc' },
},
},
};
const data = {
analyses: [
{ ward: 'Ematologia', color: '#8db3e2', time: '08:00', exam: 'Emocromo', samples: 12 },
{ ward: 'Ematologia', color: '#8db3e2', time: '09:00', exam: 'PT/INR', samples: 8 },
{ ward: 'Biochimica', color: '#e2a8b3', time: '08:30', exam: 'Glicemia', samples: 20 },
],
};
const resolved = resolve(doc, data, new TsExpressionEngine());
const paginated = paginate(doc, resolved, new DomMeasurer());
document.getElementById('output')!.innerHTML = renderPages(paginated, doc.page);This produces two groups (Ward Ematologia, Ward Biochimica), each with a colored header, its rows, and a
subtotal (Total: 20 and Total: 20). The DomMeasurer requires a browser; in Node tests,
swap it for a StubMeasurer / LeafCountMeasurer from @print-engine/core.
pnpm workspaces + TypeScript project references. Build the whole graph from the root with
tsc -b, which respects inter-package dependency order.
packages/
schema/ Document model (discriminated-union node types) + hand-written validator.
expr/ Expression language: tokenizer → recursive-descent parser → tree-walking
interpreter, behind an ExpressionEngine contract.
core/ resolve() (data binding) + paginate() (page-breaking). Renderer-agnostic:
ZERO DOM code. Depends on schema + expr.
adapter-html/ The ONLY package allowed to touch the DOM. renderNode, DomMeasurer,
renderPages. Depends on core + schema.
playground/ Vite app that wires everything together and produces real PDFs. Not a library.
-
schema — Defines
PrintDocument,PageSetup,Regions,Style, and theNodeunion (text,field,stack,repeat,group,image,canvas). Schema-versioned (CURRENT_SCHEMA_VERSION). The validator returns a list of issues rather than throwing — invalid input is an expected outcome, not an exception. -
expr — A small expression language used inside the document (
$.field,$item.x,$group.items[0].color,SUM(...), etc.). Three stages: tokenizer, parser (produces an AST), evaluator (walks the AST). Fronted by anExpressionEngineinterface so a second implementation (e.g. Rust→Wasm) can pass the exact same contract test suite. Errors from malformed input are returned as{ ok: false }values; only genuine engine bugs throw. -
core —
resolve()walks the document, expandsrepeat/groupagainst the data (injecting$item/$groupinto the evaluation context), evaluatesfieldbindings, applies value formatting, and resolves conditional style expressions.paginate()recursively places nodes into pages, descending into a block only when it does not fit as a whole, and delegates height measurement to an injectedMeasurer. -
adapter-html — Translates a
ResolvedNodetree into HTML strings.DomMeasurerimplements the core'sMeasurercontract by rendering a node off-screen and reading its height in mm.renderPageslays out each page as a full A4 sheet with margins as padding, header pinned top, content flex-growing in the middle, footer pinned bottom.
-
The
Measurerseam. The core cannot know how tall a text block is — that depends on fonts and layout, which only the renderer knows. Sopaginate()depends on aMeasurerinterface; the HTML adapter provides a real DOM-based implementation, and tests provide stubs (StubMeasurer,LeafCountMeasurer). This is what keeps the core DOM-free and unit-testable in Node. -
Explicit pagination, not browser pagination. The engine decides page breaks itself and emits fixed-size page containers. The browser only draws boxes that are already the right size. This sidesteps most browser-print discrepancies (the
px↔mmconversion is exact per the CSS spec; real-world drift comes from font substitution and the print dialog's own margins). -
Business logic lives in the data, not the template. Conditional styling is done by binding a style property to a data field (
background: '=$group.items[0].color'). Whoever prepares the data computes the value. The engine reads it. The expression language deliberately has noIF/comparison operators — that logic belongs upstream. -
Errors as values in the expression engine. The tokenizer/parser throw internally (convenient inside recursion), but the
ExpressionEngineboundary catches them and converts to{ ok: false, error }. Exceptions never escape the contract.
Scopes available in the evaluation context: $ (root data), $item (current record inside a
repeat/group), $group ({ key, items } inside a group), $row / $column (the row and column records inside a pivot cell -- both in scope at once), $page (declared but deliberately not populated -- see Deliberate non-goals).
Supported:
- Path access:
$.analyses,$item.time,$group.items[0].color - Array "pluck":
$.analyses.samples→[12, 8, 20] - Aggregates (Excel-style, N mixed args):
SUM,COUNT,AVG,MIN,MAX,CONCAT - Missing paths resolve to
nullrather than throwing.
Style expressions use a = prefix: a style property whose string value starts with = is
evaluated against the current context; anything else is a literal.
Field value formatting via a format string:
number:0.00→ Italian locale, two decimals (1.234,50)date:dd/MM/yyyy→ token replacement on a parsedDate(assumes ISO input)
Working end-to-end. The engine produces faithful multi-page A4 PDFs from data: grouped sections with colored headers, per-group subtotals, aligned tables, repeated page header/footer, row-level page-break control, and data-driven conditional styling.
Implemented:
- Schema + validator
- Expression engine (tokenizer, parser, evaluator) + contract test suite
-
resolve— repeat, group, field binding, canvas, image (literal + bound src) - Value formatting (
number:,date:) - Conditional styles (
=expression prefix) - Pagination — recursive, keeps blocks whole when they fit
-
breakInside: avoid— never split a marked block across pages -
keepWithNext— prevents orphaned group headers at page bottom - Page regions (header/footer repeated on every page)
- Table columns (fixed
width+grow+gapon Style) - Independent
columnsnode -- splits the body flow into K side-by-side sub-flows, each paginated on its own and zipped page-for-page; nestable - Newspaper (snake) columns node -- a single flow snaking through
countcolumns per page: overflow moves to the next column, then to a new page -
pivotnode -- fixed rows x dynamic columns (one column per record), split into horizontal chunks when the table is wider than the page - Wrapper preserved across a page split -- a block that breaks is rebuilt on every page it spans, keeping its style and direction (its padding/border/gap are charged against the page budget)
- HTML adapter: render, DOM measurer, page rendering
- Playground producing real PDFs
Test coverage is meaningful across schema, expr (parameterized over the engine contract), and core (pagination, resolve, formatting, styles) — all runnable in Node without a browser.
Both column modes are implemented. Independent: K separate flows (each child
is a column), paginated on their own and zipped page-for-page; nestable. Newspaper
(snake): a single flow that fills column 1 top-to-bottom, then column 2, then a
new page. The column count comes from count on the node (independent derives it
implicitly from children.length). An atomic block that overflows moves to the next
column, and only when the columns are exhausted to a new page; an item taller than a
full column is placed anyway (it cannot be split further) instead of looping.
Newspaper columns do not force their own page: if content (e.g. a small title) already sits on the current page, the snake starts below it, using the reduced remaining height on that first page and the full column height afterwards. This is the intended behaviour, and will likely become the standard for independent columns too (which today always break to a fresh page before starting).
repeat and group generate dynamic rows with hand-written columns. The pivot
node is the opposite: rows are known up front (rowSource, e.g. the stops) and one
column is generated per record of columnSource (e.g. the runs). Each data cell sees
both records at once via the $row and $column scopes, so a lookup like
$column.times[$row.id] works. headers declares one or more header bands above the
dynamic columns, each with its own optional top-left corner cell.
resolve() materialises the whole grid (rows x columns, no expressions left);
paginate() then splits it horizontally into chunks of consecutive columns that
fit the page width, repeating the row-header column on every chunk. Each chunk is
paginated vertically with the existing engine, and the header bands are re-drawn at
the top of every page. Column widths are declared (rowHeaderWidth, columnWidth),
which is what keeps the split pure arithmetic -- the Measurer still only measures
heights.
Reading order is horizontal-first: all rows for the first group of columns, then the same rows for the next group, and so on.
Roughly in the order they are worth doing: (1) tests for adapter-html -- the only
package with no coverage at all, and where both recent layout bugs actually lived;
(2) rebuilding the wrapper inside newspaper columns too (normal page splits already do it); then the-term items below.
- Rust → WebAssembly rewrite of the expression engine. The primary learning goal of the
project. The
ExpressionEnginecontract and the parameterized contract test suite are already in place, waiting for a second implementation to be dropped into theengines[]array. - QR code generation — logo/QR are
imagenodes, but a QR must be generated from data, not referenced as a static file. Needs a dedicated node type or a generation step. - Designer — a drag-and-drop UI that produces the document JSON. The largest remaining piece. Requires deciding the authoring paradigm (band-based vs. free canvas; the intended answer is a hybrid — a band spine with canvas freedom inside).
- Numeric/date style expressions — currently only string style properties can be
expressions; numeric ones (
weight,grow) are always literal.
- A single atomic node taller than one page overflows rather than splitting (can't split a text block without line-level layout knowledge).
- Inside newspaper columns a block that overflows a column still loses its wrapper (style and direction): the snake placement has its own descent that does not rebuild the container. Normal page splitting does rebuild it.
- Date parsing assumes ISO input; non-ISO strings via
new Date(string)are unreliable. Thedate:format does naive token replacement (only the dd/MM/yyyy tokens; yy, single M/d and month names are unsupported). - The DOM measurer measures nodes in isolation; if inherited/contextual CSS ever affects heights, measured and rendered heights could diverge.
- A
columnsnode must be a direct child of a block: pagination only inspects direct children for acolumns, so one nested deeper inside a block that fits is measured as a single unit (no width division) and can overflow the page. This mirrors the note in the schema. - A fixed-
widthelement is rigid (flex-shrink: 0): content wider than the width overflows the cell instead of shrinking it. This is deliberate -- it keeps table columns aligned from row to row -- but a too-narrow fixed width clips/overflows rather than adapting. - Newspaper columns fill greedily (top-to-bottom, left-to-right): with little content the first column fills and the rest stay empty. There is no balancing across columns or on the last page.
- A
pivotneeds declared column widths (rowHeaderWidth,columnWidth): there is no content-driven auto-sizing, since theMeasurerseam only reports heights. - A
pivotstyle dresses every table row (header bands included, with the band style layered on top); it is not a box around the whole table, sopadding/border/backgroundrepeat per row and a vertical gap between rows is not expressible. - If
columnSourceyields no records apivotrenders nothing at all -- the row headers disappear too. - Horizontal chunks are greedy: the last chunk can hold fewer columns and leave white space on the right.
These are decided, not forgotten.
- Page numbers (
$page.current/$page.total). The browser already numbers printed pages natively (@pagecounters in print CSS), so the engine does not reimplement them. Doing so would cost a whole extra pipeline stage:resolve()runs beforepaginate(), so page counts do not exist yet when header and footer are resolved, and they would have to be re-resolved once per page. Not worth it for something the renderer gives for free. The$pagescope stays in theEvalContextcontract so a future renderer without native page numbering can populate it.
pnpm install
pnpm build # tsc -b at the root — builds all packages in dependency order
pnpm test # vitest run
cd packages/playground
pnpm dev # Vite dev server; open the printed URL, print to PDF from the browser- After creating a new file in a package, add it to that package's
index.tsbarrel, then rebuild. Cross-package imports resolve through compileddist/, not source — a change incoreis invisible toadapter-htmluntilpnpm buildruns. This is the single most common tripwire in this repo. - TypeScript is
strictwithnoUncheckedIndexedAccess. Array/object index access isT | undefined; handle it. - ESM throughout (
"type": "module"). Relative imports use the.jsextension even from.tsfiles (e.g.import { x } from './foo.js'). coremust never import anything DOM-related.adapter-htmlis the only package whosetsconfigincludes theDOMlib. This boundary is enforced by the compiler, not just convention.- The playground is
privateandnoEmit— Vite handles its transpilation; it is not part of the project-reference build graph.
Proprietary. Copyright (c) 2026 ProgrammatoreInCamicia, all rights reserved -- see LICENSE.
The repository is publicly readable so the design can be read and discussed, but no rights to use, copy, modify or redistribute the code are granted. This is a deliberate holding position for a project still taking shape, not a final decision: revisiting it (including opening it under a permissive license) is on the table.