@quario/pdf
v0.11.1
Published
Tiny, browserless PDF render target for quario. Pagination, embedded fonts and deterministic bytes, with no Chromium.
Maintainers
Readme
@quario/pdf
The PDF render target for quario. Renders a report definition straight to a paginated PDF. No headless browser, no print CSS, deterministic bytes.
quario does the typesetting — pagination, keep-together, tables, fonts, the document outline —
through @quario/layout. That is the same layout the viewer paints on screen, so the preview and
the document break their pages in the same places. @cantoo/pdf-lib writes the file. You get typesetting plus pdf-lib: no Chromium
in your container, no page-load race, no fonts-not-ready flake.
Contents
- Install
- Quick start
- API
- Layout
- Fonts and text
- Outline
- Determinism
- Unlicensed marking
- Page furniture
- Documentation
- License
Install
npm install quario @quario/pdf@cantoo/pdf-lib arrives as a dependency. The engine is a peer, installed beside it. ESM-only,
Node 22+, and browser-ready through any standards-based ESM bundler. The renderer returns bytes, so the host decides where they go.
Embedding TrueType fonts also needs the optional peer:
npm install fontkitIt loads only when you pass fonts to the factory. The base-14 families need nothing extra.
Quick start
import { writeFileSync } from "node:fs";
import { pdf } from "@quario/pdf";
import { quario } from "quario";
const report = quario().report(schema, funcs);
const bytes = await report.render(
pdf({ page: { size: "A4", margin: 54 }, meta: { title: "Sales 2026" } }),
data,
);
writeFileSync("sales.pdf", bytes);Any schema the HTML target renders works here unchanged. Add page bands when you want running headers and page numbers. Page bands are the one part of the schema that only paginated targets consume:
const paged = {
...schema,
page: {
header: [{ type: "text", value: "{{ $.params.title }}", style: { size: 8, color: "#777777" } }],
footer: [
{ type: "text", value: "{{ page.number }} / {{ page.total }}", style: { size: 8, align: "right" } },
],
},
};visible: "=page.number > 1" on a band is the cover-page recipe. Gate band visibility on
page.number only. The layout reserves band heights before it knows the real page count, probing as a
two-page document. =page.total > 10 therefore reserves only the 8 pt gap a declared band always
costs.
page.total is fine in band text.
API
pdf(options?)
The target factory takes this target's host options, validates them at the call, and returns
the target you pass to render. The factory refuses an option it does not know. It throws a TypeError at the call for an unknown key. It throws one also for a key with a value of the wrong type. report() compiles once and report.render(pdf(options), data)
resolves the document bytes. One compile serves any number of configurations (A4 and letter from
the same report). Compile at startup and render per request. Definition problems throw at
report(), at compile time.
const report = quario().report(schema, funcs);
const bytes = await report.render(pdf({ page: { size: "letter" } }), data);The compiled report carries stream (the raw event generator), names, functions, and
paths, like every quario report. Engine-level options (query budgets, the license key)
live on the instance, and q.license settles with the verification result.
Rendering is asynchronous and returns the loop between batches, so a large report never blocks the host. Render-time failures reject with located errors.
Options
Page size and fonts are target configuration. The margin is the target's unless the document
declares page.margin, in which case the document's margin wins. A document and a target that
both declare one reject the render.
{
page: { size: "A4", margin: 54 }, // 'A4' | 'letter' | [width, height] in points
meta: { title, author, subject }, // optional /Info — strings only, never a date
fonts: { Inter: { regular, bold, italic, boldItalic } }, // TrueType bytes
}size defaults to A4 (595.28 × 841.89 pt). An array is a custom [width, height] in points. An
unknown size name is an error the factory throws. margin applies to all four sides and defaults to 54
(0.75 in). Text renders at 10pt when nothing declares a size: a document's type size is the
document's own, so it is the report's style.size, not a host option. Line leading is 1.4× a line's
largest font size.
Layout
Guaranteed normative behavior a conforming target must produce:
- Table header rows repeat after every page break. An unstyled table draws no rules. The target draws an authored border on a header row or a total row at the width written.
- A group header always travels with its first content unit: the first detail lines, or a table's header plus its first row. An instance's headers also repeat at the top of every page it continues onto (outermost first, then content) and stop when the instance ends.
- The total rows keep the last data row with them.
- A row that fits on a page is never split across one.
- A
breakon a group turns a page at every boundary its position names. reset: "page"on a group restartspage.number/page.totalat the page its leading edge opened.- The target honours authored column
widthpercentages. It measures the remaining columns from content and scales them to fill the rest. A table whose authored shares leave the width-less columns nothing is a definition error, so there is no over-commitment case. - Hidden cells keep their column slot.
- Page bands render on every page, their heights reserved out of the body area.
- A report header declaring
heightpins a box of that many points from the page top. Its items pack from the top and the next band starts where the box ends. - Every group instance opens with a half-line gap, dropped at a page top. Authored
spaceBeforedrops at a fresh page or strip top the same way. - An item or row a page cannot hold whole still carries its box: each slice draws the sides the break left it, the top on the first and the bottom on the last.
- A split lays out as one block at the height of its tallest slot. Every slot's box is the split's
height, and a slot's
valignplaces its content in that slack, as a table cell's does in its row's. - Page columns are strips: the flow fills one to its foot, moves to the next, and turns the page once the last is spent. A table restates its headings at a strip head, and a group's headers only at a page head.
- The band-role defaults (a bold, larger report header, and bold group headers) sit under the
author's own style.
uppercasecapitalises the drawn string before the target measures it. - An image draws at the size its
fitdictates (naturalat 96 dpi capped at the content width,widthscaled to it, aspect ratio preserved) and is never split across a page break: one that does not fit the remaining height moves whole to a fresh page.
Best-effort current implementation, pinned only as a regression guard where the layout suite holds it, and changeable without a breaking change: keeping a whole group instance together where a fresh strip or a fresh page could hold it, paragraph widow and orphan avoidance, the gutter between page-column strips and whether a region balances, slicing a row taller than the page, downscaling an image taller than any page, embedding repeated image bytes once, and where an image's page break lands. Do not write definitions that depend on these.
Bands flow vertically in walk order, matching the HTML target's divs. Group nesting adds no indentation, but every instance that draws opens with a structural half-line gap so groups read as blocks without authored margins.
Fonts and text
The base-14 Helvetica, Times, and Courier families carry the default output (family: "sans",
"serif", "mono", each with regular, bold, italic, and bold-italic faces), using WinAnsi
encoding. Characters a face cannot draw render as ? rather than failing the report, because
quario does not trust cell text, and one stray character must not take a document down. WinAnsi
is the base-14 limit. A TrueType face's cmap is its own.
For full Unicode, supply TrueType families and select them by name:
import { readFileSync } from "node:fs";
const target = pdf({
fonts: { Inter: { regular: readFileSync("Inter-Regular.ttf"), bold: readFileSync("Inter-Bold.ttf") } },
});
// then, in the schema: style: { family: "Inter" }Missing variants fall back to the family's regular. Embedded text uses the font's own metrics.
The target subsets it to the glyphs the document uses and carries a ToUnicode map, so extraction
and copy see the original text. quario trusts fonts as host assets, like registered functions. The
factory refuses a mapping without a regular (options.fonts.<name>.regular: required). Bytes
the parser cannot read reject the render, located at the same option path.
Links
A styled run that declares href becomes a link annotation
over the rectangle it drew, with no border. An external URL is a URI action; an href written as
# and a group's label goes to that instance's own outline destination, and a link to a label no
instance carried is dropped rather than invented. A run wrapping across two lines is two
rectangles, so it is two annotations.
The engine admits a URL against the host's scheme allowlist before this target sees it, so a
javascript: URL never reaches an annotation. See quario({ schemes }).
Outline
A grouped report carries a document outline (bookmarks) mirroring the group tree: one open entry
per instance, nested by depth, titled by the group's label, else by the instance's first
group-header text, else name: key. A group declares label to name its entry independently of
what the page shows. Ungrouped reports have no outline.
Determinism
This target pins the document's dates rather than stamping them, and meta is opt-in, so the
same schema, data, and options produce byte-identical output. That makes report bytes cacheable, diffable,
and safe to compare in tests.
Unlicensed marking
An unlicensed render draws the wording from report-start.marking once per page, after
everything else: a translucent grey line corner-to-corner over content and page furniture. The
per-page presence is normative. Exact geometry is best-effort. A licensed render draws nothing.
Page furniture
Paper size and fonts are options, not schema. The margin is an option a document may declare in its stead. Authored watermarks and finer break control stay out of scope. Keep-together and continuation headers are by construction, above.
The full contract is The PDF target, with each declaration's fate in the support matrix.
Documentation
The quario documentation is the reference.
The report schema is the normative
specification of what a report may declare, and
@quario/pdf is this package's own API.
License
Commercial software with readable source. Evaluation is free, unlimited, and watermarked. Per-developer licenses at getquario.com. See the bundled LICENSE.
Pass your license key once, on the instance. quario verifies it offline:
const q = quario({ license: "quario_..." });
await q.license; // { licensed: true, licensee: "Acme BV", id: "1-ACME" }