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