Escape
HomeDocsGetting started

Getting started

Installing the engine and a render target

The engine and the targets ship as separate packages. quario compiles definitions and emits an event stream. Each target consumes that stream and writes one kind of output. A service that only ever returns pages never carries the PDF typesetter.

Each target declares the engine as a peer dependency. Install quario alongside whichever targets you render with, and add another later without touching the definitions you have already written.

shell
npm install quario @quario/html @quario/pdf

The packages run on Node 22 or newer. They’re ESM-only, so there is no CommonJS build to fall back on. In the browser they need a bundler that understands ES2024.

Creating an instance that carries locale and currency

An instance holds host configuration rather than document content. Locale, currency, timezone, the license key and the JSONPath budgets all live here, because each describes the deployment, not the report.

report.js
import { quario } from 'quario'

const q = quario({ locale: 'en-IE', currency: 'EUR', timeZone: 'UTC' })

Presentation policy belongs to the host. The same definition reads as €15,076.60 under en-IE and as $15,076.60 under en-US, with nothing in the JSON changing. A definition that hard-coded the symbol would need one copy per market.

One instance serves every report your service renders. Build it at startup and keep it. The instance verifies the license key there, once, offline.

Describing the invoice as a definition

Starting from the record you already have

The tutorial renders one invoice record from your application. Its shape owes nothing to quario. It’s the object your ORM or your API already hands you, with the line items nested under the invoice.

the record you already have
const invoice = {
  number: 'INV-2043',
  issued: '2026-08-14',
  due: '2026-09-13',
  customer: { name: 'Northwind Trading', vatId: 'IE6388047V' },
  lines: [
    { project: 'Rollout', item: 'Discovery workshop', qty: 2, unitPrice: 950, vatRate: 21 },
    { project: 'Rollout', item: 'Integration build', qty: 12, unitPrice: 680, vatRate: 21 },
    { project: 'Support', item: 'Retainer, August', qty: 1, unitPrice: 2400, vatRate: 21 }
  ]
}

Selecting the rows the report walks

A definition is plain JSON. You store it beside your migrations, put it through review, and diff it when a customer asks why last month’s invoice looked different. Expressions in it parse into closures when the definition compiles, so none of it ever runs as JavaScript source.

data is a JSONPath that selects the rows the report walks. Here it reaches the invoice’s line items, which gives the report three rows. Everything outside that path stays readable through $.input.

Reading values into the header

An interpolation reads a value into a line of text. {{ }} marks one, and the target escapes whatever comes out of it. quario has no syntax for an unescaped interpolation. No definition can opt a data value out of escaping.

A value may be a list of runs rather than one string. The issue date is two runs, Issued and the interpolation, so the cell's format reaches a run holding a single value. Step 4 says why a format needs one value to speak about.

$.input reaches the data you hand to render. A header draws before any row, and it reads the whole payload. $.input.invoice.number is the invoice number from the record above.

A split item places cells across the band. Stacked text items each take a line of their own, which suits an address block and not an invoice number sitting opposite an issue date. Each slot takes a width as a percentage, and the last slot takes what’s left.

Every report header item states its own size. Targets give that band a headline default of bold 14pt, and it sits above the document’s own style. An item declaring no size renders headline-sized on the page and in the PDF alike.

invoice.report.json
{
  "data": "$.invoice.lines[*]",
  "style": { "family": "sans", "size": 10 },
  "header": [
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "Invoice No. {{ $.input.invoice.number }}", "width": 60,
          "style": { "size": 18, "bold": true } },
        { "type": "text", "value": [{ "value": "Issued " }, { "value": "{{ $.input.invoice.issued }}" }],
          "style": { "size": 9, "align": "right", "format": "date" } }
      ]
    },
    { "type": "text", "value": "{{ $.input.invoice.customer.name }}",
      "style": { "size": 10, "bold": true, "spaceBefore": 24 } },
    { "type": "text", "value": "VAT {{ $.input.invoice.customer.vatId }}",
      "style": { "size": 9 } }
  ]
}

Turning the line items into a table

A detail band becomes a table when it declares columns. Handed a list of items instead, quario draws them as stacked bands and repeats them per row. With columns it emits a real table. That reaches HTML as thead and tbody, the PDF as a header row repeated on each page, and a workbook as a frozen pane.

@ is the row the report is currently walking. Inside a column’s value it holds one line item, so @.qty is that line’s quantity and @.qty * @.unitPrice is its amount. The arithmetic runs in the expression, where your reviewers can read it.

