---
title: "pdf-lib alternative: generate the report, edit the PDF — quario"
description: "pdf-lib edits PDF files and draws new ones at coordinates. quario renders a report from data. When to use each, when to use both, with the same document."
url: "https://getquario.com/compare/pdf-lib"
---

# pdf-lib alternative

pdf-lib does something most PDF libraries don’t. It opens a PDF that already exists and changes
it. It fills and flattens form fields, copies pages from one file into another, draws a stamp
across a page, and saves the result. It’s MIT-licensed, written in TypeScript, and runs in Node, the
browser, Deno, and React Native. For editing a PDF in JavaScript, it’s the library to know.

It can also create a document from nothing, and that’s where teams run into its limits. pdf-lib
draws text, lines, and images at coordinates. It has no table, no page flow, and no notion of a
total. Building a report with it means a loop that measures, draws, and moves a `y` down the page.

So the honest question is which of two jobs you have. Generating a report from data is quario’s
job. Editing a PDF that already exists is pdf-lib’s, and quario doesn’t
do it. This page shows the first job both ways, then how the two work together.

## The same remittance advice, twice

A remittance advice tells a supplier which invoices a payment covers. It has a title, four rows, a
total, and “page 1 of 1” in the footer. First with pdf-lib 1.17 in Node:

```ts title="remittance-pdf-lib.ts"
import { writeFileSync } from 'node:fs'
import { PDFDocument, PageSizes, StandardFonts } from 'pdf-lib'

type Payment = { invoice: string; date: string; amount: number }
type Remittance = { number: string; payee: string; paid: string; payments: Payment[] }
const { remittance } = (await getRemittance(id)) as { remittance: Remittance }

const eur = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' })
const day = new Intl.DateTimeFormat('en-IE', { dateStyle: 'medium', timeZone: 'UTC' })

const doc = await PDFDocument.create()
const regular = await doc.embedFont(StandardFonts.Helvetica)
const bold = await doc.embedFont(StandardFonts.HelveticaBold)
const [width, height] = PageSizes.A4
const margin = 54

let page = doc.addPage(PageSizes.A4)
let y = height - margin - 22
const text = (s: string, x: number, size = 10, font = regular) => page.drawText(s, { x, y, size, font })
const right = (s: string, size = 10, font = regular) => text(s, width - margin - font.widthOfTextAtSize(s, size), size, font)

text(`Remittance advice ${remittance.number}`, margin, 22, bold)
y -= 24
text(`To ${remittance.payee}`, margin, 9)
right(`Paid ${day.format(new Date(remittance.paid))}`, 9)
y -= 30
text('Invoice', margin, 10, bold)
text('Date', 260, 10, bold)
right('Amount', 10, bold)

for (const p of remittance.payments) {
  y -= 16
  if (y < margin + 40) {                             // no room left: a new page, and no header on it
    page = doc.addPage(PageSizes.A4)
    y = height - margin
  }
  text(p.invoice, margin)
  text(day.format(new Date(p.date)), 260)
  right(eur.format(p.amount))
}

const total = remittance.payments.reduce((sum, p) => sum + p.amount, 0)
page.drawLine({ start: { x: margin, y: y - 6 }, end: { x: width - margin, y: y - 6 }, thickness: 0.5 })
y -= 20
right(`Total paid  ${eur.format(Math.round(total * 100) / 100)}`, 10, bold)

const pages = doc.getPages()
for (const [i, p] of pages.entries()) {
  const footer = `Remittance advice ${remittance.number} · page ${i + 1} of ${pages.length}`
  p.drawText(footer, { x: width - margin - regular.widthOfTextAtSize(footer, 8), y: 30, size: 8, font: regular })
}

writeFileSync('remittance.pdf', await doc.save())
```

It works, and every line is layout arithmetic. Right alignment is a width measured and
subtracted. Pagination is an `if` on `y`, and the header row doesn’t come back on page two unless
you draw it again. The footer waits until the end, because “of 2” isn’t known before then. And the
standard fonts in pdf-lib only encode WinAnsi, so a payee name outside it throws an error rather
than rendering. Wider text needs `registerFontkit` and a font file.

