JSON to PDF, in JavaScript
You have JSON — an invoice, a statement, a data export — and you need a PDF a person can read. The awkward part is everything between the raw data and a page that looks intentional. quario handles that middle: you describe the report once, hand it your JSON, and get a paginated PDF back.
The approach in one line
JSON data + a report definition → PDF. No headless browser in the middle. quario does the typesetting — pagination, repeated table headers, keep-together — and writes the file itself.
Do it with quario
Install:
npm install @quario/pdfDescribe the report once — it’s just JSON, so it lives in your repository, gets code-reviewed, and never becomes a tangle of string concatenation:
{
"data": "$.invoice.lines[*]",
"aggregates": { "total": "sum:[email protected] * @.unitPrice" },
"header": [
{ "type": "text",
"value": "Invoice {{ $.input.invoice.number }}",
"style": { "size": 22, "bold": true } }
],
"detail": {
"columns": [
{ "header": "Item", "value": "{{ @.item }}", "width": 70 },
{ "header": "Amount", "value": "{{ round(@.qty * @.unitPrice, 2) }}",
"style": { "align": "right", "format": "currency" }, "width": 30 }
],
"total": {
"style": { "bold": true },
"rows": [
{ "cells": [
{ "value": "Total" },
{ "value": "{{ round($.total, 2) }}", "style": { "align": "right", "format": "currency" } }
] }
]
}
}
}data is a JSONPath selecting the rows the table repeats over. {{ … }} interpolates a value: @ is the current row, $.input is the JSON you passed in. "sum:=…" declares an aggregate that lands on $ for the total row to read. total is a block. Its rows each carry their own cells, so a subtotal-and-VAT stack is more entries rather than a different shape. What every total row shares — bold, here — is the block’s own style, said once.
Notice the amount columns interpolate the number and declare "format": "currency". The value stays a number and the target presents it, from the locale and currency you give quario(). That’s why the same definition can read as €1,440.00 here and still arrive in a spreadsheet as something you can sum.
The arithmetic carries round(…, 2) because a currency value is a whole number of its currency’s minor units. A number format hides a stray 242.70000000000002 on the page; the spreadsheet cell behind it doesn’t.
Feed it your JSON and render:
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './invoice.report.json' with { type: 'json' }
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const data = await getInvoice() // your JSON, from an API or DB
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)
writeFileSync('invoice.pdf', bytes)That’s JSON in, PDF out — in one step, with no Chromium in your container. The bytes are deterministic too: same definition, same data, same file.
Prefer to go through HTML?
If you want full CSS typography, or you already run a headless browser, the HTML route is still there. Render a fragment with @quario/html, wrap it in your own print stylesheet, and print it:
import { quario } from 'quario'
import { html } from '@quario/html'
import puppeteer from 'puppeteer'
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const fragment = await report.render(html(), data)
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`<style>${printCss}</style>${fragment}`)
const pdf = await page.pdf({ format: 'A4', printBackground: true })
await browser.close()The fragment carries a stable class contract for exactly this. .q-group, .q-table, .q-item.q-group-header and friends give your print CSS something to hold on to. If the HTML is the deliverable rather than a step on the way to paper, HTML reports takes it from there.
Why not just build the PDF by hand?
Drawing coordinate-by-coordinate or hand-templating markup works until the layout changes — then you’re editing pixel offsets instead of a definition. Because the report is data, a table that grows is an edit, not a rewrite. And because quario renders without eval, your data never becomes executable. The longer case, and where a definition renders →
FAQ
Is it safe to render user-supplied data?
Yes — that’s the case quario exists for. quario escapes every interpolated value at the markup edge, and there is no raw form to opt out of it. Expressions parse into closures, and nothing evaluates them as source, so data can never become code.
Report definitions are a different matter. They’re trusted configuration, like the code around them. A definition author chooses what the report reads and what its literal text says. Don’t accept definitions from untrusted parties without a sanitisation policy of your own.
How big is it?
The engine is under 5 kB minified and compressed. Each render target adds a little of its own on top — around 1 kB for HTML, 5 kB for PDF. The PDF target also leans on a file writer.
Can I use it for free?
Evaluating it’s free and has no time limit — the full engine, no key to request — and evaluation output carries a watermark. Anything past evaluating it, including shipping it in an application or running it in your business, needs a seat per developer. Deployment is then unlimited and royalty-free, however many servers or users you have. See pricing →