invoice.report.json — detail
{
  "detail": {
    "columns": [
      { "header": { "value": "Item" }, "value": "{{ @.item }}", "width": 46 },
      { "header": { "value": "Qty", "style": { "align": "right" } },
        "value": "{{ @.qty }}", "width": 10, "style": { "align": "right" } },
      { "header": { "value": "Unit", "style": { "align": "right" } },
        "value": "{{ @.unitPrice }}", "width": 16,
        "style": { "align": "right", "format": "currency" } },
      { "header": { "value": "VAT", "style": { "align": "right" } },
        "value": "{{ @.vatRate / 100 }}", "width": 10,
        "style": { "align": "right", "format": { "kind": "percent", "digits": 0 } } },
      { "header": { "value": "Amount", "style": { "align": "right" } },
        "value": "{{ @.qty * @.unitPrice }}", "width": 18,
        "style": { "align": "right", "format": "currency" } }
    ]
  }
}

Keeping amounts as numbers

format presents a bare interpolated number. Interpolate @.unitPrice on its own, declare "format": "currency" in its style, and the cell reads €950.00 on the page while staying the number 950 in a workbook. Formatting it yourself with toFixed would make it a string on every surface. A format applies only where its run renders one value and nothing else. That’s why step 3 split the issue date into runs rather than formatting the whole line.

format is a closed kind rather than a pattern string. The four kinds are number, currency, percent, and date, and each carries at most one modifier. The VAT column declares { "kind": "percent", "digits": 0 } so it reads 21% instead of 21.00%.

A column’s width is a percentage share of the table. The five columns here add up to 100. Padding and borders come out of that share rather than adding to it.

Subtotalling per project and totalling the invoice

Declaring the aggregates

An aggregate is a reducer name joined to an expression. sum:[email protected] * @.unitPrice runs sum over every row the report walks and stores the result under the name you gave it. The built-in reducers are sum, count, countDistinct, avg, min, and max.

A named group puts its own values in scope. Calling the group project makes project.key the value it grouped by and project.lineTotal its own aggregate. Report-level aggregates stay on $, so $.subtotal covers the whole invoice while project.lineTotal covers one project.

Choosing where a total lands

A total block draws at the foot of the table. Under a group band the table emits once per group, and the total block goes with it. That makes it the right place for a per-project subtotal and the wrong place for the invoice total. Its cells carry span to reach across columns, so the row reads as a label and an amount instead of a line of empty spacers.

The report footer draws once for the whole document. Subtotal, VAT, and total due belong there, each a split with the label on the left and the amount on the right. A single-project invoice hides the distinction, because both bands then draw once.

round wraps arithmetic a cell does for itself. Summing money in binary floating point produces values like 83.75999999999999, which a page presents as €83.76 while a workbook stores every digit. Wrapping the expression in round(…, 2) settles the stored number as well as the presented one.

invoice.report.json — groups and totals
{
  "aggregates": {
    "subtotal": "sum:[email protected] * @.unitPrice",
    "vat": "sum:[email protected] * @.unitPrice * @.vatRate / 100"
  },
  "groups": [
    {
      "name": "project",
      "by": "[email protected]",
      "aggregates": { "lineTotal": "sum:[email protected] * @.unitPrice" },
      "header": [
        { "type": "text", "value": "{{ project.key }}",
          "style": { "size": 9, "bold": true, "spaceBefore": 12 } }
      ]
    }
  ],
  "detail": {
    "total": {
      "style": { "align": "right" },
      "rows": [
        { "cells": [
          { "value": "{{ project.key }} subtotal", "span": 4 },
          { "value": "{{ round(project.lineTotal, 2) }}", "style": { "format": "currency" } }
        ] }
      ]
    }
  },
  "footer": [
    {
      "type": "split",
      "style": { "spaceBefore": 16 },
      "slots": [
        { "type": "text", "value": "Subtotal", "width": 82,
          "style": { "size": 9, "align": "right" } },
        { "type": "text", "value": "{{ $.subtotal }}",
          "style": { "size": 9, "align": "right", "format": "currency" } }
      ]
    },
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "VAT", "width": 82,
          "style": { "size": 9, "align": "right" } },
        { "type": "text", "value": "{{ round($.vat, 2) }}",
          "style": { "size": 9, "align": "right", "format": "currency" } }
      ]
    },
    {
      "type": "split",
      "style": { "paddingTop": 5, "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
      "slots": [
        { "type": "text", "value": "Total due", "width": 82,
          "style": { "size": 9, "bold": true, "align": "right" } },
        { "type": "text", "value": "{{ round($.subtotal + $.vat, 2) }}",
          "style": { "size": 9, "bold": true, "align": "right", "format": "currency" } }
      ]
    }
  ]
}

