Escape

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:

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

remittance.report.json
{
  "data": "$.remittance.payments[*]",
  "aggregates": { "total": "sum:[email protected]" },
  "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:

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 →

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:

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 →