Escape

jsPDF alternative

jsPDF is the library most web apps reach for when a button has to turn into a PDF. It’s MIT-licensed, it started in the browser, and it runs in Node too. It’s small to start with. You create a document, call text(), line() and addImage() at coordinates, and save() hands the browser a download. For tables, almost everyone adds the jspdf-autotable plugin, which lays rows out, wraps cells, and starts a new page when the table runs long.

What neither knows about is your data. Your code formats every date and every amount into a string, sums the totals, and decides where the title, the table, and the footer go. That’s fine for a label or a certificate. For a document whose shape comes from rows of records, it’s the code that keeps growing, and it’s what this page compares.

The same expense claim, twice

One expense claim, with a title line, five items, a total, and “page 1 of 1” in the footer. First with jsPDF 4.2 and jspdf-autotable 5, in the browser:

claim-jspdf.ts
import { jsPDF } from 'jspdf'
import { autoTable } from 'jspdf-autotable'

type Item = { date: string; category: string; description: string; amount: number }
type Claim = { number: string; employee: string; submitted: string; items: Item[] }
const { claim } = (await getExpenses('EXP-0912')) as { claim: Claim }

const eur = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' })
const day = new Intl.DateTimeFormat('en-IE', { dateStyle: 'medium', timeZone: 'UTC' })
const right = (content: string) => ({ content, styles: { halign: 'right' } })
const total = claim.items.reduce((sum, i) => sum + i.amount, 0)
const pages = '{total_pages_count_string}'           // swapped for the count at the end

const doc = new jsPDF({ unit: 'pt', format: 'a4' })   // the default unit is mm
doc.setFont('helvetica', 'bold').setFontSize(20).text(`Expense claim ${claim.number}`, 54, 72)
doc.setFont('helvetica', 'normal').setFontSize(9).text(claim.employee, 54, 92)
doc.text(`Submitted ${day.format(new Date(claim.submitted))}`, 541, 92, { align: 'right' })

autoTable(doc, {
  startY: 110,
  margin: { left: 54, right: 54, bottom: 72 },
  theme: 'plain',
  head: [['Date', 'Category', 'Description', right('Amount')]],
  body: claim.items.map((i) => [day.format(new Date(i.date)), i.category, i.description, right(eur.format(i.amount))]),
  foot: [[{ content: 'Total claimed', colSpan: 3, styles: { halign: 'right' } }, right(eur.format(Math.round(total * 100) / 100))]],
  showFoot: 'lastPage',                               // the default repeats it on every page
  didDrawPage: (data: { pageNumber: number }) => {
    doc.setFontSize(8).text(`Expense claim ${claim.number} · page ${data.pageNumber} of ${pages}`, 541, 812, { align: 'right' })
  },
})

doc.putTotalPages(pages)
doc.save(`${claim.number}.pdf`)

That’s a fair jsPDF program, and it works. Look at what it had to know. Every position is a number of points someone measured, down to the 812 that places the footer. columnStyles reaches only body cells, so the header and the total each restate the right alignment. The total sits in foot, which repeats on every page unless you say otherwise. And “page 1 of 1” needs a placeholder string that putTotalPages swaps out once the last page exists.

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

claim.report.json
{
  "data": "$.claim.items[*]",
  "aggregates": { "total": "sum:[email protected]" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Expense claim {{ $.input.claim.number }}",
      "style": { "size": 20, "bold": true } },
    { "type": "split",
      "style": { "spaceBefore": 6, "spaceAfter": 18 },
      "slots": [
        { "type": "text", "value": "{{ $.input.claim.employee }}", "width": 60,
          "style": { "size": 9, "bold": false } },
        { "type": "text", "value": [{ "value": "Submitted " }, { "value": "{{ $.input.claim.submitted }}" }],
          "style": { "size": 9, "bold": false, "align": "right", "format": "date" } }
      ] }
  ],
  "detail": {
    "header": { "style": { "bold": true,
      "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
    "columns": [
      { "header": "Date", "value": "{{ @.date }}", "style": { "format": "date" }, "width": 18 },
      { "header": "Category", "value": "{{ @.category }}", "width": 18 },
      { "header": "Description", "value": "{{ @.description }}", "width": 44 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ @.amount }}",
        "style": { "align": "right", "format": "currency" }, "width": 20 }
    ],
    "total": {
      "style": { "align": "right", "bold": true },
      "rows": [
        { "style": { "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
          "cells": [
            { "value": "Total claimed", "span": 3 },
            { "value": "{{ round($.total, 2) }}", "style": { "format": "currency" } }
          ] }
      ]
    }
  },
  "page": {
    "footer": [
      { "type": "text",
        "value": "Expense claim {{ $.input.claim.number }} · page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ]
  }
}

And the code behind the download button, still in the browser:

claim-quario.ts
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './claim.report.json' with { type: 'json' }

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

async function download(id: string) {
  const data = await getExpenses(id)                 // your JSON, from your API
  const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

  const link = document.createElement('a')
  link.href = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/pdf' }))
  link.download = `${id}.pdf`
  link.click()
}

The loop is "data": "$.claim.items[*]". The total is an aggregate the engine computes, and round(…, 2) sits on the cell that shows it. The dates stay ISO strings and the amounts stay numbers, because "format": "date" and "format": "currency" present them from the locale and currency you hand quario(). The footer reads page.number and page.total without a placeholder. Nothing in the definition is a coordinate.

What changes when you switch

The document becomes data. A jsPDF program is the document, and the only way to change the layout is to change the program. A quario definition is JSON you store, version, and review. It can live in a database or come from an editor or 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. jsPDF 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)

The finance team that asks for the claim as a spreadsheet gets a workbook where the Amount column is numbers and the dates are dates. With jsPDF, that’s a second library and a second program.

Pagination keeps the totals honest. jspdf-autotable repeats the head row after a page break, and so does quario. quario also keeps the last data row on the page with its total. A group’s header stays with its first rows, and the definition declares none of it. 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 jsPDF

jsPDF is the better choice when:

  • The project is open source, or the budget is zero. jsPDF and jspdf-autotable are MIT. quario is commercial past evaluation.
  • You draw the page rather than fill it. A certificate, a badge, or a label with a logo at an exact spot is where drawing at coordinates is the simplest thing that works.
  • You print a part of the page you already show. The html() method in jsPDF can turn an element into PDF content, through the optional html2canvas package. quario doesn’t read HTML.
  • You need PDF features quario doesn’t claim, such as password protection, which the jsPDF constructor takes as encryption.
  • The existing code works and nothing else needs the document. If nobody asks for the same claim 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 expense claim, an invoice, a statement, or an orders report. It earns its place when the same 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 jsPDF?

Yes. @quario/pdf renders in the browser as well as in Node and edge runtimes, with no headless browser anywhere. The definition your server renders is the one the download button renders. To show the pages before anyone downloads them, the viewer paints the same layout on screen, with an export menu.

What about fonts beyond Latin text?

The jsPDF README says its 14 standard fonts cover the ASCII code page only. Anything else means adding a TrueType font with addFileToVFS and addFont. quario’s PDF target draws Latin text in the standard faces with no font files. For full Unicode, hand pdf({ fonts }) TrueType bytes and install the optional fontkit peer. The PDF target covers it.

Can I migrate one document at a time?

Yes. quario is a dependency, not a framework. Render the expense claim through a definition and leave the rest of your jsPDF 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 →