Rendering the definition into a page

Compiling once and rendering per dataset

report compiles the definition once for every later render. Every expression in the document parses into a closure at that point, which is the expensive step. render then walks your data through those closures, Parsing costs you once per definition, not once per invoice.

The compiled report is the object your service holds. Compile it at startup, or the first time something asks for a definition, and keep it. A request then supplies a dataset and nothing else.

render.js
import { html } from '@quario/html'

const report = q.report(definition)

const invoice = await getInvoice()
const fragment = await report.render(html(), { invoice })

Dropping the fragment into your page

The HTML target resolves a fragment for your own page shell. It’s a div with class names on it and no document around it. Drop it into a template, an email body, or a server-rendered response.

The target escapes every interpolation at its own edge. Escaping belongs to the target, not to the definition, and nothing in the event stream arrives pre-escaped. Writing {{{ }}} is a definition error.

The reference stylesheet is a separate import. The fragment carries no presentation beyond the styles a definition asked for, which leaves the class names yours to restyle.

page.css
@import '@quario/html/style.css';

Unlicensed output carries an evaluation marking. Rendering without a license key marks the output, and in HTML that’s a banner above the fragment. It’s the first thing you will see on the first render. Evaluation has no time limit.

Rendering the same definition as a PDF

The same compiled report renders through a second target. The definition does not change, the data doesn’t change, and the call differs only in the factory it’s handed. Describing a document as data is what buys that.

The PDF target writes the document without a browser. It typesets the pages itself and writes the file directly. Nothing in the path needs Chromium in the container or a screenshot step in the middle.

Setting the page geometry

Page geometry is a host option rather than a document field. pdf({ page }) takes a named size or a pair of points, along with a margin. A definition that named A4 itself would be a definition you couldn’t print on Letter.

A factory reads its options once, at the call, and refuses the ones it doesn’t know. An unrecognised key, a key of the wrong type, or an options value that isn’t an object throws a TypeError naming the path — options.page.size. A typo then costs one stack trace where you wrote it, rather than the option you meant to set. Nullish is absence at either level, so { meta: null } and a meta whose one property is undefined are both properties you didn’t write.

The key sets differ by target, because the formats do. pdf() takes page, meta and fonts; docx() takes page and meta; xlsx() takes meta; html() takes fonts and paths; csv() takes nothing. So one options object shared across several targets holds only as long as every one of them knows every key in it.

A page band draws on every page. page.footer sits on the page’s baseline instead of following the last row. Inside it, {{ page.number }} and {{ page.total }} are readable. Targets that don’t paginate ignore the band.

invoice.report.json — page band
{
  "page": {
    "footer": [
      { "type": "text", "value": "{{ $.input.invoice.number }} · page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ]
  }
}
render.js
import { pdf } from '@quario/pdf'

const bytes = await report.render(
  pdf({ page: { size: 'A4', margin: 54 }, meta: { title: 'Invoice INV-2043' } }),
  { invoice }
)

The render resolves a Uint8Array. Write it to disk, put it in object storage, or send it as a response body. The bytes are deterministic. One definition and one dataset produce the same file every time.

Catching a broken definition before it ships

plan compiles a definition without rendering anything. It hands back the compiled report, or null when the document has problems, along with the problems themselves. Running it over the definitions you ship is a test that needs no data.

plan reports warnings beside problems. A warning is a declaration the engine will quietly drop, such as a format on a line that mixes text and a value. The definition still compiles, so a warning never blocks a build on its own. It’s what would have caught the issue date in step 3 had it stayed one string.

A problem names the schema path it sits at. detail.columns[3].value tells you which cell to open, and the message carries the offending source beside it. Problems come back as a list, and a failing check prints all of them at once.

check.js
const { report, problems, warnings } = q.plan(definition)

for (const problem of problems) console.error(`${problem.path}: ${problem.message}`)
for (const warning of warnings) console.warn(`${warning.path}: ${warning.message}`)

if (!report) process.exitCode = 1

A definition that stops compiling is the failure that hides longest. Definitions sit outside your type system, so an engine upgrade that changes a shape breaks them quietly until someone renders one. This site taught a superseded detail.total shape in two guides for a full release before a build-time check caught it.

One definition. A page, and a PDF.

npm install quario
Getting started
© 2026 quario · KvK 61815977