Escape

Puppeteer PDF

Puppeteer drives a real Chrome, and page.pdf() prints whatever that Chrome can render. That’s the appeal. Every CSS feature works, web fonts load, and a chart library that needs a DOM draws its chart. A page your app already shows can become a PDF without a second layout. If you already have the HTML, it’s the shortest path from a screen to paper.

The cost is that you now run a browser to make a document. This page covers how to generate a PDF with Puppeteer properly and what that costs in production. Then it shows when a renderer without a browser is the better trade.

Generate a PDF with Puppeteer

Build the HTML, load it into a page, and print it. With puppeteer installed:

statement-puppeteer.ts
import { writeFileSync } from 'node:fs'
import puppeteer from 'puppeteer'

const html = statementHtml(await getStatement(id))   // your template, your escaping

const browser = await puppeteer.launch()
try {
  const page = await browser.newPage()
  await page.setContent(html, { waitUntil: 'networkidle0' })

  const bytes = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '20mm', right: '19mm', bottom: '24mm', left: '19mm' },
    displayHeaderFooter: true,
    headerTemplate: '<div></div>',
    footerTemplate: `<div style="width:100%;padding:0 19mm;font-size:8px;text-align:right">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
  })
  writeFileSync('statement.pdf', bytes)
} finally {
  await browser.close()
}

To print a page that’s already live, swap setContent for page.goto(url). Either way, a few defaults catch people out:

  • The paper is Letter unless you pass format.
  • Backgrounds don’t print unless printBackground is true, so table stripes and coloured bands vanish.
  • There are no margins unless you set them, and the header and footer draw inside the margins, so a footer needs room at the bottom.
  • The header and footer stay off until displayHeaderFooter is true. Each template is a separate HTML string, styled inline, into which Chrome fills the pageNumber, totalPages, date, title and url classes.
  • The page prints with the print media type. Call page.emulateMediaType('screen') first to print what the screen shows.
  • The call times out after 30 seconds by default. A long document on a busy machine can reach it.

Page breaks come from CSS. break-before, break-after, and break-inside: avoid on rows and blocks steer where Chrome cuts the page. Getting a long table to break cleanly, with its total kept on the same page as its last rows, takes tuning.

What Chromium costs in production

Installing puppeteer downloads Chrome for Testing, around 282 MB on Linux, and a chrome-headless-shell binary into a cache folder. puppeteer-core skips the download if you bring your own browser. Either way, the browser comes with its own needs:

  • A container that can run Chrome. The image needs its system libraries, plus fonts for every script your documents print. Puppeteer’s own troubleshooting guide carries a Docker setup for this.
  • A browser process to manage. Launch one per request and every document pays its start-up time. Keep one warm instead, and the crashes, the memory, and the pages left open are yours to handle.
  • A browser to patch. The Chrome in your image is a browser like any other, and its updates are yours to ship.
  • HTML that’s yours to escape. The document is a web page in a real browser. A customer name that reaches your template unescaped is markup, and markup can load URLs.

None of this rules Puppeteer out. It’s the bill, and it’s worth reading before the first document ships.

Moving the browser into a service, as Gotenberg does with Chromium behind an HTTP API, changes who runs it rather than whether it runs. Gotenberg against a library in-process →

The same statement, without a browser

quario renders a document from a definition, JSON you store, version, and review, bound to your data at render time. @quario/pdf typesets the pages itself, so there’s no Chromium in the path. Here is the same statement of account, from the statement template:

statement-quario.ts
import { readFileSync, writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import statement from './statement.report.json' with { type: 'json' }

const logo = () => readFileSync('logo.png')          // your logo, as PNG bytes
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(statement, { logo })

const data = await getStatement(id)                  // your ledger
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('statement.pdf', bytes)

The Puppeteer version split the document across a template, a print stylesheet, and a footer string. Here it sits in one place. The “Page 2 of 3” line is a page.footer band in the definition, reading page.number and page.total. The running header that only appears from page 2 is a page.header band with "visible": "=page.number > 1". The movement table’s column labels repeat on every page, and the last row keeps its totals with it, without a line of CSS.

Three more things change:

  • The output is deterministic. The same definition and data produce a byte-identical file, so you can cache a rendered PDF, hash it, or compare it in a test.
  • Data is never markup. The target draws a customer name as text, so there’s nothing to escape and nothing for it to load. Expressions parse into closures, with no eval, no new Function, and no code generation. Even a definition a user or an AI agent wrote can’t run anything. How that holds →
  • One definition reaches four document shapes. The statement renders to an HTML fragment, a PDF, an Excel workbook, and a Word document. The export a customer downloads and the page they see never drift apart.

Keep Puppeteer for the typography

Some documents genuinely need the browser’s CSS. You can still take the report logic out of the template. Render the definition to an HTML fragment with @quario/html, wrap it in your own print stylesheet, and print that:

statement-hybrid.ts
import { quario } from 'quario'
import { html } from '@quario/html'
import puppeteer from 'puppeteer'

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const fragment = await report.render(html(), data)

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`<style>${printCss}</style>${fragment}`)
const pdf = await page.pdf({ format: 'A4', printBackground: true })
await browser.close()

The fragment carries a stable class contract, .q-group, .q-table and friends, for a print stylesheet to hold on to. HTML reports covers it.

When to keep Puppeteer

Puppeteer is the better choice when:

  • The PDF is a page you already render. A dashboard, a ticket, or a marketing page your app shows is already laid out. Printing it beats describing it twice.
  • The design needs CSS a report layout doesn’t have. Grid layouts, web fonts loaded from a stylesheet, and chart libraries that draw into the DOM all need the browser.
  • You already run browsers, for end-to-end tests or screenshots, and one more job on that fleet is cheap.
  • The budget is zero. Puppeteer is Apache-2.0. quario is commercial past evaluation.

Reach for quario when the documents are data-driven, such as invoices, statements, payslips, and reports. It earns its place when they also need to exist as a workbook or a Word file. It also fits when Chromium is the heaviest thing in a container that otherwise needs none.

FAQ

Can quario generate a PDF in a cloud function or an edge runtime?

Yes. @quario/pdf is a JavaScript library with no browser behind it, so it runs wherever your code runs, in Node, the browser, and edge runtimes. A function that renders a PDF doesn’t need a Chromium layer.

Does quario convert HTML to PDF?

No. quario doesn’t read HTML. It renders a definition and your data straight to PDF. If you have an existing HTML page to print, Puppeteer is the right tool for that page.

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 →