Escape

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.

Have a CSV file rather than JSON, and need one PDF of it rather than code? The CSV to PDF converter does it in your browser, and shows the definition it used.

Do it with quario

Install:

shell
npm install @quario/pdf

Describe the report once — it’s just JSON, so it lives in your repository, gets code-reviewed, and never becomes a tangle of string concatenation:

invoice.report.json
{
  "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:

app.ts
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:

pdf-via-browser.ts
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. What Puppeteer itself costs, and the options that matter →

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

Does it run in Node.js or in the browser?

Both. The PDF target typesets the pages itself rather than driving a browser. The same render call runs in a Node.js service and in a browser tab, and resolves a Uint8Array. In Node.js, write it to disk or send it as a response body. In the browser, hand it to a download. Page geometry, fonts and the bytes →

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.

The same holds for report definitions. A definition is untrusted input too. One an agent or a user wrote can’t execute, and the HTML target escapes its literal text like any value. What stays trusted is your own configuration, such as the functions you register.

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 license for each production deployment. Its users, agents, and renders are never counted, and replicas of one service count once. See pricing →