Skip to content

Repository files navigation

Print Engine

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.


Pipeline

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.


Minimal example

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.


Monorepo layout

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.

Package responsibilities

  • schema — Defines PrintDocument, PageSetup, Regions, Style, and the Node union (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 an ExpressionEngine interface 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, expands repeat/group against the data (injecting $item / $group into the evaluation context), evaluates field bindings, 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 injected Measurer.

  • adapter-html — Translates a ResolvedNode tree into HTML strings. DomMeasurer implements the core's Measurer contract by rendering a node off-screen and reading its height in mm. renderPages lays out each page as a full A4 sheet with margins as padding, header pinned top, content flex-growing in the middle, footer pinned bottom.


Key design decisions

  • The Measurer seam. The core cannot know how tall a text block is — that depends on fonts and layout, which only the renderer knows. So paginate() depends on a Measurer interface; 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↔mm conversion 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 no IF/comparison operators — that logic belongs upstream.

  • Errors as values in the expression engine. The tokenizer/parser throw internally (convenient inside recursion), but the ExpressionEngine boundary catches them and converts to { ok: false, error }. Exceptions never escape the contract.


Expression language

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 null rather 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 parsed Date (assumes ISO input)

Current status

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 + gap on Style)
  • Independent columns node -- 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 count columns per page: overflow moves to the next column, then to a new page
  • pivot node -- 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.


Roadmap

Columns (independent + newspaper)

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

Pivot tables (fixed rows x dynamic columns)

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.

Other pending work

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 ExpressionEngine contract and the parameterized contract test suite are already in place, waiting for a second implementation to be dropped into the engines[] array.
  • QR code generation — logo/QR are image nodes, 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.

Known limitations

  • 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. The date: 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 columns node must be a direct child of a block: pagination only inspects direct children for a columns, 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-width element 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 pivot needs declared column widths (rowHeaderWidth, columnWidth): there is no content-driven auto-sizing, since the Measurer seam only reports heights.
  • A pivot style dresses every table row (header bands included, with the band style layered on top); it is not a box around the whole table, so padding/border/background repeat per row and a vertical gap between rows is not expressible.
  • If columnSource yields no records a pivot renders 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.

Deliberate non-goals

These are decided, not forgotten.

  • Page numbers ($page.current / $page.total). The browser already numbers printed pages natively (@page counters in print CSS), so the engine does not reimplement them. Doing so would cost a whole extra pipeline stage: resolve() runs before paginate(), 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 $page scope stays in the EvalContext contract so a future renderer without native page numbering can populate it.

Development

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

Conventions & gotchas

  • After creating a new file in a package, add it to that package's index.ts barrel, then rebuild. Cross-package imports resolve through compiled dist/, not source — a change in core is invisible to adapter-html until pnpm build runs. This is the single most common tripwire in this repo.
  • TypeScript is strict with noUncheckedIndexedAccess. Array/object index access is T | undefined; handle it.
  • ESM throughout ("type": "module"). Relative imports use the .js extension even from .ts files (e.g. import { x } from './foo.js').
  • core must never import anything DOM-related. adapter-html is the only package whose tsconfig includes the DOM lib. This boundary is enforced by the compiler, not just convention.
  • The playground is private and noEmit — Vite handles its transpilation; it is not part of the project-reference build graph.

License

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.

About

Crystal Reports-style print engine in TypeScript. Renderer-agnostic core, declarative JSON documents, custom expression language.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages