Escape

PDFKit alternative

PDFKit is the drawing layer much of the JavaScript PDF world stands on. pdfmake lays its documents out on top of it, and so does React-PDF. On its own, it’s an MIT-licensed API of chained calls for Node and the browser, and it covers a lot of the format. It wraps text onto new pages, draws vector graphics, and embeds images and TrueType fonts. It also writes links, form fields, encryption, and PDF/A. Since 0.17 it has a table() helper too.

What it leaves to you is the report. PDFKit keeps a cursor, and your code moves it. Grouping rows, carrying a subtotal per group, formatting money, and numbering pages are all JavaScript you write around the drawing calls. That’s the part this page compares.

The same report, twice

A sales report for one month groups its orders by region, with a subtotal under each region. Then comes a grand total, and “page 1 of 1” sits in the footer. First with PDFKit 0.20 in Node:

sales-pdfkit.ts
import { createWriteStream } from 'node:fs'
import PDFDocument from 'pdfkit'

type Order = { region: string; order: string; customer: string; amount: number }
const { sales } = (await getSales('2026-09')) as { sales: { period: string; orders: Order[] } }

const eur = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' })
const round = (n: number) => Math.round(n * 100) / 100
const right = (text: string) => ({ text, align: { x: 'right' } })
const bold = (text: string) => ({ text, font: { src: 'Helvetica-Bold' } })

const doc = new PDFDocument({ size: 'A4', margin: 54, bufferPages: true })  // Letter and 72pt otherwise
doc.pipe(createWriteStream('sales.pdf'))
doc.font('Helvetica-Bold').fontSize(20).text(`Sales by region, ${sales.period}`)

const regions = [...Map.groupBy(sales.orders, (o) => o.region)].sort(([a], [b]) => a.localeCompare(b))
for (const [region, orders] of regions) {
  const subtotal = orders.reduce((sum, o) => sum + o.amount, 0)
  doc.moveDown().font('Helvetica-Bold').fontSize(12).text(region).fontSize(10).font('Helvetica')
  doc.table({
    columnStyles: ['*', 100, 110],
    defaultStyle: { border: 0 },
    data: [
      [bold('Customer'), bold('Order'), { ...bold('Amount'), align: { x: 'right' } }],
      ...orders.map((o) => [o.customer, o.order, right(eur.format(o.amount))]),
      [{ ...right(`Subtotal ${region}`), colSpan: 2 }, right(eur.format(round(subtotal)))],
    ],
  })
}

const total = sales.orders.reduce((sum, o) => sum + o.amount, 0)
doc.moveDown().font('Helvetica-Bold').text(`Total ${eur.format(round(total))}`, { align: 'right' })

const { start, count } = doc.bufferedPageRange()
for (let i = start; i < start + count; i++) {
  doc.switchToPage(i)
  doc.page.margins.bottom = 0                        // or text below the margin opens a new page
  doc.font('Helvetica').fontSize(8)
    .text(`Sales by region, ${sales.period} · page ${i + 1} of ${count}`, 54, 800, { width: 487, align: 'right' })
}
doc.end()

It’s a reasonable PDFKit program, and it draws a clean report. Count what it carries, though. The grouping is Map.groupBy and a sort. Each subtotal is a reduce, and every amount is a string by the time it’s drawn. The footer needs bufferPages, so the code can go back once it knows the page count. It also needs the bottom margin lowered first, because PDFKit treats text past the margin as overflow and starts a page for it. And table() starts a new page when a row doesn’t fit, but it doesn’t repeat the header row there. That’s another hook you’d write.

Now the same report as a quario definition. This is the file the card on the guides page renders:

