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.
npm install quario @quario/html @quario/pdfThe 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.
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.
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.
{
"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.
{
"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.
{
"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.
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.
@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.
{
"page": {
"footer": [
{ "type": "text", "value": "{{ $.input.invoice.number }} · page {{ page.number }} of {{ page.total }}",
"style": { "size": 8, "align": "right" } }
]
}
}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.
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 = 1A 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.