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:
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:
{
"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:
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:
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-autotableare 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 optionalhtml2canvaspackage. 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 →