---
title: "jsPDF alternative: a browser PDF from a definition — quario"
description: "jsPDF and jspdf-autotable draw a PDF from your code in the browser. quario renders one JSON definition to PDF, Excel and Word, in the browser too."
url: "https://getquario.com/compare/jspdf"
---

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

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

```json title="claim.report.json"
{
  "data": "$.claim.items[*]",
  "aggregates": { "total": "sum:=@.amount" },
  "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:

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

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

```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)
```

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](https://getquario.com/docs/diving-deeper/pdf-target.md) 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](https://getquario.com/docs/diving-deeper/viewer.md) 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](https://getquario.com/docs/diving-deeper/pdf-target.md#embedding-fonts-for-full-unicode) 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 →](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":"jsPDF alternative","item":"https://getquario.com/compare/jspdf"}]}
```
