Escape

pdfmake alternative

pdfmake is a good library. It’s MIT-licensed, and it runs in Node and in the browser. For years it has been the usual answer to “a table that spans pages”. You describe the document as one object of paragraphs, tables, and columns. pdfmake lays it out on top of PDFKit, repeats header rows after a page break, and fills in page numbers through a footer function.

What it doesn’t do is know anything about your data. Before pdfmake sees a row, your code has looped over the records and multiplied the quantities. It has summed the totals, too, and formatted every amount as a string. That assembly code is the part teams usually go looking to replace, and it’s what this page is about.

The same invoice, twice

One invoice, three lines, a subtotal, 21% VAT, a total, and “page 1 of 1” in the footer. First with pdfmake 0.3 in Node:

invoice-pdfmake.ts
import pdfmake from 'pdfmake'

type Line = { item: string; qty: number; unitPrice: number }
const { invoice } = (await getInvoice()) as { invoice: { number: string; lines: Line[] } }

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

const subtotal = invoice.lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0)
const vat = subtotal * 0.21

pdfmake.addFonts({ Roboto: { normal: 'fonts/Roboto-Regular.ttf', bold: 'fonts/Roboto-Medium.ttf' } })
pdfmake.setLocalAccessPolicy((path: string) => path.startsWith('fonts/'))

const doc = {
  pageSize: 'A4',
  pageMargins: [54, 54, 54, 54],
  defaultStyle: { fontSize: 10 },
  footer: (currentPage: number, pageCount: number) => ({
    text: `Invoice ${invoice.number} · page ${currentPage} of ${pageCount}`,
    alignment: 'right', fontSize: 8, margin: [54, 0],
  }),
  content: [
    { text: `Invoice ${invoice.number}`, fontSize: 22, bold: true, margin: [0, 0, 0, 18] },
    {
      layout: 'headerLineOnly',
      table: {
        headerRows: 1,                                 // repeated after a page break
        widths: ['*', 'auto', 'auto', 'auto'],
        body: [
          [{ text: 'Item', bold: true }, right('Qty', true), right('Unit', true), right('Amount', true)],
          ...invoice.lines.map((l) => [
            l.item,
            right(String(l.qty)),
            right(eur.format(l.unitPrice)),
            right(eur.format(round(l.qty * l.unitPrice))),
          ]),
          [{ ...right('Subtotal'), colSpan: 3 }, {}, {}, right(eur.format(round(subtotal)))],
          [{ ...right('VAT 21%'), colSpan: 3 }, {}, {}, right(eur.format(round(vat)))],
          [{ ...right('Total due', true), colSpan: 3 }, {}, {}, right(eur.format(round(subtotal + vat)), true)],
        ],
      },
    },
  ],
}

await pdfmake.createPdf(doc).write('invoice.pdf')

That’s a fair pdfmake program, and it works. Read it again for where the invoice actually lives. The columns are in body, the arithmetic is in reduce, the money is in Intl.NumberFormat, and the footer is a function. Add a column and you touch the widths, the header row, the row mapping and every colSpan.

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

invoice.report.json
{
  "data": "$.invoice.lines[*]",
  "aggregates": { "subtotal": "sum:[email protected] * @.unitPrice" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Invoice {{ $.input.invoice.number }}",
      "style": { "size": 22, "bold": true, "spaceAfter": 18 } }
  ],
  "detail": {
    "header": { "style": { "bold": true,
      "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
    "columns": [
      { "header": "Item", "value": "{{ @.item }}", "width": 46 },
      { "header": { "value": "Qty", "style": { "align": "right" } }, "value": "{{ @.qty }}",
        "style": { "align": "right" }, "width": 12 },
      { "header": { "value": "Unit", "style": { "align": "right" } }, "value": "{{ @.unitPrice }}",
        "style": { "align": "right", "format": "currency" }, "width": 20 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ round(@.qty * @.unitPrice, 2) }}",
        "style": { "align": "right", "format": "currency" }, "width": 22 }
    ],
    "total": {
      "style": { "align": "right" },
      "rows": [
        { "cells": [
          { "value": "Subtotal", "span": 3 },
          { "value": "{{ round($.subtotal, 2) }}", "style": { "format": "currency" } }
        ] },
        { "cells": [
          { "value": "VAT 21%", "span": 3 },
          { "value": "{{ round($.subtotal * 0.21, 2) }}", "style": { "format": "currency" } }
        ] },
        { "style": { "bold": true,
            "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
          "cells": [
            { "value": "Total due", "span": 3 },
            { "value": "{{ round($.subtotal * 1.21, 2) }}", "style": { "format": "currency" } }
          ] }
      ]
    }
  },
  "page": {
    "footer": [
      { "type": "text",
        "value": "Invoice {{ $.input.invoice.number }} · page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ]
  }
}

And the code that renders it:

invoice-quario.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)

The loop is "data": "$.invoice.lines[*]". The subtotal is an aggregate the engine computes, and round(…, 2) sits on the cell that shows it. The amounts stay numbers, and "format": "currency" presents them from the locale and currency you hand quario(). The footer reads page.number and page.total without a callback. Nothing in the definition is code, so it’s JSON you store, version and review like any other data.

What changes when you switch

The document becomes data. A pdfmake document object with a footer function can’t round-trip through JSON, and the amounts in it are already strings. A quario definition is plain JSON. It can sit in a database, arrive from an editor, or come from an AI agent, and it still renders safely. 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. pdfmake 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 the Amount column is still numbers, under a currency number format, so the customer’s accountant can sum it. A pdfmake export to Excel is a second program.

Pagination stays automatic. Both libraries repeat a table’s header rows after a page break. quario also keeps group headers with their first rows and keeps the last row with its totals, without a declaration in the definition. The PDF target covers what it promises and what it does best-effort.

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 pdfmake

Switch for the reasons above, and only for them. pdfmake is the better choice when:

  • The project is open source, or the budget is zero. pdfmake is MIT. quario is commercial past evaluation.
  • The document isn’t a report. A brochure, a letter or a one-off layout with nested columns and lists is where pdfmake’s document object is at home. A report definition is for documents whose shape comes from rows of data.
  • You need PDF features quario doesn’t claim, such as a generated table of contents or tiling patterns.
  • The existing code works and nothing else needs the document. If nobody asks for the same report in Excel or Word and the layout rarely changes, a rewrite buys you little.

Reach for quario when the document is data-driven, such as an invoice, a statement, a payslip, or a sales report. It earns its place when the document has to exist as a PDF and a spreadsheet. It also fits when someone other than your code writes the layout.

FAQ

Does quario run in the browser like pdfmake?

Yes. @quario/pdf renders in the browser as well as in Node and edge runtimes, with no headless browser anywhere. The definition you render on the server is the one the browser renders.

Do I still need font files?

pdfmake reads the TTF files you register with addFonts in Node, or a virtual file system of them in the browser. quario’s PDF target draws Latin text in the PDF standard faces and needs no font files for it. For full Unicode or your own face, hand pdf({ fonts }) TrueType bytes and install the optional fontkit peer. The target then embeds a subset of the glyphs it used. The PDF target covers it.

Can I migrate one document at a time?

Yes. quario is a dependency, not a framework. Render the invoice through a definition and leave the rest of your pdfmake documents where they are.

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 →