---
title: "A PDFKit alternative for grouped, data-driven reports — quario"
description: "PDFKit draws a PDF one call at a time. quario renders one JSON definition to PDF, Excel and Word. The same grouped report with each, side by side."
url: "https://getquario.com/compare/pdfkit"
---

# 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:

```ts title="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](https://getquario.com/guides.md)
renders:

```json title="sales.report.json"
{
  "data": "$.sales.orders[*]",
  "sort": [{ "by": "=@.region" }, { "by": "=@.order" }],
  "aggregates": { "total": "sum:=@.amount" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Sales by region, {{ $.input.sales.period }}",
      "style": { "size": 20, "bold": true, "spaceAfter": 6 } }
  ],
  "groups": [
    { "name": "region",
      "by": "=@.region",
      "aggregates": { "subtotal": "sum:=@.amount" },
      "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:

```ts title="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": "=@.region"`, 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 →](https://getquario.com/docs/diving-deeper/security.md)

**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`:

```ts title="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 →](https://getquario.com/pricing.md)

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Comparisons","item":"https://getquario.com/compare"},{"@type":"ListItem","position":3,"name":"PDFKit alternative","item":"https://getquario.com/compare/pdfkit"}]}
```