sales.report.json
{
  "data": "$.sales.orders[*]",
  "sort": [{ "by": "[email protected]" }, { "by": "[email protected]" }],
  "aggregates": { "total": "sum:[email protected]" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Sales by region, {{ $.input.sales.period }}",
      "style": { "size": 20, "bold": true, "spaceAfter": 6 } }
  ],
  "groups": [
    { "name": "region",
      "by": "[email protected]",
      "aggregates": { "subtotal": "sum:[email protected]" },
      "header": [
        { "type": "text",
          "value": "{{ region.key }}",
          "style": { "size": 12, "bold": true, "paddingTop": 14, "spaceAfter": 4 } }
      ] }
  ],
  "detail": {
    "header": { "style": { "bold": true,
      "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
    "columns": [
      { "header": "Customer", "value": "{{ @.customer }}", "width": 56 },
      { "header": "Order", "value": "{{ @.order }}", "width": 20 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ @.amount }}",
        "style": { "align": "right", "format": "currency" }, "width": 24 }
    ],
    "total": {
      "style": { "align": "right" },
      "rows": [
        { "cells": [
          { "value": "Subtotal {{ region.key }}", "span": 2 },
          { "value": "{{ round(region.subtotal, 2) }}", "style": { "format": "currency" } }
        ] }
      ]
    }
  },
  "footer": [
    { "type": "split",
      "style": { "spaceBefore": 18, "paddingTop": 6,
        "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
      "slots": [
        { "type": "text", "value": "Total", "width": 70, "style": { "bold": true } },
        { "type": "text", "value": "{{ round($.total, 2) }}",
          "style": { "bold": true, "align": "right", "format": "currency" } }
      ] }
  ],
  "page": {
    "footer": [
      { "type": "text",
        "value": "Sales by region, {{ $.input.sales.period }} · page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ]
  }
}

And the code that renders it:

sales-quario.ts
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './sales.report.json' with { type: 'json' }

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)

const data = await getSales('2026-09')               // your JSON, from an API or DB
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('sales.pdf', bytes)

The grouping is "by": "[email protected]", and the sort is a declaration. Each group carries its own subtotal aggregate, the report carries the grand total, and round(…, 2) sits on every cell that shows one. The column labels repeat at the top of each page a region continues onto. A region’s header stays with its first rows, and the last row keeps its subtotal, without a hook. The footer reads page.number and page.total.

What changes when you switch

The report becomes data. A PDFKit program is the report, and the layout is the order its calls run in. A quario definition is JSON you store, version, and review. It can come from an editor or an AI agent as safely as from your repository. Expressions parse into closures. There’s no eval, no new Function and no code generation, so a definition someone else wrote can’t run anything. How that holds →

One definition reaches four document shapes. PDFKit writes PDF. The same quario definition renders to an HTML fragment with @quario/html, a PDF with @quario/pdf, an Excel workbook with @quario/xlsx and a Word document with @quario/docx:

every-shape.ts
import { quario } from 'quario'
import { html } from '@quario/html'
import { pdf } from '@quario/pdf'
import { xlsx } from '@quario/xlsx'
import { docx } from '@quario/docx'

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)

const page = await report.render(html(), data)
const paper = await report.render(pdf({ page: { size: 'A4' } }), data)
const book = await report.render(xlsx(), data)
const word = await report.render(docx({ page: { size: 'A4' } }), data)

In the workbook, each region keeps its header and subtotal, and the amounts stay numbers under a currency format. The sales team can pivot on them without retyping a figure.

Grouping is a declaration. Add a second level, say by sales rep inside each region, and the definition gains one entry in groups. In the PDFKit program, it’s another loop, another reduce, and another look at where a page might break.

The bytes repeat. The PDF target writes no creation timestamp. The same definition and data produce a byte-identical file, which is a fair thing to test against.

When to keep PDFKit

PDFKit is the better choice when:

  • The project is open source, or the budget is zero. PDFKit is MIT. quario is commercial past evaluation.
  • You draw the page rather than tabulate it. Vector diagrams, a certificate, or anything positioned point by point is what a drawing API is for.
  • You need PDF features quario doesn’t claim, such as form fields a reader fills in, encryption, or PDF/A output.
  • You’re building your own layer on top. pdfmake and React-PDF both chose PDFKit as their foundation for good reasons. If you’re writing a renderer, it’s a solid one.

Reach for quario when the document comes from rows of data, such as a sales report, an invoice, a statement, or a payslip. It earns its place when that document also has to exist as a spreadsheet or a Word file. It also fits when someone other than your code writes the layout.

FAQ

Is quario built on PDFKit?

No. @quario/pdf typesets the pages itself and hands the finished layout to @cantoo/pdf-lib, a maintained pdf-lib fork, to write the file bytes. Latin text draws in the PDF standard faces with no font files, and embedding TrueType for full Unicode needs the optional fontkit peer.

Does quario run in the browser like PDFKit?

Yes, with no extra setup. @quario/pdf renders in the browser as well as in Node and edge runtimes. Since 0.20, PDFKit’s browser build has you import and register each standard font you use.

Can I migrate one document at a time?

Yes. quario is a dependency, not a framework. Render the sales report through a definition and leave the rest of your PDFKit code where it is.

Can I use quario for free?

Evaluating it’s free, with no time limit. You get the full engine and need no key, and evaluation output carries a watermark. 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 →