Every published release of the 9 packages the reference documents — 70 in all, read from the same install that builds these docs. The packages version independently and follow Semantic Versioning: a minor bump adds, a patch fixes.
| quario | 0.9.0 |
| @quario/html | 0.9.0 |
| @quario/pdf | 0.9.0 |
| @quario/xlsx | 0.8.0 |
| @quario/csv | 0.8.0 |
| @quario/docx | 0.1.1 |
| @quario/viewer | 0.9.0 |
| @quario/editor | 0.7.1 |
| @quario/layout | 0.6.0 |
Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
BREAKING: an image whose header does not state its size now fails on every target. A PNG or JPEG truncated before its dimensions used to render on the HTML and CSV targets — the browser coped, and CSV has no picture to place — while failing on the PDF and XLSX targets, which have to know how big a picture is before they can put it anywhere. One file, two verdicts, depending on where the report was sent.
It is now a single render error, raised where every other verdict on image
bytes is raised and naming the item’s source the same way — for example
header[0].source [=$.input.logo]: could not read the image's size from its bytes.
A report that renders as a PDF today is unaffected: those bytes already
failed there. What changes is a report that renders as HTML today on bytes no other target
would accept: it now fails, and the file it names is one no target could ever
have drawn correctly.
Nothing further has changed about what the engine promises: a file whose size reads but whose pixel data is corrupt is still nobody’s guarantee, and each target does whatever its own machinery does with it.
The image event carries the picture’s size. width and height, in
pixels, read from the same header the format is sniffed from. A target — the
built-in ones, or your own — places a picture from two fields instead of
parsing a PNG’s IHDR and a JPEG’s frame header for itself.
A page-number token says which page value it is. A value token whose
interpolation is exactly {{ page.number }} or {{ page.total }} now carries
field on the event stream, holding that same string. The value is
unchanged — the number the page band was called with — so nothing renders
differently; field tells a target whose own document format numbers pages
that it may write its own live field there instead of the number this render
saw. Anything computed from them ({{ page.number + 1 }}, {{ pad(page.total) }}) is an ordinary value and carries no field, so write the bare token
wherever a live number matters.
Styled runs. Bold a word, colour a phrase, or format one value inside a
sentence, and have it survive into the PDF and the spreadsheet — not only the
HTML, which was the only place <b> in a template ever meant anything. A run
carries the inline declarations only: no padding, borders or spacing, which
belong to a whole line or block.
A cell’s tokens carry a run’s resolved style on the tokens it covers, and
styledRuns(tokens) groups them back out — the same rule every built-in
target reads them through, so a custom target cannot drift from them.
RUN_STYLE_NAMES is the inline half of the vocabulary as a list, beside
STYLE_NAMES, for a tool that offers an author what a run may wear.
plan() now reports declarations that nothing will read. A definition can
be perfectly valid and still say something the engine quietly drops, and
until now the only way to find out was to notice the output was wrong.
plan() returns a warnings list beside problems — same shape, same
document order, one entry per declaration — and it currently catches three:
format on a cell whose value can never present a single interpolation —
"Due {{ due }}" under format: "date" — which the engine leaves unread;currency on a cell whose format is not "currency", which the engine
reads only under that kind and otherwise ignores, so the cell renders in
the instance’s currency and the code you wrote does nothing;width and the widths total under
100, which leaves the trailing share of the table unused — usually a column
edited down without its neighbours being adjusted.problems is unchanged and still fatal: a definition with problems compiles
to nothing, while one carrying only warnings compiles and renders exactly as
before. Hosts that ignore the new field are unaffected, and validate() is
untouched.
Warnings are advisory and deliberately incomplete — they catch what can be
seen in the definition alone, without data, so a quiet warnings list is not
a promise that every declaration will be read.
A format declaration now needs one value to speak about, and a cell can
give it one. Previously a format on a cell reached every interpolation in
it and skipped the literal text between them, which meant
"{{ amount }} of {{ quantity }}" under format: "currency" rendered the
quantity as money too — and the page and the spreadsheet disagreed about it,
the page formatting inside a sentence where the sheet did not.
A cell value may now be a list of styled runs —
{ "value": "…", "style": { … } } — each with its own declarations, and
format applies to a run holding a single interpolation.
What changes in an existing report: a cell that mixes text and an
interpolation under a format stops presenting that value and renders it
plainly, in every target. "Due {{ due }}" with format: "date" was
Due 14 Aug 2026 on the page and Due 2026-08-14 in the sheet; it is now
the plain form in both. To restore it, split the value and leave the
declaration where it is:
{
"value": [{ "value": "Due " }, { "value": "{{ due }}" }],
"style": { "format": "date" }
}
A cell’s format reaches its runs, so nothing moves and nothing is declared
twice. plan() reports the cells this affects wherever it can see them
without data.
round, floor and ceil reuse their formatters instead of rebuilding
one per call. The three reach the decimal you wrote through Intl, and
each call built a fresh formatter to do it — so a detail column of
{{ round(@.qty * @.price, 2) }} paid for one per row. A hundred-thousand
row report with two such columns drained in 3.5 s; it now drains in 0.2 s.
Every answer is the value it was before: the rounding mode and the digit
count are both part of what a reused formatter is found by, so no call can
be handed one built for another.
round, floor and ceil name themselves when they refuse an n. All
three threw n must be a number from 0 to 100, so a cell calling more than
one was told how it failed and never which call; the message now opens with
the function, as a reducer’s already did. The located path still names the
cell — one cell holds as many calls as you write, which is the whole reason
the name is worth having.
An n that cannot be read as a number at all now gets the same answer.
A BigInt or a Symbol reaching n from render data used to surface the
runtime’s own Cannot convert a BigInt value to a number, and an object
carrying a throwing valueOf surfaced whatever it threw — each located at
the cell but naming neither the function nor anything an author could act
on. All of them are now the same refusal.
isDiagnostic is documented for what it actually answers. The README
said it was true for “one of the stack’s located errors: quario’s own, or one
thrown directly by xprsn, sjabloon, or padvinder”, and its example read a
false as “one of your own functions failed”. Only an engine’s own error is
a diagnostic: quario’s verdicts on a document — an unknown reducer, a
misused inline reducer, image bytes it will not vouch for — are located just
the same and are not diagnostics, so a host following that example rethrew
report faults as its own. Nothing about the guard changes; it never behaved
the way the page described.
A misused inline reducer says what it is, instead of how it folds.
{{ min(1, 2) }} — the two-argument scalar min quario does not have —
failed at the cell with (rows || []).map is not a function, the fold’s own
internals, naming neither the reducer nor the mistake. It now reads
min is a reducer over an array, not a scalar function; got a number, and a
second argument that is not a lambda says so in the same words rather than
reaching a host as of is not a function. Register a function of your own to
shadow a built-in reducer when a report needs the scalar.
Two reducers used to answer instead of failing, each reading a length
off a value that is not a collection: count('ab') presented 2 — a
string’s own length, folded as if it were a count of rows — while count(1)
and avg(1, 2) presented nothing at all. All three are now the same located
error. A reducer over an absent array is unchanged and still not a
mistake: sum(@.lines) on a row carrying no lines is 0, exactly as an
empty array gives.
imageError(path, said, cause), the mint a render target raises an image
failure through. The engine vouches for an image’s magic numbers and no
further, so every failure past them belongs to whichever target was sizing or
embedding the file — and each was wording it its own way. This names the item
that asked for the bytes and keeps the failing class and message behind it,
so one report reads the same however it is rendered. It is not a diagnostic:
isDiagnostic does not answer for one.
Formatted cells render dramatically faster. The engine built a fresh
Intl formatter for every presented cell and now keeps them, keyed on
everything a formatter is built from — the kind, the locale, the digit count,
the currency code, and for a date its form and timezone. A report of a
hundred thousand formatted cells in mixed currencies went from 3.2 s to
0.2 s rendering to HTML. Nothing about what a cell presents changes.
The store is bounded and empties past its bound rather than growing, so a report presenting an unusual number of distinct formats costs what it always did and never a formatter belonging to another cell.
format takes a digit count, and a date takes a form. The declaration is
still a closed kind, and now the kind may carry the one modifier it
understands: number, percent and currency take digits, and date
takes form.
{ "header": "Rate", "value": "{{ @.rate }}",
"style": { "format": { "kind": "percent", "digits": 3 } } }
{ "header": "Total", "value": "{{ @.amt }}",
"style": { "format": { "kind": "currency", "digits": 0 }, "currency": "EUR" } }
{ "header": "Due", "value": "{{ @.due }}",
"style": { "format": { "kind": "date", "form": "long" } } }
The bare kind stays the shorthand — "number" means
{ "kind": "number" } — so no existing declaration has to change.
digits is a whole number from 0 to 20 and overrides the count the kind
would otherwise present, a currency’s minor units included: the example
above shows €1,235 on the page and builds "EUR"#,##0 in the worksheet.
The minor units remain the default, so a JPY cell still shows no decimals
beside a EUR one that shows two.
On percent, digits counts the places of the presented percentage, not
of the stored fraction: digits: 2 on 0.12345 gives 12.35%. Making the
stored value agree still takes round(x, 4) — the two count different
things, a factor of 100 apart.
form is one of short, medium, long, full. It is honoured exactly in
HTML and PDF, and approximated in the worksheet: a sheet date is a real
typed date its reader re-presents, so the shape is yours while the language a
month or weekday name is spelled in follows whoever opens the file. Pinning
that would stop the sheet reading naturally for its reader.
A modifier a kind does not understand is a definition error naming the cell —
{ "kind": "date", "digits": 2 } fails, as does a digits outside 0 to 20.
format layers whole: a cell that names it restates the whole declaration, so
naming a kind again resets the modifier. Only the whole declaration may be an
= expression; a computed count is written
"={'kind':'number','digits':@.dp}". Custom #,##0.00 patterns still belong
in funcs, and a fraction-digit count no longer needs one.
sort is stable: rows whose keys compare equal keep the order they
arrived in. The engine has always sorted this way; the promise is new.
currencyOf(style, options) joins the package’s exports beside format().
It answers which currency code a cell wears — its own when it declares one,
else the instance default — for a target that needs the code itself rather
than presented text. A cell that declared a code the engine could not accept
carries style.currency as null, so style.currency ?? yourDefault is the
wrong spelling and this helper is the right one.
A cell may name its own currency. currency is a new style declaration
beside format — a three-letter ISO 4217 code saying what a money cell is
denominated in.
{ "header": "Amount", "value": "{{ @.amt }}",
"style": { "format": "currency", "currency": "[email protected]" } }
Until now the code lived only on the instance, so a report whose rows arrive
in different currencies — a revenue listing, a statement, an outstanding-
invoices table — had to format through funcs, and that costs the value: a
formatter returns a string, so the cell leaves the typed seam and the
worksheet gets text with no number format. A declared code keeps the number.
The cell’s own code beats the instance’s, which stays the default for
documents that only ever hold one denomination. Locale and timezone do not
follow it into the document: a locale describes the reader, while a currency
code describes the value. The declaration takes an expression like any other,
so a total under a group keyed by currency reads "=byCcy.key". It shares
format’s sites exactly — text items, column cells, headers and totals, and
a column’s own style.currency declares an amount column once — and, like
format, it is refused on an image, on row.style and on the report
default. It is unread without format: "currency".
A literal that is not three uppercase letters is a definition error. An expression that resolves to something else stays lenient, and the cell then renders as plain display text: it does not fall back to the instance’s currency, because labelling a figure in a denomination nobody named is worse than not formatting it.
round, floor, ceil and abs are callable from any expression,
without registering anything. round(x, n) gives x to n decimal places;
n defaults to 0 and must be a number from 0 to 100; a fraction in it
truncates. This is the lever a report needs when arithmetic across exact values lands on an inexact one:
a receipt whose {{ $.subtotal + $.vatLow + $.vatHigh }} computes
83.75999999999999 writes 83.76 once the sum is wrapped in round(…, 2).
It matters in the two targets that keep the number rather than presenting it
— the workbook stores the value, and a CSV field shows it as text with no
number format to hide behind. A registered function of the same name still
wins, so nothing an existing report does changes.
They round the decimal you wrote, not the binary value it is stored as:
round(2.675, 2) is 2.68 and round(0.615, 2) is 0.62. Note that a
percent cell presents its value multiplied by 100, so matching a
two-decimal percent display takes round(x, 4). A value that is not a finite
number passes through untouched rather than becoming zero; an n outside the
range is an error naming the cell it came from.
split-start events carry path, the split definition’s schema path, as
item and image events already do. A consumer can now name the definition
behind a split without tracking position.
fractionDigits(kind, options) is exported beside format(): how many
fraction digits a kind presents, or nothing for a kind that carries no
count. It is the table the official targets read, and it is public so a
consumer writing its own target presents the same digits they do.
A date cell with no form now presents medium, not the runtime’s own
default. A date that names no form used to render whatever the platform’s
default date format was — under en-US, 8/14/2026 — which matched none of
the four forms you can now ask for. It renders Aug 14, 2026 instead, and
14 Aug 2026 under en-IE. In the worksheet the number format moves from
yyyy-mm-dd to dd mmm yyyy.
If you want the old shape back, say so: { "kind": "date", "form": "short" }
is the nearest of the four. This is the one change here that moves output
under an existing report.
style.format crosses the event stream resolved, not as authored. A
custom target reading style.format now sees { kind, digits } or
{ kind, form } where it saw a bare string: "number" arrives as
{ kind: "number", digits: 2 } and "date" as
{ kind: "date", form: "medium" }. A currency whose code cannot be read
arrives as { kind: "currency" } with no count at all, which is the signal
to write no number format. Read style.format.kind where you read
style.format, and take the digit count off the declaration rather than
computing one.
fractionDigits takes the whole style. Its first argument was the kind
and is now the style, matching format() and currencyOf().
fractionDigits("number") becomes
fractionDigits({ format: "number" }). Targets no longer need it at all —
the count rides on the declaration the stream carries.
typed()’s second argument is the resolved declaration. Pass
style.format as it arrives on the stream; it reads the kind from the
object. The shorthand string is still accepted, and omitting the argument is
unchanged.
A rejected box value is now dropped from the resolved style rather than
carried on it. A border* or padding* declaration written as an =
expression used to cross the render stream with whatever the expression
answered, even a value the vocabulary rejects; every other name was already
dropped. It is now dropped too, so one rule covers the whole vocabulary and
currency is its only exception.
Nothing a report renders changes. A border side is still won whole by the node that names any of its three parts — that is now settled from the declarations rather than from what they resolve to, so a rejected part still cannot hand the node its row’s other two. A side left incomplete still contributes nothing rather than becoming a solid black stroke, and a rejected padding still falls to the target’s own inset.
What changes is what a consumer reading the stream itself sees. Where a cell
had style.borderTopColor holding an unusable value, the key is now absent:
// before
cell.style; // { borderTopWidth: 1, borderTopColor: "2-6-A-2-2" }
// after
cell.style; // { borderTopWidth: 1 }
Every target in this release already checked such values before reading them and is unaffected. A consumer that inferred border-side ownership from which keys were present should read the declarations instead; one that validates what it reads needs no change.
format() now takes the cell’s resolved style instead of its format
kind. The signature is format(value, style, options), where it was
format(value, kind, options). This is what lets the helper read a cell’s own
currency code alongside the kind. Callers pass one property less:
// before
format(token.value, style?.format, intl);
// after
format(token.value, style, intl);
A caller holding only a kind wraps it as { format: kind }.
A table’s total is now a block holding its rows, so what belongs to the
totals as a whole can be said once. Before, total was the array of rows
itself:
"total": [{ "cells": [...] }, { "cells": [...] }]
Now it is an object whose rows is that same array, plus a style and a
visible of its own:
"total": {
"style": { "paddingTop": 0, "paddingBottom": 0 },
"rows": [{ "cells": [...] }, { "cells": [...] }]
}
The rows themselves are unchanged — still { "cells", "style?", "visible?" },
still covering every column exactly once. A total that is still an array is
a definition error reading detail.total: expected an object, and a total
with no rows reads detail.total.rows: expected an array. Total cell paths
gain the one segment: detail.total.rows[r].cells[i], which is what a
total-row cell now carries as its path and what a located problem names.
The block’s style is the layer below each row’s — box under box, every
other declaration under the row’s own — and takes a total row’s vocabulary,
the whole style set less flow spacing and format. Where the block, a row
and a cell all speak, the innermost one that names any of a border side’s
three keys takes that side whole, exactly as a cell already did over a row;
padding layers per name. Exact false on the block’s visible omits the
totals entirely: the rows are never evaluated, none of them votes on a
width-less column, and the output is what leaving total out gives.
This is why the change earns a break: a table’s header row has detail.header
and its data rows have detail.row, but the totals — the one kind whose rows
a document writes out one by one — had no level above them, so “these sit at
the band’s line pitch, not the table’s row pitch” had to be spelled on every
row. columns is unaffected and needs no such block: a declaration reaching
every column reaches every data cell, which is what detail.row’s style
already says from the other side.
table-start now carries the header row, instead of a header cell on
each column that a custom target had to collect for itself. The event’s
header is { cells, style? } — the same shape a row or total-row
event has — where cells holds one cell per column no neighbour’s span
covers, so it is shorter than columns whenever a header spans. Each header
cell carries its column’s path, as a row cell does; a spanning header takes
the first column it covers. What is left on columns is the geometry: one
entry per column, holding a width percentage when the document authored
one. Nothing an existing report produces changes in any of the shipped
targets — this is the seam between the engine and a target, not the document.
// before
const headers = event.columns
.filter((column) => column.header)
.map((column) => Object.assign({}, column.header, { path: column.path }));
const headerStyle = event.style;
// after
const headers = event.header.cells;
const headerStyle = event.header.style;
columns[i].header, columns[i].path and table-start.style are gone. A
target that reads only columns[i].width is unaffected.
format now presents a fixed number of fraction digits, and every target
presents the same number. Until now the digits were whatever each edge
happened to default to: format: "number" showed up to three decimals in
HTML and PDF and exactly two in a worksheet, and format: "percent" showed
none in HTML and PDF against the worksheet’s two — so one cell in one
document read 12% in the PDF and 12.35% in the workbook beside it.
number and percent are now two places, always, in every target that
presents. currency is its currency’s own minor units, so a JPY amount has
none and a BHD amount has three, where every currency previously got two.
Author-visible, with no document change:
| value | before | now |
|---|---|---|
number 1000 |
1,000 |
1,000.00 |
number 0.12345 |
0.123 |
0.12 |
percent 0.21 |
21% |
21.00% |
currency JPY |
¥1,234.57 |
¥1,235 |
Only the digit count converges. Grouping separators, a currency symbol’s
position and a date’s shape still follow each target’s own conventions, and
in a worksheet they follow whoever opens the file. date is unchanged.
A report that needs different digits formats through funcs, as before.
A currency code that is not a readable currency now presents nothing in
every target, rather than the worksheet inventing a number format from it
while the other targets fell back to the unformatted value.
A split slot is typed with the declarations the traversal accepts.
SplitSlot reused the band items’ style types, so TypeScript refused
valign on an image slot — legal there, and only there — and admitted
spaceBefore/spaceAfter on a slot, which the engine rejects. Two new
exported types, SlotStyleDeclarations and SlotImageStyleDeclarations,
now say what a slot may declare.
A page band closure is typed as what it returns. PageBandRenderers
declared header/footer as returning item and image events only, while a
split in a page band has always come back as its bracket — split-start,
one event per slot, split-end. The closures now return PageBandEvent[],
a new exported union naming all four, so a TypeScript host reading a page
band no longer types role onto a split-end that carries none.
A computed style value the schema does not accept now leaves the layer below in place, which is what the schema has always said it does — “contributes nothing, same as omit”. The name is dropped from the resolved style rather than passed on, so no target has to decide what an unreadable value means.
Before, the value reached each target and they disagreed. A detail item with
"bold": "[email protected]" over a 1 rendered bold in a PDF and a worksheet but not
in HTML. On a report header, where those two targets carry a heavier default
of their own, a 0 rendered not bold in both while HTML left the header
bold. The same held for every other declaration: a report header whose
"size": "[email protected]" resolved to "big" rendered at 14pt in HTML, at the body
size in a PDF, and at no size at all in a worksheet. Now all three keep the
layer below in every case, and only a real false turns a flag off.
This covers family, size, the five flags, color, background, align,
valign, format, spaceBefore and spaceAfter, and only where the value
comes from an = expression — a literal has always been checked when the
report was compiled. It reaches inside a split too: a slot whose format
expression resolves to something other than the four kinds now keeps the
split’s own kind instead of overwriting it.
Two declarations are deliberately unchanged. A border or padding name is kept
rather than dropped, because a node that names any part of a border side
takes that side whole from its row — dropping the name would hand the node
the row’s other two and draw an edge nobody declared. And a currency code
that is not a code still crosses as null rather than disappearing, so a
cell that named a denomination is never presented under another one.
A format kind that an = expression resolved to the name of a built-in
object member — "toString", "constructor" — now contributes nothing and
the cell falls back to display text, as the documented rule says any
unrecognised kind does. Before, such a cell could render [object Undefined].
A literal was never affected: it is checked against the four kinds.
A pinned report header now refuses a lead on every band that can sit under
its box, and only where the document settles which one that is. When
header declares a height, an authored spaceBefore on the first item of
the band below the box would push that band off the pin, so it is refused.
Which band is below the box was read too narrowly, and which item leads it
too eagerly.
Reports that used to validate and now do not: a lead on the empty band’s
first item, and a lead on a group header below the first one. Both were
already refused at render, but only on the render that reached them — an
empty band’s lead detonated the first time a filter emptied the row set,
on a report that had validated clean for months. Both are definition errors
now, on every render.
Reports that used to be refused and now validate: a lead on an item that may
not appear at all — one whose visible is an expression, or an image whose
source yields nothing. Such an item was blamed as though it always
occupied, so a document that renders correctly could not be compiled. The
check now stays silent wherever only the data decides which item comes first,
and the render refuses it at the moment the answer exists.
Two smaller repairs come with it. A spaceBefore that is not a paintable
number — negative, NaN, infinite — no longer produces two problems on one
key, one telling you to make it 0 and one telling you it is not a valid
measurement; only the second is reported, because it is the one that is
wrong. And a split that leads a band now names itself in the error rather
than reporting against the report header, which is a band this rule never
covers.
STYLE_NAMES, every name in the style vocabulary as a read-only list,
in the order the specification’s table lists them. A tool that offers the
vocabulary — the editor’s style rail — reads it from here instead of
carrying a copy that has to learn each new name.span lets a cell cover several table columns, so a label reaches across
them instead of being pushed sideways by empty cells. A literal positive
integer; absent is 1. It is legal on the two cells a document writes: a
column’s header, where the columns it covers omit their own header, and a
total row’s cells, whose spans must sum to the column count exactly. A data
row’s cells are the columns’ own, so there is no cell there to carry one — a
line that runs across the table is a split item below it. A span covers and
does not vote: a cell over more than one column has no say in their widths,
and a column nothing votes on takes the cell-padding floor.valign joins the style vocabulary: "top", "middle" or "bottom",
a literal or an = expression like align. It is legal exactly where a box
is taller than its content asked for — table cells, headers, totals, row,
and split slots, text or image — and a definition error on a stacked item and
on a band image, whose boxes have no such slack. A row’s or a split’s layers
under its cells’ or slots’ own. Undeclared is not a declaration: each target
keeps its own default.style now resolves onto that row’s cells, the box
included, instead of meaning something different on every output. Before,
padding and border on detail.header, detail.row or a total row’s style
reached no cell at all: a fragment dropped them, a worksheet turned them into
an edge on each cell, and a page drew one box around the row. Now they layer
under each cell’s own, the way bold and valign on a row already did, so
one declaration means one thing everywhere.
What this makes possible is declining the cell padding a row at a time:
"paddingTop": 0, "paddingBottom": 0 on detail.row sets the pitch for
every data row, where before it had to be written on each cell.
Two things move for documents that already declared a box on a row.
A row’s borderLeft is now an edge on each of the row’s cells rather than
one at the row’s outer left — write it on the first column’s cells to get the
single edge back. And a row’s border now occupies height, as a cell’s border
always has, so a bordered row is taller by its border’s width.
A border side is won whole by the cell: a cell naming any of that side’s
three keys takes the side entirely, so a cell that failed soft on one name
does not inherit the row’s other two.
On the event stream, a row, total-row or table-start style now
carries only what layers by ordinary means, and a row whose whole block was
box carries no style at all.format: "date" now reads a date string, not only a Date. A JSON
document has no date type, so the kind could not be reached from parsed
data without reviving every date field by hand first. It now revives two
forms itself: a calendar date 2026-08-14, and a timestamp naming its
offset (2026-08-14T12:30:00Z or +02:00). This changes existing
output: a cell declaring the kind over "2026-08-14" rendered
2026-08-14 and now renders 14/8/2026 under an en-IE instance. A
zoneless 2026-08-14T00:00:00 is not read — it means local time, so it
would present a different day per machine — and neither is a loose
14/8/2026, a partial 2026-08, a lowercase t/z, or a day that does
not exist. Anything unread renders as authored, as before.
The revival is the Date a host would have injected, timezone included: a
calendar date is UTC midnight, so under a western instance timezone it
presents as the previous day, exactly as new Date("2026-08-14") does.
Only date revives — number, currency and percent never read a
string, because JSON already carries numbers — and epoch milliseconds stay
a number under every kind.
typed(tokens, kind?) takes the cell’s format kind. Passing "date"
opts into the same revival, for consumers that write typed cells. The
one-argument call is unchanged.
The TypeScript declarations accept the box. The per-side padding* and
border* names, and the table’s detail.header, were validated by the
engine from 0.3.0 but were missing from the shipped declarations, so a
report declaring the padding or the border sides that release introduced
was rejected by the compiler as an unknown property and needed a cast to
get past it. They now type exactly as the engine reads them, on a band
image’s style as well. Four names come with them, for annotating your own
helpers: LineStyle ("solid" | "dashed" | "dotted"), beside Align and
FormatKind; TableHeaderBox, the type of detail.header; and
BoxDeclarations with Side, the per-side names as one type, which
StyleDeclarations was built from without being nameable. Nothing
about rendering changes: a report that compiled through a cast produces the
same output without one.
format is a closed style name. number / currency / percent /
date on text items, column cells, headers, and totals. Not images, not
row.style, not the report default. Locale, currency code, and timezone
live on quario({ locale, currency, timeZone }). The public format()
helper is how targets present a kind; a kind on the wrong type contributes
nothing.height from the page top. Dual-shaped
like detail: an item array, or { height, items }. page.margin is a
document field (one number, all four sides), required with height and
legal without. Authored spaceBefore on the first occupying item of the
next band is refused.spaceBefore / spaceAfter return as item flow spacing. Blank space
before or after a band item, in points, including band images and splits as
band items. Adjacent gaps add. Table cells, row.style, headers, totals,
and split slots refuse the names. spaceBefore drops at a fresh body page
or strip top; page-band items keep it. Leading and inset stay cut.paddingTop / Right / Bottom / Left (points, ≥ 0) and, per side,
border*Width, border*Style (solid | dashed | dotted),
border*Color (#rgb / #rrggbb). A border side is all three names or
none; width 0 is no stroke; an incomplete literal is a definition error,
and an incomplete expression result at render contributes nothing rather
than a solid black stroke. The names are legal wherever background is,
including images, plus row.style. The report default still takes only
family and size. Column % widths are border-box.detail.header is the header-row box. { style } only, a different
path from columns[i].header. It crosses the seam as table-start.style.total was one row: a flat cell
array or { style, cells }. After, it is absent or a non-empty array of
{ cells, style?, visible? }. A one-row total is [{ cells: [...] }].
total: [] is a definition error. Paths are detail.total[r].cells[i].
The stream yields one total-row per emitted row.size. Empty display
used to take the report default’s leading in PDF and collapse in HTML;
"a\n\nb" broke in PDF and collapsed to a space in HTML. A visible item
now occupies at least one line set in that item’s size, and a literal
newline is a line break, on every target that can show a line. Empty
table cells stay contentless for height. This is not a spacing primitive;
visible: false is still how an item leaves the layout.style block for the whole document. A top-level
style beside header/detail/footer states the typeface a report is set
in, so a document with one face names it once instead of on every item, every
table column, every header and every total cell. It takes family and
size; any other declaration there is a located definition error, exactly as
a text declaration on an image item is. Values are literals or =
expressions like any other style block’s, resolved once per render in report
scope.report-start carries the resolved report default as style. A
report-level fact, never merged into an item’s own style: an event’s
style stays what the author wrote on that node, and a consumer composes the
default itself, once — under its own band-role defaults and under every
event’s own style. Absent when the report declares none, so a consumer
written before this field renders in its own baseline exactly as it did.uppercase style declaration. A boolean beside bold and italic,
literal or an = expression, for the capitalised column labels business
forms are usually set with. It is capitals, not small caps — real small caps
need a font feature the PDF target’s built-in faces cannot supply, so the
declaration promises only what every target can draw. Image items keep
refusing it, as they refuse every text declaration.split item places values across the line instead of down the band.
The invoice header’s “seller left, customer right”, which a band could not
say before. { "type": "split", "slots": [...] } takes two or more slots,
each an ordinary text or image item plus an optional width percentage
under the same rules a table column’s obeys; width-less slots divide what
the sized ones leave, evenly. A split is always the full content width and
never nests — it says what sits beside what, never where anything is — and
a slot that renders nothing keeps its width, so a line’s geometry never
moves with the data. Splits may appear in every item array except table and
total cells.split-start / split-end bracket a split’s slots on the event stream.
split-start carries the slot geometry, then one ordinary item or image
event per slot in order, then split-end. Existing consumers need no
change: the walk driver’s missing-handler rule means a target that ignores
the bracket still receives the slot items and renders them stacked.quario().plan(schema, funcs?) hands the whole traversal over at once.
Returns { report, problems, anchors } from one descent: the compiled
report (null while the document has problems), every problem as
{ path, source?, message, diagnostic? }, and a map from each compiled
source’s schema path to the anchors and group handles it reads. A host that
validates and renders per edit — an editor — pays one traversal instead of
two, can point at a problem in its own UI without parsing a message apart,
and can tell where a node may safely move. validate() is unchanged: its
strings are those problems’ message fields.validate() retained
start/end offsets on at most one problem per document; the structured
list carries each problem’s own, so a consumer can underline the offending
character in every faulty expression rather than only the first.item, image and
group-start events carry path, the schema path of the node that
produced them; table-start carries detail and its columns their own,
and every table cell carries its column definition’s path (a total cell its
detail.total[i] entry). Rows carry no identity — a row is data, not
schema. Additive: a consumer that ignores the field is unaffected.display(value) joins one value the way text() does. The scalar
display rule behind the token join, re-exported beside text() so a stream
consumer that stringifies token values itself renders exactly what the
official targets render — Dates included.maxDepth: Infinity opts a query budget out. The data query’s traversal
budgets accept an explicit Infinity per key for “this budget, unbounded”.
The 500-deep default is unchanged — it is now padvinder’s own, applied for
every consumer rather than added by quario at the seam.data
query that does not parse now surfaces with padvinder’s code and
start/end offsets into the query you wrote — filter faults included —
alongside the band/item path quario already attached. The code names the
category of mistake: PADVINDER_MISSING_ROOT, PADVINDER_BAD_SELECTOR,
PADVINDER_UNCLOSED_BRACKET, PADVINDER_BAD_STRING,
PADVINDER_UNKNOWN_FUNCTION, or PADVINDER_SYNTAX for a path character or
filter body that is open-endedly not a query. Traversal budgets exceeded at
render time keep limit/actual and carry no span.$ at the render base and @ on the detail row, and
sjabloon now renders over it as-is. A 4-column stream over a million rows
went from 1.7s to 0.5s. No report changes what it renders. Requires
sjabloon 0.11.functions carry signatures. Each entry is now
{ name, arity, doc? } instead of a bare name — arity from the function’s
declared parameter count (or its own numeric arity where rest parameters
mislead length), doc from an own doc string when it carries one — in
the same call-first-seen order. names is unchanged. Requires xprsn 0.11
and sjabloon 0.11.Date renders as ISO 8601 UTC, the same on every machine.
Display text for a Date value was String(date), which bakes the
rendering host’s timezone and locale into the output — so one report
produced different bytes on different machines. Every target now renders a
valid Date as its toISOString() form through the one shared display
rule; an invalid Date keeps its deterministic Invalid Date text.
Reports that want a formatted date keep using a registered function,
exactly as before.match()/search() used to
produce a plausible empty report with no signal; report() and
validate() now surface it at compile time as a located error, with
offsets at the pattern literal in the query. The class and code are
treffer’s — a malformed pattern is a SyntaxError with TREFFER_SYNTAX,
one past treffer’s limits a RangeError with a TREFFER_MAX_* code and
limit/actual — on the same rule that already gives xprsn’s codes to a
fault in an expression: the engine that decided the fault names it. A
pattern that arrives from render data keeps RFC 9535 semantics and still
matches nothing at render time. Requires padvinder 0.8.isDiagnostic. Host errors are wrapped as plain
errors with no diagnostic metadata, exactly as before. Requires xprsn 0.10,
sjabloon 0.9, and padvinder 0.5. No report changes what it renders.report() rethrows
the first definition problem’s located engine error; it chose that error by
probing for a code property, so a host error class that stamps code on
itself in its constructor was rethrown as if the engines had raised it. The
choice now goes through the same identity-based authentication as
isDiagnostic; everything else falls back to a plain SyntaxError naming
the first problem, as before.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
<img> now carries width and height attributes holding the image’s
natural size in pixels, which lets a browser reserve the right aspect ratio
before the picture decodes. The fit mapping is unchanged and still decides
the rendered width, now paired with height:auto so the height follows the
width instead of staying pinned: an image capped narrower than its file
scales rather than squashing. Nothing an author writes changes, and no
existing report renders differently once its images have decoded.Styled runs render as spans. A cell value written as a list of styled runs
emits one <span style="…"> per styled run, carrying that run’s whole
resolved inline style; unstyled runs stay bare, so a cell with no runs emits
exactly the markup it always did. Every interpolated value is escaped as
before, and the span’s own CSS is escaped like any other attribute value.
A format inside a sentence now presents where the engine says it does. A
cell mixing literal text and one interpolation under a format renders the
value plainly, as the spreadsheet and CSV targets already did; splitting the
value into styled runs is how a value inside a sentence is formatted.
The reference stylesheet no longer paints a look no other target has.
@quario/html/style.css dropped the hairline under a table’s header row, the
hairline and bold weight on its total rows, and the rule above the report
footer. A report that declares no borders now renders without them on screen,
which is what the PDF and the worksheet have always shown for the same
report — the difference was the stylesheet’s alone, and it also surfaced as a
cell whose computed border colour failed keeping a black stroke here while
every other target drew nothing. The sheet now carries the band-role defaults,
the table’s structure and the honor and pagination rules, and no look beyond
them. The classes are unchanged, so a host that wants the old look adds three
rules to its own sheet after the shipped one:
.q-table thead th {
border-bottom: 0.5pt solid #000;
}
.q-table tfoot td {
border-top: 0.5pt solid #000;
font-weight: bold;
}
.q-item.q-report-footer {
border-top: 1pt solid #000;
padding-top: 4pt;
}
currency code is honoured at the markup edge, ahead of the
instance’s default, so a listing whose rows arrive in different currencies
presents each in its own. A code the engine could not accept leaves the cell
as plain display text rather than presenting it in the instance’s currency.format now show a fixed two fraction digits,
matching every other target. format: "number" on 1000 renders
1,000.00 where it rendered 1,000, and 0.12345 renders 0.12 where it
rendered 0.123; format: "percent" on 0.21 renders 21.00% where it
rendered 21%. format: "currency" follows the currency’s own minor units,
so a JPY amount loses the two decimals it never had. date is unchanged, as
are grouping separators and symbol placement. The digits come from the
engine, so a cell reads the same here as it does in a workbook.colspan, ahead of its style; a span of one
emits no attribute. The <colgroup> still names every column, so the
geometry is the document’s and only the cells merge — which means the header
row can carry fewer <th> than there are columns.valign renders as vertical-align, on a cell or on the row’s <tr>,
which the browser’s own inherit on cells carries down. On a split slot the
item itself becomes a grid with align-content, so the content moves inside
the box the slot wrapper already stretches.detail.header, detail.row or a total row’s style are emitted
inline on each <th>/<td>, where they also beat the reference
stylesheet’s own cell padding. The <tr> still carries the row’s other
declarations and still never carries a box, which it could not honour.
Fragments of documents that declared a box on a row gain borders and padding
they previously rendered without.vertical-align: top on .q-table tr, which the cells
inherit.
Before, a browser centred the shorter cells of a wrapping row while the PDF
painted them from the top, so the same report read differently in the two.
A host stylesheet overrides it the ordinary way.display:grid, so the item inside it fills the row; a slot’s item markup is
unchanged. See the @quario/layout changelog for the rule and for what it
costs a report that relied on the short box.format: "date" now presents as a date. The
engine’s format() helper revives the two read forms, so a cell that
rendered 2026-08-14 verbatim now renders the presented date. See the
quario changelog for the forms and the timezone rule.format stringifies at the markup edge. The kind is presented through
the engine’s format() helper from the instance locale, then escaped like
any other interpolated value.page.margin becomes @page{margin:<n>pt}. Report-header height
is unread: a CSS height would pin in the body box, not from the page top.spaceBefore / spaceAfter map to margin-top / margin-bottom.
Report and group containers are flex columns so adjacent authored gaps add
rather than collapse.box-sizing:border-box rides the same attribute. A side that does not
resolve all three names emits nothing, so a missing colour never becomes
the browser’s solid black. Header-row, row, and total-row boxes are
withdrawn from <tr> — CSS does not box a table row — while other row
styles still inherit.<tfoot> holds every total row. Extra rows are extra <tr>; the
section still closes at table-end..q-item and .q-table th, td in the reference
stylesheet, the same pattern as .q-break — class is the hook, the rule
is overridable. Empty table cells still collapse. Host CSS still owns
leading; this is not line-height: 1.4.false flag now emits its CSS off-value. bold: false used
to emit nothing, so the reference stylesheet’s band-role font-weight: bold
still won on screen while PDF rendered regular. Exact false now writes
font-weight:normal (and the matching off-value for italic, uppercase, and
the decorations), which beats the sheet. Omit and a non-boolean result still
emit nothing.html({ fonts }) says what a family name means on your page. A report
declares the typeface it wants — family: "Instrument Sans", or the
portable family: "mono" — and this option resolves that name to whatever
CSS means on the page it is displayed in:
html({ fonts: { "Instrument Sans": "var(--font-instrument-sans)" } }). Any
CSS font-family value works, so a custom property, a font stack or a
quoted name are all fine; a value containing ; or } throws from html()
rather than at render. Names match case-insensitively, and sans, serif
and mono can be mapped like any other, which is what lets one definition
say “the mono one” and each surface answer for itself. Useful wherever your
faces are registered under names you do not control — a build tool that
hashes @font-face names, for instance. The mapping resolves families a
report declares; the face of text that declares none stays your
stylesheet’s, so it is still one CSS rule away.<div class="q-report">, and a report that declares a style carries it
there as an inline font-family/font-size — written once for the document
rather than repeated on every item, with CSS inheritance doing the rest. The
unlicensed marking stays outside the root, as the fragment’s first element
and the root’s sibling, so no authored style can reach it.uppercase maps to text-transform:uppercase. The markup carries the
text exactly as authored and only its rendering is capitalised, so what a
reader selects, copies, or hears from a screen reader is your own casing.<div class="q-split q-<role>"> carrying inline display:flex, with each
slot a <div class="q-slot"> sized inline — width:<n>% for an authored
share, flex:1 for a width-less one. The slot item inside keeps its
ordinary q-item container, so an item’s markup never depends on where it
sits. Placement is emitted inline for the same reason page columns are: it
is document structure, not one of the visual defaults this target leaves to
your stylesheet.html({ paths: true }) maps rendered output back to the schema. Each
element whose event carries a schema path gains data-q-path: item and
image containers, each group instance’s container, the table, and every
<th>/<td> (the column definition’s path; total cells their own entry).
One definition renders many times, so one path appears on every instance of
it. Escaped like every generated attribute, and off by default — the
attribute is weight a plain display pays for nothing.family carrying characters that cannot appear in a
family name was stripped down to whatever survived and emitted anyway, which
named a face that could not exist — family: "var(--my-font)" became
font-family:'var--my-font'. Such a name now contributes nothing, so the
text renders in the face above it: the report default, or your stylesheet’s
baseline. Ordinary names are unaffected. To resolve a name your page knows
under a different one, map it with html({ fonts }).<div class="q-report"> wraps every
render, whether or not a report declares a default. Host CSS that assumed
items and tables were the fragment’s top-level children needs a descendant
selector; nothing else about the markup moved..q-report. It was
on .q-item, .q-table and .q-unlicensed — a direct rule, which would
have beaten the report default an author writes on the document. The marking
keeps its own rule, being outside the root. Same look, one rule up.Date escapes to ISO 8601 UTC, the same on every machine.
Interpolated Date values rendered through String(date), which bakes the
host’s timezone and locale into the markup. They now render as
toISOString() text through the engine’s shared display rule — escaped
like every other value — so the same report produces the same HTML
everywhere. Formatted dates keep coming from registered functions.@quario/html/style.css
now sets sans-serif on report text, as an ordinary rule any host overrides
by source order.q-*
classes, every interpolated value escaped. Pass html() to report.render
and wrap the fragment in your own page. The default look ships as
@quario/html/style.css, not as inline style in the markup.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
The package now accepts only a version of @cantoo/pdf-lib it can import. The declared range is
~2.9.1 rather than ^2.9.1.
Earlier versions accepted @cantoo/pdf-lib 2.11.0, whose ESM build imports its font metric JSON
without an import attribute. A fresh install could resolve to it, and importing @quario/pdf then
failed on Node before rendering anything, with TypeError [ERR_IMPORT_ATTRIBUTE_MISSING] naming a
.compressed.json file inside that package. Node 22 and Node 24 both report it. If you saw that
error, reinstall — nothing in your own code has to change.
Updated dependencies
background or border* on an image item hugged the picture and now spans
the content width, or the slot’s share inside a split; the picture itself
does not move. See the @quario/layout changelog for the rule and for what
it costs a report that relied on a border hugging a logo.background
as a highlight behind its text.format inside a sentence now presents where the engine says it does. A
cell mixing literal text and one interpolation under a format draws the
value plainly, as the spreadsheet and CSV targets already did; splitting the
value into styled runs is how a value inside a sentence is formatted.currency code is honoured, ahead of the instance’s
default, so a listing whose rows arrive in different currencies presents each
in its own. A code the engine could not accept leaves the cell as plain
display text rather than presenting it in the instance’s currency.format now show a fixed two fraction digits,
matching every other target. format: "number" on 1000 renders
1,000.00 where it rendered 1,000, and 0.12345 renders 0.12 where it
rendered 0.123; format: "percent" on 0.21 renders 21.00% where it
rendered 21%. format: "currency" follows the currency’s own minor units,
so a JPY amount loses the two decimals it never had. date is unchanged, as
are grouping separators and symbol placement. The digits come from the
engine, so a cell reads the same here as it does in a workbook.Invalid typed array length: 0 — the PDF writer’s own words,
naming neither an image nor an item, so a report with two pictures gave no
way to tell which was at fault. The message now begins with the item’s
source path and says the image could not be embedded, keeping the writer’s
account after it and on cause. A JPEG is unchanged: its header is read but
its pixel data never is, so a corrupt one still embeds without complaint.@quario/layout. It takes no part in measuring them, and a spanning row that
slices across pages or page-column strips follows the geometry of the strip
each slice lands in.valign on table cells and split slots, through @quario/layout: middle
and bottom place the content in the height its row or split leaves over it.@quario/layout. A box
declared on a table row used to be one rect across the columns; each covered
cell now draws its own. A row’s borderBottom still reads as one continuous
edge, a row’s borderLeft becomes an edge on every cell rather than one at
the row’s outer left, and a bordered row is taller by its border’s width,
since a border occupies height as a cell’s always has.fontkit, not @pdf-lib/fontkit.
Install fontkit instead; nothing else about options.fonts changes. The
old package’s bundle crashed with a bare ReferenceError on any OpenType
face needing a shaping state machine — which is every Devanagari, Bengali,
Tamil, Khmer or Myanmar webfont in practice, and some fifty further scripts
besides. Those faces now measure, embed and draw. Which scripts a face
supports remains the font’s and the parser’s to answer, not this package’s.pdf-lib is now @cantoo/pdf-lib. A maintained fork, and what
fontkit’s subsetting requires. Rendered documents are unchanged in what
they draw: every drawn string is identical and every filled area lands in
the same place, but the file’s bytes differ, so a host comparing digests
against stored output will see them move once.@quario/layout changelog
for the rule and for what it costs a report that relied on the short box.format: "date" now presents as a date. The
drawn text comes from the engine’s format() helper, which revives the two
read forms. See the quario changelog for the forms and the timezone rule.@quario/layout; this target paints its list.
Measurement, wrapping, pagination, page furniture, the marking’s geometry
and the page-size table now live in the layout package, which this target
depends on and runs first; pdf-lib writes the resulting display list. The
move itself changed no output, byte for byte, for the same input; the fix
below is a separate change and does move some. The page and fonts
options are the layout’s own, so the same object configures pdf(), the
viewer and the editor. The font mapping’s shape is now refused at the
factory call rather than at render.columns were absent. Only the node that declared columns closes its
own region now — a nested group’s header and footer are region content,
laid out in the strips.format stringifies at the edge from the instance locale. A PDF
without a host locale still uses en-US / UTC, so the bytes stay
reproducible.page.margin on the document is the inset when the host
omits it; both is a render error.spaceBefore / spaceAfter skip the cursor. Adjacent gaps add.
spaceBefore drops at a fresh body page or strip top; page-band items
keep it.0 beats the cell omakase (PADX 6 / PADY 2) on that
side. Incomplete sides draw nothing.size, empty or not. An
empty or whitespace-only value used to sit at the report’s base leading
(~14 pt at the 10 pt baseline). It now occupies 1.4 × the item’s size, the
same as a glyph line. A literal newline is a line break; the blank line
among "a\n\nb" is that size too. A table cell that is empty or only
horizontal whitespace still has no content height.?, not .notdef. Characters
outside WinAnsi already substituted ?; an embedded face’s cmap holes
drew a box instead, and text extraction hid it. Same rule for every
face: a character the face cannot draw becomes ?.SCHEMA.md Cell values.
Wrapping still applies within each line.style replaces this target’s own baseline, so its size scales
row heights and band gaps with the type rather than leaving them at a size
nothing is set in. It is the layer under the band-role defaults: a report
declaring size: 12 still renders its report header at 14, and an item’s own
style wins over both.uppercase draws capitals. With no text-transform to defer to, this
target capitalises the string before measuring it, so wrapping and column
widths are those of the text actually drawn. The mapping is Unicode default
case, never the host’s locale, so output stays byte-reproducible.background fills the whole split
behind them.Date draws as ISO 8601 UTC, the same on every machine. Cell
text and outline bookmark titles for Date values used String(date),
which bakes the host’s timezone and locale into the document — at odds with
this target’s byte-reproducibility guarantee. Both now render through the
engine’s shared display rule, so a Date group key titles its bookmark
with the same ISO text its cells draw.options.baseSize. A document’s type size is the document’s own, so it
is the report’s style.size — portable, travelling with the definition to
every target — rather than a host option one target honoured. Text with
nothing declared still renders at 10 points. Replace pdf({ baseSize: 11 })
with "style": { "size": 11 } on the report.@pdf-lib/fontkit.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
meta is now read once, at the factory call. It was read inside the render, so a host that
mutated its options object between renders got a different workbook from the same configured
target. The documented behaviour was always the factory call. If you were mutating an options
object to change the document properties, build a new target instead.
background is not read: a spreadsheet’s rich-text runs carry fonts
only, and filling the cell would colour text nobody asked to colour. A cell of
exactly one run stays a typed cell, so a single styled amount is still a
number.format shows the same decimals here as on the page,
where a mixed cell used to show the bare value.A date cell’s number format follows the form its report declares. The
four forms map to dd/mm/yy, dd mmm yyyy, dd mmmm yyyy and
dddd, dd mmmm yyyy. The shape is the document’s; the language a month or
weekday name is spelled in stays the reader’s, because that is what a
spreadsheet application supplies and pinning it would stop the file reading
naturally for whoever opens it.
A declared digit count builds the pattern. A number cell asking for
three digits writes #,##0.000, and a currency cell asking for none writes
"EUR"#,##0 — the same count the page presents, taken off the declaration
the engine resolved rather than computed here.
A cell’s own currency code picks the number format, ahead of the
instance’s default: "JPY"#,##0 where the row says JPY, since the code
decides the digits too. The cell stays a number either way. A code the engine
could not accept writes no number format at all, rather than labelling the
cell in the instance’s currency.
A date cell with no declared form now writes dd mmm yyyy, not
yyyy-mm-dd. A bare date means the medium form throughout, so the grid
agrees with the page. A report that wants the ISO shape has no form for it;
the nearest is { "kind": "date", "form": "short" }, which writes
dd/mm/yy. The cell is still a real typed date either way.
A cell’s number format now comes from the engine’s fraction-digit count
rather than from four patterns this target held of its own. The visible
change is currency: the pattern followed the currency’s own minor units,
so an amount in a currency with none — JPY — is "JPY"#,##0 where it was
"JPY"#,##0.00, and one with three is "BHD"#,##0.000. number
(#,##0.00), percent (0.00%) and date (yyyy-mm-dd) are unchanged in
a worksheet; what changed for those is that HTML and PDF now show the same
decimals the grid always did, instead of their own.
A currency code that is not a readable currency no longer produces a number
format at all. The cell keeps its value; before, the code was pasted straight
into a pattern while every other target fell back to the unformatted value.
Only the digit count is shared. The grouping separator and the symbol’s position still come from the application that opens the file, which is what lets one workbook read naturally wherever it is opened.
A format kind reaching this target from an = expression is now looked up
as an own key. Before, a kind resolving to the name of a built-in object
member threw out of the render (valueOf, hasOwnProperty) or wrote
[object Undefined] into the cell’s number format (toString). A literal
was never affected: it is checked against the four kinds.
An image whose size cannot be read now names the item that asked for it.
The failure said only that the size could not be read from the bytes, so a
report with two pictures gave no way to tell which one was bad. The message
now begins with the item’s source path, as every other render error does
and as it does from every other target.
valign writes the cell’s vertical alignment for table cells and split
slots. Undeclared writes nothing, so the spreadsheet application keeps its
own default.format: "date" now reaches the grid as a date.
The cell carries a real date value under the yyyy-mm-dd number format
instead of the author’s text, so it sorts and computes as a date. Only a
cell declaring the kind is affected; a string in a form the engine does not
read stays text. See the quario changelog for the forms.format maps to a number format; the cell stays typed. number is
#,##0.00, percent 0.00%, date yyyy-mm-dd, currency the
instance currency code as "USD"#,##0.00. A kind on the wrong type
contributes nothing.solid is exceljs
thin; dashed and dotted keep their names. A header-row, row, or
total-row box fans onto that row’s cells; a cell that named any of a
side’s three keys owns that side whole. Padding is unread: a worksheet
cell has no inset. Flow spacing is unread: a grid has no flow.page.margin and report-header height are unread. A grid has no
page top to pin from.total-row is one worksheet row, as
each data row is.wrapText is this
target’s mapping of a literal newline as a line break, so "one\ntwo" is
two lines in the grid. Single-line cells are unchanged.style
replaces this target’s baseline, so a document declaring a face and size gets
them in cells that declare nothing of their own. It is the layer under the
band-role defaults — a report declaring size: 12 still writes its report
header at 14 — and under each cell’s own style.uppercase is accepted and deliberately not read. A spreadsheet font
has no text-transform, and writing capitals into the cell instead would turn
presentation into data — the cell would stop round-tripping and would sort
differently. The cell keeps the text you wrote; every other declaration on
it still applies. Same posture as the column widths this target withdrew.width shares go unread,
on exactly the ground the table’s column widths do — a worksheet’s columns
are global to the sheet. The split’s own style is what its slots sit under,
layering the way a table row’s does, and a slot that renders nothing writes
an empty cell so the cells either side keep their columns.Dates join as ISO 8601 UTC. A Date inside a mixed
cell (literal text plus interpolation) joined through String(date), which
bakes the host’s timezone and locale into the worksheet. It now joins
through the engine’s shared display rule as toISOString() text. Typed
cells are untouched: a cell that is one bare Date interpolation still
writes a native date.family used
to inherit whatever the workbook writer defaulted to, which agreed with the
other targets only by coincidence. It now writes the same face that
family: "sans" resolves to, so the baseline is a rule rather than a
property of the library underneath.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
total-row is one record, as
each data row is.format is unread. Bare interpolations stay 1000 / ISO dates, as
every other style is unread here.page.margin and report-header height are unread. This target has
no page to inset or pin on.Dates join as ISO 8601 UTC. A Date inside a mixed
cell (literal text plus interpolation) joined through String(date), so
the field carried the host’s timezone and locale. It now joins through the
engine’s shared display rule as toISOString() text — the same form a
typed single-Date field has always used, so every Date in a CSV now
reads the same way.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
Updated dependencies
A Word target. @quario/docx renders the same report definition every other target reads into
the bytes of a .docx — real Word tables, inline pictures, and a grouping that becomes the
navigation pane.
It is a flow target rather than a painter: it states the geometry and the typography and lets
Word paginate, so the promise is the preview’s look rather than the preview’s page breaks. Page
bands become a section’s header and footer, and a bare {{ page.number }} or {{ page.total }}
becomes a live field the reader’s own application recomputes.
import { docx } from "@quario/docx";
import { quario } from "quario";
const bytes = await quario()
.report(schema)
.render(docx({ page: { size: "A4" }, meta: { title: "Sales 2026" } }), data);
Its one runtime dependency is fflate, confined to the module that writes the zip — pure
JavaScript, so the same schema, data and options produce byte-identical output on every supported
runtime. SCHEMA.md’s “The DOCX target” states the full contract.
The viewer offers a Word download. Pass a docx target in targets, and the bar carries a DOCX
button beside the others. The buttons keep the order you gave. The file carries the content type
application/vnd.openxmlformats-officedocument.wordprocessingml.document.
Earlier versions dropped a docx target. The viewer rendered no button for it, and it reported no
failure.
An image’s box is now as wide as the container it sits in. A
background or border* on an image item hugged the picture and now spans
the content width, or the slot’s share inside a split; the picture itself
does not move. See the @quario/layout changelog for the rule and for what
it costs a report that relied on a border hugging a logo.
Only the pages you can see are on the sheet. A page outside the reach used to be an empty canvas holding its place; now it has no element at all, and the sheet carries the document’s extent itself — its height and width are written from the layout, and it paints the page silhouettes. The stretch a reader scrolls through is exactly what it was, and a scale change no longer costs the browser a relayout of every page: that was around 10 microseconds a page, so a five-thousand-page report paid roughly 50 ms of it each time.
This changes the sheet’s markup, which matters if you style it. .qv-page is
now absolutely positioned inside .qv-sheet, and is present only for the pages
on screen and one viewport height either side. A rule that gave a page a
margin, or that relied on the pages being a flow of siblings, no longer
applies — the sheet places its own pages, and does it with inline styles a
stylesheet rule cannot override. --qv-sheet-shadow is unchanged.
One cost, stated: a page outside the reach has no role="img" node, so
assistive technology sees the few images of the reach renumbering as the
reader scrolls rather than a document of named pages.
fonts is validated as a host property. It was the one the viewer never
checked, so a malformed record reached the target inside the render and came
back as a render failure about a report that was never at fault. Its shape is
now checked with the other host properties and named on fonts rather than
on the target’s options.fonts. A face that will not parse, or a missing
parser, is still found while the report is measured and remains a render
failure there.
A rejected host property reports as host-option. ViewerErrorKind
gains a fourth member, so a host switching exhaustively over the kind needs
a case for it. A page, zoom, filename, colorScheme or fonts the
viewer rejected was reported as mount-render or update-render — a render
that was never attempted — and the panel said “Could not render the report”,
which reads as the report being at fault. It now says the viewer is
misconfigured and that the report was not the problem.
A malformed report or targets still reports as a render failure: those
are refused where the render begins rather than when the property is written,
so nothing yet tells them apart from a render that failed.
format now show a fixed two fraction digits,
the same as every other target: 1,000.00 where the preview showed 1,000,
21.00% where it showed 21%, and a currency’s own minor units in place of
a universal two. The digits come from the engine, so a page on screen and
the PDF of it agree. A formatted cell is also up to three characters wider,
so a line that just fitted can wrap and move a page break.renderComplete, so a host awaiting it saw an unhandled
rejection instead of an answer, and every page after it on screen went
unpainted too. Such an image is now drawn as nothing and the page is drawn
around it, marking and all; renderComplete resolves true, rendered
fires, and no error event is raised. It never rejects for this reason
again: a page that cannot be drawn is blank, not a failed render.fonts now needs fontkit, not @pdf-lib/fontkit.
Install fontkit instead. The viewer measures host TrueType faces through
@quario/layout, whose optional parser this is; the old package crashed on
any OpenType face needing a shaping state machine, so those faces now
preview where they used to throw.currentColor, so
--qv-icon and --qv-icon-active still recolour them.PDF, XLSX or CSV beside it, in place of the sheet drawing that
lettered the format inside itself. The buttons are wider; the accessible
name is unchanged.fonts. The font mapping a host passes to pdf({ fonts }), so the
preview measures and draws in the same TrueType faces the document embeds.renderComplete and rendered settle once the pages on screen are
painted. Both already answered for the newest render reaching the sheet;
what that means is now stated: the pages the reader can see carry their
pixels, and the pages further down the report do not hold the promise up.
A screenshot or a pixel-reading test taken at that moment sees what the
reader sees.@quario/layout changelog
for the rule and for what it costs a report that relied on the short box.@quario/layout and paints the same display list the pdf target
writes, one canvas per page, in the faces the document itself uses.
Zoom is a repaint, so text stays crisp at 200%; the menu’s first row reads
“Fit page” and fit sizes one page to the width. Breaking:
targets is exports only — no "html" target is required or read — and a
page change re-lays the report out rather than resizing a sheet. The
unlicensed marking is painted per page from the list; the --qv-mark
token is gone with the DOM stamps.spaceBefore / spaceAfter margins add rather than collapse, matching
the html reference stylesheet.min-height: 1lh and white-space: pre-line the html reference
stylesheet now carries on .q-item, and pre-line on table cells.The sheet is now page-shaped, so short reports reserve a full page.
page sized the sheet’s width and padding and ignored its height, so a
report shorter than a page was drawn on paper the shape of its own content —
an A4 half-page invoice came out twice as wide as it was tall while the PDF
export was a true A4 page. The sheet now takes at least one page of height as
well. It remains a minimum, so a longer report keeps growing on the one
continuous sheet, and nothing about a report of a page or more changes.
This is visible if you size a container to the viewer. An A4 sheet at
fit-width is roughly a thousand pixels tall against five hundred before, so a
box fitted to a short report roughly doubles. Set zoom to a percentage small
enough where the box has to stay small. The sheet still draws no page
boundaries.
The adopted sheet’s baseline face moved onto .q-report. It was on
.q-item and .q-table, where a direct rule would have beaten a report’s
own declared family. Same look; the fragment’s new root is what carries it.
The sheet no longer draws report text in the platform’s own face. It
hard-coded system-ui, which is SF Pro on macOS, Segoe UI on Windows and
Roboto on Android — so the same report showed a different typeface to every
visitor, and none of them matched what the PDF exported. Text declaring no
family now renders in sans-serif, the same declaration the PDF target
resolves to Helvetica. The viewer’s own chrome keeps system-ui, which is
what a toolbar should do.
<quario-viewer>. Assign a compiled report, the targets
to render with, and data; it shows the HTML report on a continuous sheet,
with zoom, fit, and export buttons for the targets you passed. It is not a
target and never walks the event stream.A keystroke no longer plans the document twice.
The editor commits what an author types when they pause, and that commit re-planned and re-rendered the very document the keystroke had already planned — a second compile and a second layout pass per keystroke, for a preview identical to the one already on screen. On a long report that is the larger part of what typing costs. The editor now re-plans when the document it would plan is a different one, so a pause commits without redrawing anything.
The change event is unaffected: it still arrives once per gesture, at the
pause, carrying the committed document with the problems and warnings found on
it. A burst of keystrokes is still one change, two commits in one turn are
still one change, and a document whose plan has not settled still delivers
nothing until it has.
Updated dependencies
Only the pages you can see are on the sheet. A page outside the reach used to be an empty canvas holding its place; now it has no element at all, and the sheet carries the document’s extent itself — its height and width are written from the layout, and it paints the page silhouettes. The stretch a reader scrolls through is exactly what it was, and an edit no longer builds and sizes a canvas for every page of the document before the first one is painted.
This changes the sheet’s markup, which matters if you style it. .qe-page is
now absolutely positioned inside .qe-sheet, and is present only for the pages
on screen and one viewport height either side. A rule that gave a page a
margin, or that relied on the pages being a flow of siblings, no longer
applies — the sheet places its own pages, and does it with inline styles a
stylesheet rule cannot override. --qe-sheet-shadow is unchanged.
One cost, stated: a page outside the reach has no role="img" node, so
assistive technology sees the few images of the reach renumbering as the
reader scrolls rather than a document of named pages.
An image’s box is now as wide as the container it sits in. A
background or border* on an image item hugged the picture and now spans
the content width, or the slot’s share inside a split; the picture itself
does not move. See the @quario/layout changelog for the rule and for what
it costs a report that relied on a border hugging a logo.
Turning a style flag off now turns it off. Unchecking bold, italic, underline, strikethrough or uppercase used to erase the setting from your report definition rather than set it to off. On most items you could not tell the difference — nothing else was turning it on, so erased and off looked the same. On a report header or a group header you could: those are bold to begin with, so unchecking bold changed your definition and left the sheet exactly as it was, and checking it again changed nothing back. Unchecking now writes off, which wins over the heading’s own weight, so the sheet follows the box.
Going back to “however this heading normally looks” is the new × beside
the checkbox. It is there only while the flag is set to something, so an item
you have deliberately un-bolded now reads differently from one that has never
mentioned bold at all — a distinction the panel could not show before. size
gained the same ×; it clears exactly what emptying the field already cleared.
A click during a fast scroll no longer lands on nothing. Which page the pointer was over was found by looking at the pages currently drawn, so in the moment after a quick scroll or a pane resize — before the sheet had caught up and drawn the page you had arrived at — a click or a hover over that page selected nothing. The page under the pointer is now worked out from the document’s own geometry, which does not wait for anything to be drawn, so the page you can see is the page you can click. On a long report this is also less work per pointer move: finding the page used to cost a step for every page in the document, and now costs a handful however long the report is.
A selection marks every rendering of the node the same. Selecting a table column highlights the column, and the heading cell and each body cell it draws are one selection — so the editor no longer marks the particular cell you happened to click more strongly than the rest. That mark suggested you had selected a single row, which is not something a report definition has: the rows are your data, and a column is one thing to edit however many times it is drawn. It also did not survive the next redraw, so it moved on its own while you typed.
A selection is now a tinted region with an edge down each side rather than an outline around every cell, so selecting a column of a long table reads as the column rather than as twenty boxes.
Every published README says where the documentation is. Each package now carries a Documentation section pointing at the reference, at the report schema that normatively specifies what a report may declare, and at the package’s own API. The paragraphs that used to end on an unstated contract — the event stream’s field semantics, the style vocabulary, page columns, the Content Security Policy a fragment with images needs, the formula mangling, and each target’s own contract — link the page that states it. Every link is an absolute URL, so it resolves from the npm package page as readily as from an installed copy.
Updated dependencies
Styled runs are authored from the rail. Select part of a cell’s value in
the Properties panel and press a style chip: the editor splits the value into
styled runs behind you, so bolding a word is styling rather than data entry.
A selection that partly overlaps an existing run splits that run — the part
you selected takes the declaration and the rest keeps what it had — and a
selection covering the whole value writes the cell’s own style instead, so a
document that never needed runs never grows one. A selection can never split
a {{ … }} tag: it opens outward to the whole tag first.
A chip whose runs disagree shows indeterminate, and pressing it sets all of them. Below the value field, a value written as runs lists them one per row, each editable and deletable; deleting the last leaves the empty value a new item starts with. The list is deliberately not reorderable — a run list is a sentence, and dragging a fragment past another produces text nobody typed.
The editor writes the smallest document that says what you meant: adjacent runs that end up styled alike are merged, and a single unstyled run collapses back to a plain template string. Both happen when a gesture ends, never while you are typing. Undo is per run: typing in one run and then in another is two steps, not one.
The rail lists the engine’s warnings beside its problems, and the change
event carries them. quario’s plan() now reports declarations that nothing
will read — a currency on a cell that is not formatted as one, a table
whose column widths leave a trailing share to nobody. The rail’s Problems
section is now Issues and lists both, an advisory in its own colour, each
entry still selecting the node it names. The change event’s detail gains
warnings alongside schema and problems, so a host that persists on
change sees what the author sees; the field is additive and no listener
breaks. A warning is never fatal: a document carrying only warnings compiles
and renders as it always did, so a save button driven by problems.length
should stay driven by it.
fonts is validated as a host property. It was the one the editor never
checked, so a malformed record reached the target inside the render and came
back as a render failure about a document that was never at fault. Its shape
is now checked with the other host properties and reported as host-option,
named on fonts rather than on the target’s options.fonts. A face that
will not parse, or a missing parser, is still found while the report is
measured and remains a render failure there.
The sheet carries pixels only in the reach, and renderComplete settles
on it. Every page used to take a backing store and be painted before a
render settled, so a large document asked for gigabytes: 13.4 GB at a
thousand pages on a HiDPI display. Only the pages on screen and one screen
either side carry pixels now — the store is flat at 27 MB whatever the page
count — and every page keeps its size, so nothing about scrolling changes.
renderComplete settles once the reach has finished trying to paint,
where it used to wait for every page. A host that awaited it and then read
pixels off a page far down the document will now find that page blank until
it is scrolled to. This is what the viewer has meant since it gained a reach.
A rejected host property reports as host-option, not as a render
failure. EditorErrorKind gains a third member, so a host switching
exhaustively over the kind needs a case for it. Previously a page,
instance, functions or colorScheme the editor rejected was reported as
mount-render or update-render — a render that was never attempted — and
the panel told the reader “The report could not be displayed”, which reads
as the document being at fault. It now says the editor is misconfigured and
that the report was not the problem.
A large document no longer throws while reaching the sheet. The editor
handed the sheet’s box elements over one call argument each, so a document
drawing more boxes than a call takes arguments — around 175,000, roughly a
thousand pages of a wide table — failed while building the sheet. Nothing
was drawn, nothing reached the error panel, and no error event fired.
renderComplete no longer rejects. It is documented never to, and a
throw while putting the list on the sheet made that false: that work runs in
the render task’s own callback, outside the guard that turns every other
failure into a value. Such a failure is now reported on the panel and
through the error event like any other, and renderComplete answers
false. A render that fails on its first attempt also no longer counts as
having landed, so it reports as mount-render rather than update-render.
Reordering a group keeps it selected. Pressing Move up or Move down in the Groups list used to clear the selection, closing the Properties panel on the group being moved — so moving a group several places meant re-selecting it between presses. The moved group now stays selected, at the index it landed on.
The Properties panel edits a format modifier. The kind picker now
carries a second control beside it: a digit spinner for number, percent
and currency, capped at the count the engine accepts, and a form picker for
date. A kind that takes no modifier draws none, and changing the kind drops
the old modifier rather than carrying one across that the new kind would
refuse.
Clearing the modifier writes the declaration back as the bare kind, so a document only holds the object form while it is saying something with it.
The style rail offers a control for the new currency declaration: a
three-character field that upper-cases as you type, cleared back to the
instance’s own code.
Merge and unmerge a total cell. A total row’s cell can now be made to span several columns from the editor, not only preserved from a document that already declared one. Select the cell and Merge absorbs the cell to its right, keeping the selected cell’s own content — repeat to reach further across. Unmerge splits a spanning cell back into single columns. Merge is offered only while there is a neighbour to absorb, and Unmerge only while the cell spans more than one column.
Numbers presented through format now show a fixed two fraction digits,
the same as every other target: the preview renders 1,000.00 where it
rendered 1,000, 21.00% where it rendered 21%, and a currency’s own
minor units in place of a universal two. The digits come from the engine, so
what the preview shows is what the rendered document shows. A formatted cell
is also up to three characters wider, so a line that just fitted can wrap.
A group’s verbs moved into the Properties panel. A group row in the
Groups list now carries its name and the pair that reorders it, and nothing
else. The four verbs that were marked H, F, ⇱ and × on that row are
in the panel instead, where they are written out: Add a header band /
Remove the header band, the same pair for the footer, Remove the group,
keeping its items, and Remove the group and its bands. Click a group’s
name to select it and the panel offers them; the row now also shows which
group is selected. Each band verb is one button whose label says which of the
two it currently is, so nothing presents adding and removing a band as a
toggle — removing one still discards its contents, and there is no hidden
band to restore.
BREAKING: a change event now needs a plan behind it. The event’s
problems describes the document the event carries, on every path. Two
consequences, both visible from a host:
problems.length check could be answering for
an older document.change event is sent at all. It used to send
one carrying the previous document’s problems. The edits are not lost: they
are held, and the next render that gets far enough to check the document
delivers the settled result as a single event. Listen for error to know
you are in that window; a save button driven by problems.length should be
treated as stale until the next change arrives.The totals block is selectable, from the breadcrumb above a total row or
a total cell. It carries the block’s own style and visible, and the “Add
total row” verb sits on it as it does on the table and on a row. There is no
hit box for it on the sheet: the block is exactly its rows, so no rectangle
drawn is the block rather than one of them.
valign.+, the alignment triple, a group’s move
pair and its remove-with-bands, the field clear and the error panel’s
dismiss are drawn from Lucide’s
set at one weight and one grid, in place of characters whose weight and
size shifted with whatever font the host resolved. They paint in
currentColor, so --qe-icon still recolours the bar and rail while the
gutter furniture keeps its own sheet-relative grey, and each control keeps
the accessible name it had.fx, the grip menu and the Columns verbs stay text. Where the
label is the information, a drawing would be a downgrade, so those controls
are unchanged.change event never carries an uncommitted document. Typing into a
field between a commit and the plan that followed it could deliver the
keystroke’s document, which had entered no history and matched no undo.
The host now hears nothing until the gesture ends, and the event’s schema
and problems describe the same committed document.uppercase. The rail carried its own copy of the
style vocabulary and had never learned the name; it now reads the engine’s
list, so a declaration the engine accepts is one the rail can write.fonts. The font mapping a host passes to pdf({ fonts }), so the
author designs in the faces the document will embed.@quario/layout changelog
for the rule and for what it costs a report that relied on the short box.@quario/layout and lays the planned report out on pages, painted per page;
every rectangle maps back to its schema node through the list’s hit boxes,
so selection, the gutter grip, drops and column drags resolve through the
layout rather than through rendered markup. Breaking: the target property
is gone — no html target is passed — and the target error kind with it.renderComplete waits for the paint. It settled as soon as the report
had been laid out, with the pages still painting, so a host that awaited it
and then read the sheet could read a blank one. It now settles once every
page has finished painting.format is a closed choice on the style rail. number / currency /
percent / date, on nodes that have a cell value. Not images, not a
total-row box, not the table itself. spaceBefore / spaceAfter are
counts from zero on items and images.cells. Removing the last row drops the
key rather than leaving total: [].spaceBefore / spaceAfter margins add rather than collapse, matching
the html reference stylesheet.min-height: 1lh and white-space: pre-line the html reference
stylesheet now carries on .q-item, and pre-line on table cells.<quario-editor>. A banded document designer whose design
surface is the rendered preview itself: assign a starter schema, a quario
instance, the html target and sample data; the report renders on the sheet,
clicking output selects the definition behind it, and items, table columns
and groups rearrange structurally — every edit recompiles and re-renders
through the real engine. A properties panel edits the selected node’s
literals, styles, templates and expressions; problems list with their exact
spans; undo and redo walk whole gestures. The edited document reaches the
host on every change, frozen, with its problem list. A failure reaches the
error panel and the error event once per distinct failure, however many
renders it survives, and a panel the author dismisses stays dismissed..q-report. It was on
.q-item and .q-table, where a direct rule would have beaten a report’s
own declared family. Same look; the fragment’s new root is what carries it.Every target factory now refuses an option it does not understand. An unknown key, a key with
a value of the wrong type, or an options that is not an object throws a TypeError at the
factory call, naming the option path — options.meta.title, options.page.size. @quario/docx
already behaved this way. @quario/pdf, @quario/html, @quario/xlsx and @quario/layout read
the keys they knew and ignored the rest, so a typo cost you the option you meant to set with no
signal at any point. csv() takes no options, and it refuses an argument rather than discarding
one.
The check itself is one thing, so the engine now exports it: hostOptions closes a key set and
hostMeta validates the { title, author, subject } contract the document targets share. A
fourth document property is one edit rather than three.
What this changes for you. One options object spread across several targets stops working if
any target does not know one of its keys — const o = { page, fonts, meta } passed to pdf(o),
html(o) and xlsx(o) is three different key sets. An object holding only what its consumers
share is unaffected, so { page, fonts } into both pdf() and layout() still works.
TypeScript does not warn about this: excess-property checking fires on an object literal and not
on a variable, so a bag held in a const compiles clean and throws when you call the factory.
Nullish is absence everywhere, at both levels, so { meta: config.meta ?? null } and
{ meta: { title: config.title } } over a config that carries neither are both fine.
html({ paths }) now takes true or false rather than anything truthy, so paths: "no" throws
instead of turning path stamping on. A fonts mapping given as an array is refused by
@quario/html and @quario/layout rather than read as families named 0, 1, 2.
Every host-option failure is now a TypeError rather than a plain Error. A catch that tests
instanceof Error is unaffected. One that compares the constructor is not.
@quario/docx is relaxed in three places, each of them now accepting what it refused: meta: null
and page: null are absence rather than errors, a property written as meta: { title: undefined }
is a property you did not write rather than one of the wrong type, and an inherited enumerable key
is no longer reported as an option you wrote.
@quario/pdf, @quario/xlsx and @quario/docx now copy meta at the factory call. Mutating the
object you passed no longer changes what a configured target writes.
An image’s box is now as wide as the container it sits in. A
background or border* on an image item hugged the picture and now spans
the content width — or the slot’s share inside a split — with fit sizing
the picture and align placing it inside, the way a text item’s box has
always been drawn. The picture itself does not move. Breaking: a report that
relied on a border hugging a logo now draws that border across the full
width, and no declaration asks for the old shape.
A page of the display list no longer carries width and height. Read
the list’s own width and height instead: they are the paper every page of
the report is drawn on, and every page’s pair was a copy of them. A report has
one page size for the whole render — that has always been so, and page size is
a target option rather than something a document declares — so a page is
identified and positioned, and asked nothing about its size. A page now
carries { number, total, ops, boxes }.
If you consume the list yourself, the fix is one substitution:
canvas.width = list.width * 2; // was list.pages[i].width * 2
canvas.height = list.height * 2; // was list.pages[i].height * 2
paint() is unchanged in shape — it still takes one page — and it now fills
the white over the whole canvas rather than over the page’s own rectangle.
Size the canvas to the page before you call it, as the sizing example above
does: the ops are in the page’s own coordinates, so a canvas short of the paper
clips them whatever the fill covers. That was always so; it is now written down
in the README and on paint() itself.
Layout.pages is typed as a non-empty list. A render always lays out at
least one page — the first page opens whether or not anything renders on it —
so pages[0] no longer needs a check for a list that cannot exist. Runtime
behaviour is unchanged; this only states in the type what was already true.
Paper — the page box a report is drawn on, { width, height } in points,
and what a display list’s own width and height are. Layout extends it, so
list.width is unchanged and the pair now has a name to refer to.
paint() filled its white over the page’s exact
rectangle and left the fraction past it transparent. On a canvas with white
behind it that is invisible; on one without, a hairline of whatever is behind
the canvas showed along two edges. The fill now covers the whole canvas.underline and strikethrough are drawn per run — over that run’s width, in
that run’s colour — where they used to stroke the whole line in the first
piece’s colour. A run’s background paints a highlight rectangle behind its
text; a cell’s own background is still painted once, at cell scope.checkFonts is public. It checks the shape of a fonts mapping without
loading a parser, and takes an optional name to prefix a failure with, the
way pageBox does — so a surface validating its own fonts property can
report the mistake against that property rather than against
options.fonts.Mark carries only what it declares. Every mark shipped an
extra titled boolean — bookkeeping for whether a header had claimed the
entry — which was never part of the Mark interface and which nothing
reads. It is the open group instance’s own state now, and no longer travels
on the object a consumer receives.currency code, ahead of the instance’s
default, so every target built on this package presents a per-cell
denomination.visible: false, with nothing
visible under it either, used to open the same half-line gap as any other
instance and could push the content after it onto a new page. A run of them
spaced whatever followed by a half-line each, so collapsing a level left the
rows above it at uneven distances. Such an instance now occupies nothing at
all and adds no PDF bookmark, which is what makes a group collapsible while
its rows stay in the aggregates. A group that declares break: "page" or
reset: "page" still starts its page either way. Every document with such a
group renders slightly shorter than it did.format now show a fixed two fraction digits,
matching every other target — 1,000.00 where 1,000 was rendered,
21.00% where 21% was, and a currency’s own minor units in place of a
universal two. Because this package presents text and then measures it,
a formatted cell is now up to three characters wider than it was: a line
that just fitted can wrap, which can move a page break in a PDF, the viewer,
or an editor preview. Nothing else about wrapping changed.source path, as every
other render error is.paint() before its first draw op, leaving the page blank: no white
fill, none of the other content, and no licence marking. The image is now
drawn as nothing and the page is drawn around it, so what a bad image costs
is the image.A spanning cell is drawn as one box across the columns it covers, starting where the first of them starts. It takes no part in allocating their widths, and a column no cell votes on opens at the cell-padding floor, so an empty table still shows its geometry. A spanning row too tall for the page it is on slices like any other, each slice following the geometry of the page or page-column strip it lands in.
valign places a cell’s lines, or a slot’s lines or picture, in the slack
its row or split leaves over its own content: middle halves it, bottom takes
it. The box does not move, and a picture is not scaled. A row too tall for
any page, sliced across pages, paints from the top.
A row’s box is drawn by the row’s cells. A box declared on a table row
used to be one rect across the summed column widths; it is now each covered
cell’s own, so a row’s borderBottom still reads as one continuous edge
while a row’s borderLeft becomes an edge on every cell rather than one at
the row’s outer left. A row’s border also occupies height now, as a cell’s
always has, so a bordered row is taller by its border’s width.
Measuring TrueType families now needs fontkit, not @pdf-lib/fontkit.
Install fontkit instead; nothing else about options.fonts changes. The
old package’s bundle crashed with a bare ReferenceError on any OpenType
face needing a shaping state machine — which is every Devanagari, Bengali,
Tamil, Khmer or Myanmar webfont in practice, and some fifty further scripts
besides. Those faces now measure where they used to throw. Which scripts a
face supports remains the font’s and the parser’s to answer, not this
package’s.
A group header no longer strands above a split. A split cannot be broken across a page, so a header that introduces one has to keep the whole of it company — but the keep-together test measured a split by its first two lines of text, and a split holds its text in its slots, not in itself. It therefore measured as nothing: the header was drawn at the foot of the page with room reserved for none of what followed, the split moved on to the next page, and the instance’s header repeated above it there. A record card whose total row is a split was drawn twice — once orphaned at a page bottom, once whole. The header now moves with the split, exactly as it already moved with an image.
Where a split is taller than the page has left, the header behaves as it always has before content no page can hold: it degrades to plain paginated flow and repeats nothing.
The paged display list. layout({ page, fonts }) is a render target
resolving every page of the report — ops in points from the top-left, one
hit box per schema node drawn, the page’s { number, total }, the outline
marks, and on an unlicensed render the marking. The algorithm is the PDF
target’s typesetter, with its home moved here so a preview can paint the
same pages the document has.
One measurer. Base-14 families measure against @pdf-lib/standard-fonts’
AFM metrics over WinAnsi, TrueType families against @pdf-lib/fontkit’s
shaping of the same bytes the PDF embeds, so a preview breaks its lines
where the document breaks them.
A box survives a page break. An item or table row no page can hold whole is drawn as slices, and a slice carries the box sides the break left it: the top belongs to the first slice, the bottom to the last, and left and right to every one. A declared border is never stroked through the middle of the content it encloses, and a long paragraph no longer loses the box a short one draws. Each slice reserves the bottom padding it may yet owe, so that edge stays above the bottom margin, and each carries a hit box, so an item that breaks is selectable on every page it reaches.
paint and hit. A Canvas 2D painter for one page, and the hit-test
that maps a point on a page back to the schema node drawn there.
Text is drawn in the face the document will use. A family you supply is registered from your own bytes and its text drawn as the browser shapes it, which is the shaping the PDF gets from the same file — so ligatures, joined scripts and accents look on screen the way they will on paper. Text in the built-in families is drawn character by character at the widths the layout measured, because the typeface a browser has for them only stands in for the one the PDF writes, and without the correction a line would drift as it ran.
Preview fidelity inside a run is not promised for a family you supply: the browser and the document agree on the glyphs and their widths, and may differ by a fraction on the kerning between them. Where a line breaks, how wide a column is and where a page ends are the layout’s, and identical.
pageBox. The page-size table and its validation, in one home for the
PDF target, the viewer and the editor.
PX_PER_POINT. How big a point is on screen — 96 dpi over PostScript’s
72 — so every surface that shows a page shows it at the same size, and a
zoom is a factor on top of it.
A split slot’s box now fills the split’s height. Before, a slot’s background and border were exactly as tall as that slot’s own content and padding asked for, so any slot shorter than the tallest one drew a box that stopped short of the row, leaving a gap between it and the row below. Now every slot’s box takes the split’s height — the way a table cell’s box already takes its row’s — so a bordered split row draws one straight edge across it whether or not a slot wraps. Only the box stretches: a slot’s text stays at the top of it, and an image’s picture is neither scaled nor moved. A slot that renders nothing holds the full-height box too, as a hidden cell does, and a slot’s hit box follows its painted one. A report that relied on the short box — a slot background used as a chip beside taller content — now draws it full height; give that content a narrower slot of its own.
A date string under format: "date" now presents as a date. The text
join presents the kind through the engine’s format() helper, which now
revives the two read forms. See the quario changelog for the forms.
A nested group’s footer keeps the page columns. Inside a page-column
region, the first footer of a group nested in it ended the region: the
strips collapsed and every band after it was laid out across the page, as
if columns were absent. Only the node that declared columns closes its
own region now — a nested group’s header and footer are region content,
laid out in the strips.