Escape
HomeDocsThe PDF target

The PDF target

Intent

@quario/pdf turns one compiled report into a paginated document. It typesets the pages itself, so nothing in the path needs a headless browser, a print stylesheet, or a screenshot step. What you deploy is a library, not a second runtime inside your container.

Design decisions

This target is a painter. It lays the document out itself, which is what lets it promise where every line falls. The trade is against the DOCX target, which states the look and lets Word turn the pages. Painters, flow targets, and a fragment sets out the family.

The target typesets, and @cantoo/pdf-lib assembles. Pagination, keep-together, table geometry and the style mapping all belong to this package. Turning the finished layout into file bytes belongs to that library, a maintained pdf-lib fork. The split is why the target stays runtime-neutral and runs in a browser as readily as in Node.

Page size is host configuration. A definition that named A4 itself would be a definition you couldn’t print on Letter. page.size therefore lives on the factory call alongside the fonts and the document metadata.

A margin may come from the host or the document. page.margin is the one piece of geometry a document may declare, because a document with a pinned header knows what room it needs. Declaring it in both places is a render error rather than a silent winner.

Text renders at 10 points when nothing declares a size. A document’s type size is the document’s own, so it’s the report’s style.size and not a host option. The XLSX target writes the same 10 for the same reason.

Styled runs reach the page. Each run draws in its own face, size, and colour. The target draws underline and strikethrough per run, over that run's width and in its colour. A run's background paints a highlight behind its text. A line takes the largest size among its runs.

The target pins dates, so the bytes repeat. The document carries no creation timestamp, and meta is opt-in and never holds a date. The same definition, data and options produce a byte-identical file. That’s what makes a rendered PDF safe to cache, hash, or diff in a test.

Keep-together needs no schema fields. Group headers travel with their first content unit, and the last data row keeps its total block. A group’s headers repeat at the top of every page it continues onto. You declare none of that, because a definition describing where its own page breaks fall would stop being portable across page sizes.

Six typesetting behaviours are best-effort. Widow and orphan handling, the page-column gutter and balancing, and how a tall row slices are this implementation’s current answers. So are the oversize image, image byte reuse, and where an image’s page break lands. Everything else in the target’s contract is normative. Don’t write a definition that depends on the six.

Embedding TrueType needs the optional fontkit peer. The base-14 families need nothing extra, so a report in Latin text carries no additional dependency. The target names a missing package rather than failing obscurely.

Mapping a generic name replaces its base-14 faces. Unlike the HTML target’s mapping, this one reaches text that declares no family at all. A generic is the face that baseline already resolves to. A host can say what “the mono one” is for a document that names no typeface of its own.

An unknown character renders as a question mark. Cell text is untrusted data, and one stray character must not fail a report. WinAnsi is the base-14 limit, and a TrueType face’s own cmap is its limit.

The unlicensed marking draws once per page. It’s drawn last, over the content and the page furniture, so no filled cell hides it. Its presence is normative and its geometry isn’t. The marking depends only on the license state and the page size, so determinism holds either way.

API walkthrough

Setting the page up

pdf({ page }) takes the geometry at the factory call and validates it there, so a bad option throws before the engine walks any data. It takes page, meta and fonts, and nothing else. A key outside that set, or one of the wrong type, throws a TypeError naming the path rather than passing unread.

page.size is 'A4', 'letter', or a [width, height] pair in points. A4 is the default at 595.28 by 841.89 points. An unknown size name fails at compile time.

page.margin is a number of points applied to all four sides, defaulting to 54. That default isn’t a pass for a header that declared a height. Such a header pins a box from the page top and starts the next band at the pin.

render.js
import { pdf } from '@quario/pdf'

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

Naming the document

meta.title, meta.author and meta.subject write an optional /Info dictionary. They’re strings only, never dates, which is what keeps the output reproducible.

A grouped report carries an outline. Bookmarks mirror the group tree, one open entry per instance that drew something. Each entry nests by depth and takes the instance’s first group-header text as its title. A group instance that drew nothing has no position to target and gets no entry. Ungrouped reports carry no outline.

Embedding fonts for full Unicode

fonts maps a family name to TrueType bytes. Styles then select it by name, as "family": "Inter", and names match case-insensitively.

regular, bold, italic and boldItalic are one family’s variants. A missing variant falls back to the family’s regular.

fontkit is the optional peer that embedding requires. Embedded text uses the font’s own metrics, subsets to the glyphs the document uses, and carries a ToUnicode map. Copying text out of the finished PDF returns the original characters.

Mapping sans, serif or mono replaces that generic’s base-14 faces and reaches text that declares no family at all.

Hold the font record and its buffers across renders rather than rebuilding them. They’re the identity the target remembers a face by, so a fresh buffer means parsing the file again.

Which scripts come out right is a fact about the face you supply and the parser reading it. Shaping isn’t quario’s, and the target claims nothing per-script of its own. Fonts are host assets you trust, the same as registered functions.

Controlling where pages break

break: "page" starts every instance of that group on a fresh page. An instance already at a page top doesn’t force an empty one.

reset: "page" does the same and restarts the numbering, so a three-page invoice inside a batch reads 1 of 3 rather than 47 of 200.

page.header and page.footer are furniture drawn on every page, the header below the top margin and the footer above the bottom. Their heights come out of the body area, measured once per render, so keep them short.

visible: "=page.number > 1" suppresses a band on the first page of the current sequence, which is the cover-page recipe.

The target probes band heights once, treating them as both a first and a later page of a two-page document. Anything that varies with the real page count is invisible to that probe. Gate visibility on page.number, and keep page.total in text short enough that it can’t change the band’s height.

Taking the bytes

render(pdf(options), data) resolves the complete document as a Uint8Array. Rendering is async and hands the event loop back between batches, so a long report doesn’t block your server.

Writing the file belongs to the host. Put the bytes on disk, in object storage, or straight into a response body.

Compilation errors throw synchronously, and render errors reject with their location attached. A failure names the band and the source behind it rather than a byte offset in a PDF.

Paginated bytes, no browser.

npm install quario
Getting started
© 2026 quario · KvK 61815977