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