Now the same document as a quario definition. This is the file the card on the
[guides page](https://getquario.com/guides.md) renders:

```json title="remittance.report.json"
{
  "data": "$.remittance.payments[*]",
  "aggregates": { "total": "sum:=@.amount" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Remittance advice {{ $.input.remittance.number }}",
      "style": { "size": 22, "bold": true } },
    { "type": "split",
      "style": { "spaceBefore": 6, "spaceAfter": 18 },
      "slots": [
        { "type": "text", "value": "To {{ $.input.remittance.payee }}", "width": 60,
          "style": { "size": 9, "bold": false } },
        { "type": "text", "value": [{ "value": "Paid " }, { "value": "{{ $.input.remittance.paid }}" }],
          "style": { "size": 9, "bold": false, "align": "right", "format": "date" } }
      ] }
  ],
  "detail": {
    "header": { "style": { "bold": true,
      "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
    "columns": [
      { "header": "Invoice", "value": "{{ @.invoice }}", "width": 40 },
      { "header": "Date", "value": "{{ @.date }}", "style": { "format": "date" }, "width": 35 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ @.amount }}",
        "style": { "align": "right", "format": "currency" }, "width": 25 }
    ],
    "total": {
      "style": { "align": "right", "bold": true },
      "rows": [
        { "style": { "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
          "cells": [
            { "value": "Total paid", "span": 2 },
            { "value": "{{ round($.total, 2) }}", "style": { "format": "currency" } }
          ] }
      ]
    }
  },
  "page": {
    "footer": [
      { "type": "text",
        "value": "Remittance advice {{ $.input.remittance.number }} · page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ]
  }
}
```

And the code that renders it:

```ts title="remittance-quario.ts"
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './remittance.report.json' with { type: 'json' }

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

const data = await getRemittance(id)                 // your JSON, from an API or DB
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('remittance.pdf', bytes)
```

There are no coordinates. Columns take a percentage of the table, `"align": "right"` aligns, and the
engine paginates. The header row repeats on each page the table continues onto, and the last row
keeps the total with it. A character the standard faces can’t draw renders as a question mark
rather than failing the document.

## What changes when you switch

**The report becomes data.** A pdf-lib program is the document, and the layout lives in its
arithmetic. 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.** The same 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`. The supplier who wants the remittance as a spreadsheet gets amounts
that are numbers, from the same file.

**pdf-lib is still underneath.** `@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. You’re not trading
pdf-lib’s file handling away, only the layout code you wrote on top of it.

**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.

## Use both

The two jobs often meet in one pipeline. Render the report with quario, then let pdf-lib do what
quario doesn’t. Here pdf-lib appends the payment terms, an existing PDF from legal, to the
rendered advice:

```ts title="remittance-with-terms.ts"
import { readFileSync, writeFileSync } from 'node:fs'
import { PDFDocument } from 'pdf-lib'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const advice = await PDFDocument.load(await report.render(pdf({ page: { size: 'A4' } }), data))
const terms = await PDFDocument.load(readFileSync('payment-terms.pdf'))

for (const page of await advice.copyPages(terms, terms.getPageIndices())) advice.addPage(page)

writeFileSync('remittance.pdf', await advice.save())
```

The same split works for stamping a rendered page or filling a form that came from outside. quario
writes the part that comes from your data, and pdf-lib edits the file.

## When to keep pdf-lib

pdf-lib is the right tool, alone or beside quario, when:

- **You fill a PDF form someone else designed.** A tax form or a carrier’s template comes with its
fields. `getForm()`, `getTextField()` and `flatten()` fill it, and quario can’t open it.
- **You merge, split, or stamp existing files.** Copying pages between documents and drawing over a
page you loaded is pdf-lib’s home ground.
- **The page is one fixed layout.** A certificate or a label with a handful of fields at known
positions doesn’t need a report engine.
- **The project is open source, or the budget is zero.** pdf-lib is MIT. quario is commercial past
evaluation.

Reach for quario when the document comes from rows of data, such as a remittance advice, an
invoice, a statement, or a sales report. It earns its place when that document also has to exist as
a spreadsheet or a Word file.

## FAQ

### Is pdf-lib still maintained?

Its latest release on npm is 1.17.1, published in November 2021. It still works, and many projects
depend on it. Forks carry it on, and `@cantoo/pdf-lib`, the one quario writes its files with,
published 2.11.1 in September 2026. Weigh that as you would for any dependency you plan to keep.

### Can quario fill in or edit an existing PDF?

No. quario writes documents and never opens one. Fill the form or edit the file with pdf-lib, and
render the parts that come from your data with quario.

### Does quario run in the browser like pdf-lib?

Yes. `@quario/pdf` renders in the browser as well as in Node and edge runtimes, with no headless
browser anywhere.

### 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":"pdf-lib alternative","item":"https://getquario.com/compare/pdf-lib"}]}
```
