One report, rendered to HTML, PDF and Excel without a headless browser
The ticket says "let customers export their statement." You read it twice looking for the catch, because there doesn't seem to be one. Then the follow-ups arrive. It should look right on screen. It should print to a PDF with the column headers repeated on page two. And finance wants the same thing as a spreadsheet they can pivot, please, by Friday.
Nobody volunteers for this ticket twice.
The three code paths you end up with
The first instinct is to render HTML and print it. You already have the markup, Puppeteer will turn a page into a PDF, done by lunch. Then you containerise it.
- The image. I ran
npm install puppeteeron this machine today: 29 MB of packages, plus a 372 MB Chrome download into the cache before anything renders. That is not a rounding error in a deployment. - The race. Your PDF is a screenshot of a page load, so it inherits every timing problem a page load has. Fonts that weren't ready. An image still in flight. A layout that settled one tick after the capture.
- The second stylesheet. Print CSS disagrees with screen CSS, and you now maintain both. Page numbers and running headers mean
@pagemargin boxes and a hopeful attitude.
So you skip the browser and reach for a PDF library instead. Now the bytes are yours, which sounds like a win until you notice what came with them: pagination, keeping a group heading with its first row, repeating a table header across a page break, embedding and subsetting a font. Typesetting, in other words. By hand, per report.
And the spreadsheet is still a separate code path. Same columns, same totals, written twice. The two copies start drifting the week after they ship, usually in the direction of whichever one the customer looks at more.
There's a fourth trap for anyone doing this in the browser. Plenty of template engines build their expressions with new Function under the hood, which works fine right up to the security review that asks why script-src needs unsafe-eval.
A report is one problem, not four
Those four headaches have one thing in common: the report itself never changed. The bands, the grouping, the totals, the columns are identical in all of them. Only the output differs.
quario is my attempt to put the shared part in one place. You write the report once as a JSON document, compile it once, and then pick an output at the call:
import { quario } from "quario";
import { html } from "@quario/html";
const schema = {
data: "$.orders[*]",
detail: {
columns: [
{ header: "Product", value: "{{ @.product }}" },
{ header: "Price", value: "{{ @.price }}" },
],
},
};
const data = {
orders: [
{ product: "Desk", price: 250 },
{ product: "Chair", price: 120 },
{ product: "Lamp", price: 40 },
],
};
const report = quario().report(schema);
const page = await report.render(html(), data);
page is a fragment you drop into your own shell:
<div class="q-report">
<table class="q-table">
<colgroup><col><col></colgroup>
<thead><tr><th>Product</th><th>Price</th></tr></thead>
<tbody>
<tr><td>Desk</td><td>250</td></tr>
<tr><td>Chair</td><td>120</td></tr>
<tr><td>Lamp</td><td>40</td></tr>
</tbody>
</table>
</div>
One thing up front, because finding it out in paragraph nine is annoying: quario is commercial, source-available software. The evaluation build is free forever and does everything, it just stamps a watermark line on what it renders. More on the licence at the end.
Change the import, not the report
Keep that schema exactly as it is. Swap which target you hand to render:
import { pdf } from "@quario/pdf";
import { xlsx } from "@quario/xlsx";
import { csv } from "@quario/csv";
await report.render(pdf(), data); // 1,411 bytes, a paginated document
await report.render(xlsx(), data); // 6,711 bytes, a real workbook
await report.render(csv(), data); // "Product,Price\nDesk,250\n..."
Those byte counts are from running it, not from a spec sheet. Render the PDF twice and you get the same bytes both times, which means your golden-file tests can compare the file instead of parsing it.
quario does the typesetting itself. Pagination, keep-together, repeating table headers, font embedding and subsetting, a document outline built from your group tree. @cantoo/pdf-lib writes the final bytes. npm install quario @quario/pdf puts 32 MB on disk and there is no browser in it.
The same layout pass also drives the on-screen preview in the <quario-viewer> element, so the preview breaks its pages exactly where the PDF does, instead of looking fine in the modal and wrong in the download.
Why the spreadsheet gets a real number
A cell's value isn't a string on its way through the engine. It's a token stream: literal runs, plus interpolations carrying the value before it gets stringified or escaped. So {{ @.price }} reaches the XLSX target as the number 250, and reaches the HTML target as escaped text.
Unzip that workbook and look at the sheet:
<c r="A3" s="2" t="s"><v>3</v></c>
<c r="B3" s="2"><v>250</v></c>
A3 is the product name, carrying t="s" for a shared string. B3 has no type attribute at all, which in OOXML means a number. Excel will sum that column. You wrote one column definition and both targets did the correct, different thing with it.
So if you want a typed cell, interpolate the bare value and let the target format it. {{ money(@.price) }} is a string everywhere, by your own request.
Two asymmetries you'll notice eventually, both deliberate:
- CSV prefixes formula-shaped text with an apostrophe. A product literally named
=SUM(A1)comes out as'=SUM(A1). A CSV field has no way to say "this is inert text," so the guard goes in the data. - XLSX doesn't. It writes that same value as a shared string, which is inert, so no guard is needed.
Different behaviour, same reason: use the format's own mechanism when it has one.
Reports are data, not code
Because the report is plain JSON, it's an ordinary value in your system. You can store it in a row, diff it in a pull request, send it between services, and hand it to a visual designer that's just another consumer of the same schema. <quario-editor> is exactly that: it edits the band structure and gives you back the document.
Reading any report definition takes three syntaxes:
| Syntax | Means |
|---|---|
"{{ @.product }}" |
A template, a text cell's value. Interpolations are always escaped. |
"[email protected] * @.qty" |
An expression. A leading = on any other property makes it dynamic. |
"sum:[email protected]" |
A reducer spec, an aggregate: a reducer name plus an expression. |
And four anchors those expressions can reach: @ is the current row, $ is the report root, a named group handle like region carries .key plus its own aggregates, and run.<name> holds running totals on a detail row.
That's the whole vocabulary. A grouped sales report with subtotals, a grand total and your own money() formatter is those same four ideas, just more of them.
Every target uses the same seam
report.stream(data) gives you the raw event stream, and it is ordered the way you'd read the report out loud: report-start, header items, then groups and detail rows (group-start, table-start, row, total-row, table-end, group-end), then footers, then report-end.
A target is { name, compile } and nothing else. The shipped ones get no privileged access to the engine, which is what makes an official target and one you wrote yourself indistinguishable to it.
The repo carries the proof as a tested example: a complete Markdown target in 55 lines of code.
export function markdown() {
return {
name: "markdown",
compile: (stream) => async (data) => {
let out = "";
await walk(stream(data), {
item: (e) => { out += inline(e) + "\n"; },
"table-start": (e) => {
out += cells(e.header.cells) + "\n";
out += "| " + e.columns.map(() => "---").join(" | ") + " |\n";
},
row: (e) => { out += cells(e.cells) + "\n"; },
});
return out;
},
};
}
Run the grouped sales example through it and you get GFM pipe tables, subtotals and all. If your company's output is a fixed-width bank file or an ancient EDI format, that's the shape of the work.
Nothing here compiles to JavaScript
Expressions and templates parse to closures. Every operator and every interpolation is a small function that already exists in the shipped source, and the parser wires them together. There's no eval and no new Function anywhere in the stack, so script-src never needs unsafe-eval.
That's enforced three ways: the unit suites run under Node's --disallow-code-generation-from-strings, a per-package source scan fails the build if the strings even appear, and Playwright harnesses load the published files under a strict CSP in a real browser.
The three primitives underneath are separately published, and all three are open source: xprsn for expressions, sjabloon for templates, and padvinder for RFC 9535 JSONPath. I wrote those up separately, because each one turned out to be a post of its own.
One honest caveat. "CSP-safe" here means no string ever becomes code. It does not mean every directive is happy: conditional formatting emits inline style attributes, so a restrictive style-src has to allow them.
Escaping has one home, too. The stream is never markup-escaped, and the HTML target escapes every interpolated value plus every class, style and attribute value it emits. There's no {{{ raw }}} syntax to reach for, because it doesn't exist and asking for it is a definition error. The line quario draws: it trusts report definitions and registered functions, and it does not trust their data.
The total that wasn't a floating point bug
A receipt total came out of the workbook as 83.75999999999999.
I knew that number on sight, and I was wrong about it. The obvious suspect was drift accumulating inside sum:, so I went looking at the aggregate. Every aggregate was exact. Kahan summation would have fixed nothing at all.
The drift was in the cell, in the plain addition the author had written: 72 + 2.52 + 9.24. Try it in a REPL and you'll get the same thing. The actual defect wasn't arithmetic, it was that the person writing the report had no declarative way to say "two decimals, please."
So round, floor, ceil and abs went in as built-in scalar functions. Both of the one-liners everyone reaches for get a different case wrong:
Math.round(1.005 * 100) / 100; // => 1 (want 1.01)
Number((2.675).toFixed(2)); // => 2.67 (want 2.68)
These functions round the decimal the author actually wrote, through Intl.NumberFormat's roundingMode, which is the one place in the platform that reasons about decimals instead of doubles.
What it deliberately isn't
The non-goals are in the spec on purpose, and they're the part I'd read first if I were you:
- Not a query tool.
datais a JSONPath into the object you pass in. There's no database connector and no SQL, so it renders whatever you hand it. - No charts, no barcodes. Deferred rather than declined. Both would compile down to the existing image item. Today you rasterize with your own tool and pass the bytes.
- No hyperlinks. Declined, not deferred. Only one of five targets could honour it, and it is the only declaration whose failure mode is a security question, namely a
javascript:URL in a slot an author fills. - Row groups only. No crosstabs, no matrix, no column groups. A second grouping axis isn't there.
- No subreports, table of contents or drill-down. Those want runtime state or forward references that a pagination-agnostic core doesn't carry.
- Not a WYSIWYG page designer. The editor edits band structure, not pixels. Bands stack, items stack, tables have columns, and there is no free positioning.
It's also 0.9.0 today. The plan is to freeze the authoring surface at 1.0, the document plus the semantics you can observe without knowing which target you're rendering to. That freeze isn't in effect yet, so a breaking authoring change right now is an ordinary, named, breaking change.
Getting started
npm install quario @quario/html
Swap @quario/html for @quario/pdf, @quario/xlsx, @quario/csv or @quario/docx, or install several. Each target declares the engine as a peer dependency, so your app carries one copy of it. Everything is ESM-only and wants Node 22 or newer, and there's no build step in any package: they publish plain JavaScript with hand-written type declarations.
The evaluation licence is free, needs no key and has no time limit, and it watermarks whatever it renders. A paid seat takes the watermark off. Whichever you end up on, the check runs offline through WebCrypto: it never phones home, never throws and never gates a feature. The pricing page has the terms.
The getting started guide builds an invoice from scratch, subtotals and all, and the scopes page is the one to read next.
Closing thoughts
Most of "make it a PDF" turned out to have nothing to do with PDFs. It was about writing the report down somewhere a second renderer could read it.
Once the definition was data instead of code, the spreadsheet stopped being a rewrite and became another reader of the same document. The Markdown target took an afternoon. What actually cost me weeks was deciding what a width or a size should mean when one target has a stylesheet, one has a fixed page, and one has no styling model at all. A target is allowed to approximate what you declared. It is not allowed to resolve it against something you never declared.
If you're maintaining two copies of the same report right now, one for the screen and one for the export, I'd like to hear how far apart they've drifted.