Escape

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. How that compares with other JavaScript PDF generators →

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.

Paper comes from the host or the document, one key at a time. A definition that works on any paper leaves page.size to the factory call. One file then prints on A4 for one customer and Letter for the next. A document that only works one way declares the paper itself. That might be a landscape statement, or a pinned header that knows what room it needs. Declaring the same key in both places is a render error rather than a silent winner. orientation in a document beside a host size isn’t both, because it turns whichever paper is in force.

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.

A table under a group keeps one set of column widths. The layout sizes each column from the widest content it holds in any instance, so the columns line up down the whole document. A table that renders once keeps the widths its own content asks for. The viewer and the editor show the same widths, because they paint the same layout.

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. The target draws it 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. A document may name A4 or letter in its own page.size, and a measured pair stays a host option.

page.orientation is the document’s alone, portrait or landscape. It turns the paper without changing which paper it is.

page.margin is a number of points applied to all four sides, or { top, right, bottom, left } naming each, 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.ts
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 label as its title. Where the group declares none it uses the instance’s first group-header text. The label exists to say what the visible text doesn’t. A group instance that drew nothing has no position to target and gets no entry. Ungrouped reports carry no outline.

A linked run becomes a link annotation. Each rectangle the layout drew for a run carrying an href gets a /Link with no border. That’s a URI action for an external URL, and a destination inside the document where the href names a group’s label. A run wrapping across two lines is two rectangles, so it’s two annotations. A link reaches only an instance that declares that label, never one whose outline title happens to read the same. The target drops a link to a label no instance declares rather than inventing one.

A collapsed group draws its header and its footer. A page has nowhere to put a row a reader could open. An instance whose group declares collapsed therefore renders as those two bands, leaving out the content between them. Nothing changes its aggregates, so the footer still totals them.

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 uses 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 places the page turns around a group’s instances — "before", "between", "after" or "around", as Laying a document out in bands sets out. An instance already at a page top doesn’t force an empty one.

reset: "page" restarts the numbering at that instance without turning a page itself, so a three-page invoice inside a batch reads 1 of 3 rather than 47 of 200. It requires break to be "before" or "around".

A short instance moves whole rather than splitting. An instance that would cross a page break travels to the next page instead, wherever a page could hold all of it. A group therefore doesn’t leave a line or two stranded at the foot. An instance taller than a page still breaks. This is best-effort, like the height probe: what’s guaranteed is that a group header travels with its first content.

page.header and page.footer are furniture drawn on every page, the header below the top margin and the footer above the bottom. The target subtracts their heights from the body area and measures them 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 yields to the event loop 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.