Every published release of the 10 packages the reference documents — 128 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.15.0 |
| @quario/html | 0.13.1 |
| @quario/pdf | 0.14.0 |
| @quario/xlsx | 0.12.1 |
| @quario/csv | 0.10.2 |
| @quario/docx | 0.5.1 |
| @quario/viewer | 0.15.0 |
| @quario/editor | 0.10.0 |
| @quario/layout | 0.11.0 |
| @quario/mcp | 0.2.1 |
take. Before, a group with take gave such an aggregate a value that changed with the order of the declarations. For example, { "a": "sum:[email protected]", "b": "sum:=g.a" } gave b the total of a, and the same two keys in the other order gave b the value 0. A group without take already caused this render error. To share a calculation between two aggregates, write it in each aggregate.pageBox(page, at) and paper(page, opening, at), the rules every target with a page follows. pageBox checks a host’s page option. paper says which size, margin and orientation are in force for a render, and the page box they come to. @quario/layout and @quario/docx now use them, and their behaviour does not change.plan() and the compiled report now carry readPaths. It maps each compiled source to the member paths it reads off its anchors and group handles, such as ["@", "qty"] or ["$", "input", "customer"]. A host can check a sample or a data feed against a definition before it renders. A path ends in null where the source reads further through a key the definition does not name.plan() now returns uses: a frozen map from each declaration a target’s capabilities descriptor answers for to the schema paths where the document writes it. Join it with any descriptor to see every withdrawal, unread band and approximation a document meets on that target. It needs no option, adds no problem, and is present while the document has problems.plan() and validate() refuse a lead on the first row of a grid after a height-declared header. Before, the plan reported no problem, and the render then failed on the same lead. A row that the document hides outright is not the first row, so the rule reads the next one.
The plan reports an item that is not an object after a height-declared header as a problem. Before, the plan failed with a TypeError.
A slot that renders nothing carries its path. Before, an image slot in a split or a grid had no path when its source was nullish or its visible was false.
quario(options) now refuses an option it does not understand, like every target factory. Before, the engine read the keys it knew and ignored the rest. A typo such as quario({ licence }) therefore marked every render as unlicensed, and nothing said why. Now an unknown key, a value of the wrong type, or an options that is not an object throws a TypeError at the call. The message names the option path, such as options.license.
locale, timeZone and currency are now checked at the call. Before, a value that Intl refused did not fail. Every formatted cell showed its raw value instead. Now quario({ locale: "en_US" }) and quario({ timeZone: "Europe/Amsterdm" }) throw a TypeError. currency must be three upper-case letters, the same rule that a cell’s own currency declaration follows. A cell’s own currency still fails soft.
The query budgets are now checked at the call. Before, the engine checked them only when a report compiled its first query. A report with no query never reported a bad budget. The error still comes from padvinder, in its own words.
plan(schema, functions, options) now refuses an unknown key in options. Before, { target: [...] } checked nothing and said nothing. A targets that is not an array also throws a TypeError.
A bad schemes allowlist now throws a TypeError, not an Error. The message starts with options.schemes:, not schemes:. A null allowlist still throws.
What this changes for you. An options object that carries keys for another consumer stops working when you pass it to quario(). Pass quario() only the six keys it takes: query, license, locale, currency, timeZone and schemes.
A group can now order and cut its instances. Declare instances beside sort, with an optional sort and an optional take inside it. They order and cut the group’s instances, as the group’s own sort and take order and cut the rows inside each instance. Each instances.sort key reads the group’s own handle once per instance, so instances: { sort: [{ by: "=region.subtotal", dir: "desc" }], take: 5 } keeps the five regions with the largest subtotals, largest first. @ and run are unbound there. A dropped instance never renders, and no running value steps over it. The totals around the group still count it, as they count a row that a group’s take drops. A group without instances renders every instance in first-seen order, so existing reports render as before.
An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
A band gains a grid item: splits that share one column geometry.
A statement is twenty lines of a label and an amount, on the same two widths. As splits, each line restated its widths:
{ "type": "split", "slots": [
{ "type": "text", "value": "Opening", "width": 70 },
{ "type": "text", "value": "{{ @.opening }}", "width": 30, "style": { "align": "right" } }
] }
A grid states the widths, and each column’s style, once:
{
"type": "grid",
"columns": [{ "width": 70 }, { "width": 30, "style": { "align": "right" } }],
"rows": [
{ "slots": [{ "type": "text", "value": "Opening" }, { "type": "text", "value": "{{ @.opening }}" }] },
{},
{ "slots": [{ "type": "text", "value": "Total" }, { "type": "text", "value": "{{ @.total }}" }], "style": { "bold": true } }
]
}
A row is a split without type, and it keeps every rule a split has. A column’s style is the layer under each of its slots, and a slot’s own declarations win. A row that names fewer slots than there are columns leaves the rest empty, so {} is a spacer line and a one-slot row is a label-only line.
Each row reaches a target as the split it is, so the event stream does not change and no target needed an update. No existing report changes.
The package now ships a JSON Schema of the report document.
The file is lib/schema.json, and the package exports it as quario/schema.json. Point $schema at the installed copy, and an editor checks the document and shows the documentation for each key:
{ "$schema": "./node_modules/quario/lib/schema.json", "data": "$.orders[*]" }
A report document accepts one new top-level key, $schema. Its value must be a string. The engine checks the type and reads nothing else, so a document keeps its $schema through an upgrade. Anywhere below the top level, $schema stays an unknown key.
The JSON Schema proves the shape of each value, and validate() still proves the document. A document can pass the JSON Schema and fail validate(): a rule across two values, a reserved name, or an expression that does not compile. The strict structured-output modes of OpenAI and Anthropic do not accept the file, because a report document leaves most of its keys out.
The TypeScript declarations are stricter in one place. A parameter’s value under params is now typed as any JSON value except a plain object. The engine always read a plain object there as a declaration, so a document that the new type refuses was never the document it looked like.
A license key has an end date and no start date. A key covers every version released on or before its to date. Before, a key also named a from date, and a version released before that date stayed marked. The engine now ignores a from in a key. When a key ends before the release date of the installed version, the warning names both dates.
A value the runtime has no text form for is now a located render error rather than [object Object] in the document.
A cell reaches its reader through String(value). A plain object, a Map and a Set have no text form of their own, so that falls back to a tag: [object Object], [object Map], [object Set]. A tag lays out, paginates and lands in a worksheet cell, and the document is wrong. A few values have no text form at all, and String() throws on them — an object with no prototype, a boxed symbol. The engine now refuses both kinds and names the cell:
detail[0].value [{{ @.address }}]: a value with no text form, which would print as [object Object]
The rule reads the value, not its source, so a registered function’s result and a data field answer to it alike. A value that says what it is still prints it: a class with its own toString, an array ([1, 2] prints 1,2), a Date. An absent read still prints nothing. A computed property is outside the rule, because a group’s by is an identity rather than text — by: "=@" makes each row its own instance — and a where and a visible are read for truth.
Every entry in the function registry must be a function. plan reports one that is not at functions.<name>, where before it raised an unhandled TypeError out of plan itself at a caller who had only asked a question.
A report that printed [object Object] stops rendering. It was printing a value no reader could use.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
params is now what the report declares it needs, and a render may supply one.
A bare value under params still is the parameter’s value, so a document of constants reads the way it always did:
{ "params": { "year": 2025, "branded": true } }
A plain object is a declaration instead, closed at default and type:
{
"params": {
"year": { "type": "number", "default": 2025 },
"region": { "type": "string" }
}
}
type names a JSON kind — string, number, boolean, array, object — because a parameter is a JSON value. There is no date and no enum.
default absent makes the parameter required. The engine rejects a render that supplies no value for it, before any expression runs. That is the main thing declaring buys: a report that states what it needs hears about it when a render does not bring it.
A host supplies values as the last argument to render and to stream:
await report.render(html(), data, { region: "EU" });
Only a declared key may be supplied, a supplied value answers to the declared type, and values stay JSON and deep-frozen — now per render rather than once at compile.
One kind of document changes meaning. The engine reads an object-valued parameter as a declaration, so a document that wrote one for its value wraps it:
{ "params": { "config": { "default": { "retries": 3 } } } }
An empty object is the sharpest case. { "config": {} } now declares a parameter with no default, so every render of that document is refused until one supplies a value, or until the document wraps it.
Nothing else moves. A bare string, number, boolean or array is untouched.
A declaration may also carry a label, the text a person reads for the parameter. A labelled parameter is one a person supplies, so a host panel shows only those. The engine never renders a label.
A null default beside a type makes a typed parameter optional, and a render may then supply null:
{ "params": { "branch": { "type": "string", "default": null, "label": "Branch" } } }
A host reads what a document declares from report.params and from plan(...).params: one frozen { name, required, type?, default?, label? } per parameter, in declaration order. A plan carries them whether or not the document compiled, so an editor can render a panel beside the problems.
A registered function may be asynchronous. The engine resolves every such call before the banded walk, in one wave, and the walk itself stays synchronous.
Write the call as the whole value of a cell in a detail row, and take its arguments from the row:
{ "data": "$.orders[*]", "detail": [{ "type": "text", "value": "{{ customerName(@.customerId) }}" }] }
The engine calls the function once for every row it renders, starts every call together, and waits for all of them before the first event reaches a target. A row that where or take removed is never fetched. Deduplication and any limit on how many calls run at once belong to the function itself: a report over ten thousand rows starts ten thousand calls.
Three rules hold. The call is the whole value of a cell, so {{ lookup(@.id).name }} and {{ "#" + lookup(@.id) }} are errors that name the function and the cell. The arguments come from the row, so a cell that reads no row cannot hold one. And render() resolves, while stream() reports the call it cannot wait for.
A function that rejects fails the render with an error that names the function and the cell, and keeps the class the function threw.
The declarations now refuse what the engine refuses. Eight style declarations type-checked at nodes the engine rejects them on, so a report that compiled could still fail to render: valign on a stacked item, flow spacing on a table cell or a total cell, and format, currency, href and flow spacing on a row box. Each site now states the narrowing the engine has always applied, through four new exported types — StackedStyleDeclarations, CellStyleDeclarations, RowStyleDeclarations, and the SlotStyleDeclarations that was already there.
No document that the engine accepts has stopped type-checking. The narrowing removes only names the engine was already refusing.
SCHEMA.md now states two keys it always admitted. The document-shape block never listed required, and the group-definition block never listed collapsed — the latter while three target sections linked to that section for it, and while the section said a group is collapsible without a field for it. Both are stated now, collapsed with the paragraph it never had.
The declarations now carry required, and a group’s label and collapsed. ReportSchema states required, and Group states both label and collapsed. The engine has admitted all three and SCHEMA.md specifies them, so a TypeScript author marking a declaration required, naming an instance for navigation, or opening a group shut was told it was an error and had to cast.
required is keyed by a new exported type, RequirableName, which spells the same names REQUIRABLE carries. A marking no target could answer for is therefore an error where an author writes it, rather than a plan problem after the fact — { "negative": true } names a modifier inside format rather than a declaration a target answers for, and the compiler now says so. collapsed reads boolean, because the engine accepts false for it as plainly as true.
href and schemes. StyleDeclarations states href, RunStyleDeclarations picks it up as the twelfth inline name, and QuarioOptions states the schemes allowlist beside the three names that present format. The engine has admitted both since 0.11.0 and SCHEMA.md specifies them; the published types said otherwise, so a TypeScript consumer writing a link, or the allowlist that admits one, was told it was an error. The format declaration’s own comment said a kind carries one modifier, which stopped being true when a number kind took three. test/types.check.ts exercises the allowlist and both spellings of a link, since a hand-written surface drifts exactly where nothing reads it.isBoxName(name) is exported. It answers whether a style name is part of the box — a padding or a border part — which is the partition the spec defines a table row’s box by. A target that strips the box off a style, or a tool that lists the box names, imports it beside STYLE_NAMES rather than matching the two prefixes itself.negative and zero. format gains two modifiers on number, currency and percent: negative is "minus" or "parens", and zero is "number" or "dash". Under parens a negative reads as its magnitude in parentheses, and under dash a zero reads as -. Both defaults are what every kind presented before, so no existing report moves. They compose with digits, with the percent kind’s scaling and with a currency’s code. A value outside either pair is a located definition error, and date takes neither. The resolved declaration a cell carries now states all three of a number kind’s modifiers, so no consumer defaults one for itself.collapsed. One optional literal, true or false, asking that the instance open shut: its header and footer are shown and the content between them is not. Aggregates are untouched — the rows stay in the partition and still count, so a footer totals what nobody is shown. The group-start event carries it, and the new collapse(stream) helper is what a target with nowhere to hide a row reads the stream through: it drops a collapsed instance’s content, nested instances whole, and keeps its brackets and its own bands. An expression is a located definition error.label. One optional template beside name and by, resolved once per instance in group scope — the instance’s own handle and $ are bound, @ is unbound as in any group band — and carried on group-start as the resolved text. It names the instance for navigation rather than for the page: a target with somewhere to put a navigation name spends it there, and the others withdraw it, so no page changes. A value that is not a template string is a located definition error, and a hollow instance stays unlisted whether it declares one or not.href. The twelfth name in the styled run’s inline subset, and the one declaration this engine judges rather than passes on: a URL names a location, and javascript: is a string-to-code path that escaping at the markup edge cannot answer. A URL is admitted before it crosses, against a host allowlist — quario({ schemes }), defaulting to https: and mailto: — so a target receives a URL already admitted or none at all. A refused value is omitted, so the text renders and the link does not; a URL stating no scheme is refused with the rest, because a scheme list cannot say “no scheme” without admitting //host/path. An href beginning # names a group’s label and needs no allowlist. A schemes option the engine cannot read throws at construction rather than falling back to the default, because an allowlist quietly replaced is a host believing it restricted something it had not. A table row refuses href, as it refuses every declaration that presents a value.REPORT_STYLE_NAMES. The two names a report default may declare, family and size, in the vocabulary’s own order — the same export RUN_STYLE_NAMES already is, so a tool that offers them reads the engine rather than keeping a copy.required, and plan checks it against the targets you name. "required": { "uppercase": true } at the report root says which declarations this document cannot do without; plan(schema, funcs, { targets }) takes the capabilities descriptors of the targets it is meant for, and every required declaration one of them withdraws or leaves unread becomes a problem of its own at required.<name>, which nulls the report as any other problem does. An approximated declaration is not one. Every withdrawal was silent by design before this, so this is how an author says that one of them matters. Additive throughout: no target sees the marking, no render changes because of it, and passing no descriptors checks nothing. A name outside the set the support matrix answers for is a definition error, because a marking nothing can check would go quiet exactly when it was needed. REQUIRABLE exports that set.The declarations now state every format modifier the engine settles. FormatDeclaration and ResolvedFormat carry negative and zero beside digits and form, and FORMAT_VOCABULARY offers the two readings and their defaults — negatives, zeroes, defaultNegative, defaultZero — with modifiers naming every modifier a kind takes rather than one of them. The engine has always read and resolved those declarations; the types said otherwise, so a TypeScript consumer writing one was told it was an error, and a tool reading the vocabulary to offer them found only the count.
An error quario replaces keeps the one it replaced, as cause. A failure inside one of your own functions reaches you named where it happened — detail[0].value [{{ total() }}]: ... — which is a copy, rebuilt from the class and a prefixed message. The stack that says which line of your function threw used to go with the original. It now rides along: read error.cause for the throw as it happened, and the message for where quario was when it happened.
The viewer’s and the editor’s own property checks do the same. A page or fonts value either element refuses throws a TypeError naming the property, and that error now carries the layout’s own account of the value behind it.
An all-authored split whose slot widths sum under 100 warns, as a table’s columns already did. A split’s slots carry the same percentage shares under the same arithmetic, so the plan now reports the trailing width they leave unused, on the split’s slots path. The report still renders; the warning is advisory, like every other.
A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
Two new reducers: first and last. They report the value an expression takes on the first and the last row in scope. Like every reducer, each one arrives on three surfaces at once: a declared aggregate on a report or a group, a runner under run, and a function you can call inline over a row’s own array.
// the customer a region's rows open and close on
{ "aggregates": { "opened": "first:[email protected]", "closed": "last:[email protected]" } }
{{ first(@.lines, l => l.sku) }} the first line's article number
They are the first reducers that read the order of the rows they fold, so the spec now says what each scope hands them. Every scope reports the ends of the rows it folded, which is not always what the page ends on. A report folds after where, its sort and its take, and before it splits the rows into groups. A group that declares a sort and no take folds its partition before it orders that partition, so its handle holds the partition’s ends. Declare first under a group’s run when a detail row needs the row that group opens on as rendered.
An empty scope gives null for both, next to avg, min and max. Neither reducer skips a null: a scope whose first row holds null reports that null.
Reports that already render are unaffected. A document that spelled first:=... before was a definition error, and it now compiles.
A hollow instance’s group-end carries hollow: true. An instance whose bands emitted nothing between its two brackets is hollow, and a hollow instance is not there: no space, no gap, no page turned by opening one, no outline entry. The stream now says which instances those are, on the closing bracket, so a target reads one field rather than counting the events between the brackets for itself. An instance holding nothing but hollow instances is hollow too, and one holding a child that drew is not. The field is absent, never false, on an instance that drew.
@quario/layout, and through it @quario/pdf, the viewer and the editor, read the field and keep no tracker of their own. What they render is unchanged.
splits(events) folds a split bracket into one event. A split reaches the event stream as split-start, one item or image per slot, then split-end, and every consumer that wanted a split whole kept that bracket for itself. splits does it once: read walk(splits(events), handlers) and register a split handler, which receives the opening event’s role, path, slots and style plus the slot events in order under items. Every other event passes through unchanged, lazily.
The stream itself is unchanged. A consumer that registers no split-start handler still receives the slots as ordinary events and stacks them, as before. The SplitStartEvent declaration now names its path, which the stream always carried.
Every built-in target reads its splits this way now, so a custom target can too.
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.An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
align now reaches its cells when you link style.css. Before, the stylesheet set text-align: left on every th and td. That rule stopped the align on a header row, a detail row or a total block, which the target writes on the <tr>. A total block with align: "right" therefore rendered its cells left-aligned. The stylesheet now sets the left default on the row, so a row’s align reaches the cells. A cell’s own align still wins. A table without any align still reads left, th included.Breaking: a report definition can no longer write markup. This target now escapes the literal text of a definition as well as every interpolated value.
Before, literal template text reached the fragment as live HTML:
{ "type": "text", "value": "<b>Total</b> {{ $.total }}" }
That cell rendered a bold Total. Now it renders the text <b>Total</b>, followed by the escaped value. A header cell follows the same rule. So does a <script> or an <img onerror> in a report header, which is the point: a definition is untrusted for markup, as its data is. An agent can write one, and nothing it writes can execute.
An authored entity is text too. & in a definition shows as &, and a plain & is escaped once, so it shows as &.
To keep the formatting, declare it. Use style on the item, or split the cell into styled runs:
{
"type": "text",
"value": [
{ "value": "Total", "style": { "bold": true } },
{ "value": " {{ $.total }}" }
]
}
There is no raw form and no switch to restore the old behaviour. {{{ }}} is still a definition error. Every other target already read literal text as text, so only this one changes.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
Breaking: a split’s style now ranks over its band’s role default. The PDF, the viewer, the editor and the HTML fragment now agree with the DOCX and XLSX targets and with the spec.
Before, a split in a report header ignored the type it declared:
{
"type": "split",
"style": { "size": 8, "bold": false },
"slots": [
{ "type": "text", "value": "{{ $.input.seller }}" },
{ "type": "text", "value": "{{ $.input.customer }}" }
]
}
Its slots rendered bold at the report header’s 1.4× size. A group-header split with "bold": false rendered bold too. Now the slots render at 8pt and not bold, as the same declaration on each slot does. A split with no style renders as before.
In the reference stylesheet, each band-role rule now selects .q-<role>:not(.q-slot > *): a band’s text item or split, and never an item in a split’s slot. A slot’s item inherits its type from the split. Before, a slot’s item in a styled report-header split took 1.4× the split’s size. The group-header, group-footer and report-footer margins now also sit on a split rather than on each of its slots. A host rule on .q-item.q-report-header that loads after this sheet still wins.
capabilities descriptor. The target exports what it makes of each declaration the spec’s support matrix names — resolved, approximated, withdrawn or unread — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (valign, break, page.margin, and column.width / slot.width where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.negative and zero reach the markup edge. The target stringifies through the engine’s format(), so a cell declaring either presents there too: a negative in parentheses, a zero as a dash.collapsed renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.href is wrapped in an <a> inside its own span, so the style the run declared paints the link, and the URL is escaped at the one markup edge like every other interpolated value. A real anchor rather than a styled span, because keyboard reachability comes with it. The engine admitted the URL before it crossed, so this target decides nothing about it..q-item.q-report-header rule is font-size: 1.4em rather than 14pt. A document declaring "size": 20 now renders its report header at 28pt where it rendered at 14pt, smaller than its own body. This is visible to every host page: an unstyled report’s header is now 1.4× the host’s own body type rather than a fixed 14pt, so a host whose body is not 10pt sees the headline move. A host rule on the class still wins, as before.href now links its text. href sits on a cell as well as in a run’s inline subset, but this target read it only off a run that declared a style of its own, so { style: { href } } on the cell rendered the text with no anchor at all. It now asks the style the run wears rather than the one it declares, which is the cell’s wherever the run is silent. A run declaring its own href still replaces the cell’s rather than nesting inside it.A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
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.Breaking: every instance of a table now shares one set of column widths. Before, a table under a group sized its columns from the content of each instance alone. The columns therefore moved left and right from one group to the next. Now the layout sizes each column from the widest content that column holds in any instance of the render. The columns line up down the document. A table that renders once keeps the widths its own content asks for. The text that a report draws does not change, but the PDF of a report with a table under a group changes its geometry. The viewer and the editor show the same change, because they draw the same layout.
The layout reads the event stream once, as before. It measures the votes of a repeated table before it places the first instance, and it uses those measurements again when it places the rows. On the benchmark report, a flat report and a grouped report take the same time as before. A grouped report with no repeated cell text takes about 12% longer.
A link to # and a name goes only to a group instance that declares that name as its label. Before, the PDF and the viewer also matched an instance with no label whose outline title read the same, which is its first header’s text. A link to a name that no instance declares now goes nowhere, as the spec says.
In @quario/layout, a Mark carries label where its instance declares one. The new destinations(marks) and destination(href, byLabel) say where a link goes. The PDF target and the viewer use them.
Updated dependencies
An outline step now lands on its group instance. Before, the layout gave each outline entry a y that the target measured up from the page bottom. The Mark type said that y reads down from the top, as an op’s y does. The viewer read it that way, so an outline row or an internal link scrolled near the foot of the page and not to the instance. The layout now gives mark.y from the top of the page. The viewer step lands on the header of the instance. The PDF target turns the value over for each bookmark and link destination, so the PDF bytes do not change. A host that reads marks from the layout target and turned y over itself must stop doing that.
An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
Text sits in the middle of its line. A line is 1.4 times its largest font size. Before, all of that extra space went below the text, so a capital touched whatever stood above its line, an image for example. Now half of the extra space goes above the text and half below, as a browser lays out a line.
Every line keeps its height, so every page breaks where it did before. Each line of text is drawn 0.2 times its font size lower than before. The PDF, the viewer and the editor all change, because they draw the same layout. The HTML target already split the space this way.
The PDF target now uses @cantoo/pdf-lib 2.11. The pages draw the same text in the same places. The bytes differ, because the library compresses streams with a different deflate implementation. A corrupt PNG still gives a located render error that keeps the class of the decoder error. That class and its message now come from the new decoder, so a corrupt PNG can give an Error where it gave a RangeError before.
Updated dependencies
capabilities descriptor. The target exports what it makes of each declaration the spec’s support matrix names — resolved, approximated, withdrawn or unread — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (valign, break, page.margin, and column.width / slot.width where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.collapsed renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.label titles its bookmark. The document writes the layout’s outline marks, so an instance that declares a label is bookmarked under it rather than under its first header line./Link annotation with no border: a URI action for an external URL, and a /Dest to the outline entry’s own destination where the href names a group’s label. A link to a label no instance carried is dropped rather than invented."size": 20 now renders its report header at 28 where it rendered at 14, smaller than its own body.A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
A font parser that will not load no longer reports itself as a missing package.
Passing fonts needs the optional peer fontkit, and every failure to load it used to arrive as options.fonts: install fontkit to embed TrueType families — advice a host had often already taken, with the real error discarded. A stale bundler cache, a 504 from a dev server or a throw from inside the parser all read as an install that was already correct.
The target no longer loads the parser itself. It awaits the one @quario/layout loads to measure the same faces, so both halves of a render report a failure in one wording:
options.fonts: the font parser did not load. Install fontkit to use TrueType families
The error that actually threw is attached as cause. Before, the two halves loaded the parser separately and concurrently, so one failure produced two errors and a host saw whichever won the race.
A face fontkit reads but pdf-lib refuses keeps its message unchanged — still options.fonts.<family>.<variant>: followed by what pdf-lib said — and now carries pdf-lib’s own error as cause as well.
Updated dependencies
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.xlsx({ page }) takes the same page option as the other targets with a page. page.margin takes one number for all four sides, or an object with top, right, bottom and left. Before, the target refused the object. The target now refuses a paper name that it does not know, such as "A5". Before, it accepted the name and printed on the reader’s default paper. The target also refuses a margin that leaves the page no room. A document and a host that both declare a page key now cause a TypeError, as in the other targets.An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
A capabilities descriptor. The target exports what it makes of each declaration the spec’s support matrix names — resolved, approximated, withdrawn or unread — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (valign, break, page.margin, and column.width / slot.width where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.
negative and zero write a sectioned number format. A cell that declares either gets positive;negative;zero — #,##0.00;(#,##0.00);"-" — so the grid presents what the page presents and the cell stays a number, where a host formatting recipe producing the same text would leave text where an amount belongs. A declaration that names neither writes one section, so no existing sheet moves.
A collapsed group keeps its rows and hides them. This is the one target that can: the content rows are written hidden at their outline level and the group is shut, so a reader opens it again. The control goes on the row after the group, which is the instance’s footer where it declares one. Needs werkmap@^0.7.0.
A group’s label names the worksheet a boundary opens. Where an instance declares one, the tab takes it in place of the key’s display text and goes through the same sanitising, so an author naming a worksheet chooses the words rather than the bytes. A label that survives none of it falls back to Report, exactly as an unspellable key does.
The report header scales with the report default. A report-header cell renders at 1.4× the report default’s size rather than a fixed 14. A document declaring no size keeps its worksheet byte for byte, because 1.4× the baseline 10 is the same 14. A document declaring "size": 20 now renders its report header at 28 where it rendered at 14, smaller than its own body.
A cell that points at one place is a hyperlink. A cell carries one hyperlink and a rich-text run carries none, so a cell links where all of its text points somewhere — the cell’s own href, a lone run’s, or several runs that agree — and a run pointing inside a longer cell stays inert, the way a run-level background does. A URL leaves the workbook as an external relationship. An href naming a group’s label points at the worksheet that label opened, resolved once every worksheet is open, so a report may point forward through itself. A label that opened no worksheet drops its link and leaves the text as it is.
The support matrix recorded this target as withdrawing href, and it now approximates it. The writer moves to werkmap 0.8, which is where the cell-level hyperlink comes from.
Page bands print. page.header and page.footer become the worksheet’s print header and footer, on every printed page, with nothing added to the grid. The target reads each band at page 1 and at page 2 of 2, as the DOCX target does, so a bare {{ page.number }} or {{ page.total }} becomes the reader’s own live page number or page count, anything computed from a page value freezes at that reading, and a band that differs on the first page gives the first page its own header or footer — the cover-page idioms resolve exactly. Text items only, each item one line, in the section its align names, with bold and size as its look. A split, an image and every other style in a page band are withdrawn. A band that stores past the format’s 255 characters is a render error naming the band. Before this, the target left page bands unread.
The group tree reaches the worksheet as row outline levels. An instance’s header and footer items sit at its parent’s outline level and everything between them one level deeper, so a reader collapses and expands an instance from the worksheet’s margin and keeps its header and footer in view, as a subtotal keeps its label. Nothing is collapsed or hidden, and an ungrouped worksheet’s bytes do not move. A grouped worksheet also states the format’s default row height, 15 points, because the format requires one beside the levels. Before this, the worksheet kept the group tree as row order alone.
A worksheet column now fits what it holds. Until now every column came out at the application’s own default width, so a long product name and a short amount got the same column and the reader widened both by hand. The target sizes each column from the content that column actually carries.
A cell votes with a count of the characters it shows, and the widest vote wins. A table’s own cells vote: the header cells, the data cells and the total cells. A band item, a split slot, an image anchor and the unlicensed marking row do not, because none of them is a table column — a band item is a line of prose that lands in column A, and a data column stretched to fit prose is not what the report asked for. A cell spanning several columns stays silent too, which is the rule a span already followed everywhere else.
Text votes its longest line, so a cell that wraps gets a column wide enough for the widest line rather than the whole string. A number and a date hold no text of their own, because the reader’s application writes the value out — so each votes the characters quario presents it as, the same text the same declaration produces on a rendered page. That is what keeps an amounts column or a dates column from arriving too narrow and showing ####. A boolean votes nothing, because the reader’s application spells TRUE and FALSE in the reader’s own language.
Because a number and a date are measured as quario presents them, a vote follows the locale and time zone the render was configured with. The same report and the same data still produce the same file.
The winning vote gains one character of slack, and a column stops at 60 characters, so one very long cell cannot produce a column nobody can scroll past. A column nothing voted on is left alone and keeps the application default.
An authored column width percentage still resolves to nothing here, and that has not changed. A percentage is a share of a table’s width, and a worksheet’s columns belong to the whole sheet rather than to one table. The measuring never reads it. All of this is best-effort, like every other presentation number this target picks that no author wrote, and it may change without a breaking change.
xlsx({ filter: true }) puts an autofilter over a worksheet’s one table.
The range covers the table’s header row and the data rows under it. It adds the reader’s dropdown controls and removes no row, so every row you wrote is still in the document.
A total row sits outside the range. A total is not data, and a filter that hides one surprises a reader. A filter that sorts it into the middle of the data is worse.
A worksheet that holds more than one table filters nothing. A grouped report renders one table per group instance, so this is the ordinary shape rather than an edge case. An autofilter needs one header row over one contiguous body, and such a worksheet has neither. Covering the first table alone would leave you working a control that does not reach the rest.
A break is how you get one. It opens a worksheet for each instance, and each of those holds one table. A report without a table filters nothing, and neither does a table with no data row.
The key is off unless you ask for it. Switching it on for every report would put a dropdown row into every workbook this target writes, and that changes what an existing report produces.
xlsx({ page: { orientation, fit } }) turns the paper and scales the sheet.
orientation takes portrait or landscape. page.fit takes true or false, and it scales a worksheet to one page wide and as many pages tall as it needs. It is a different key from the fit an image item carries, which scales a picture. Both join size and margin in the same print setup, and both reach every worksheet the report opens.
Both are host options rather than report declarations. Turning the page changes how this package prints a document, and it does not change what the document is. One report definition therefore still renders the same document in every target.
The factory refuses a word it does not know, at the call that wrote it. orientation: "sideways" throws a TypeError naming options.page.orientation.
xlsx({ page: { size, margin } }) writes print setup.
margin is all four page margins in points, and size is A4 or letter. A document that declares its own page.margin wins, and the host option applies where it does not — declaring both is a render error, the same rule the PDF target already follows.
Print setup reaches every worksheet, so a report that opens many prints the same way throughout. A report that declares neither key carries no print setup at all, and a reader applies its own defaults rather than this package’s.
A [width, height] page size is not written: a worksheet prints on named stock, because the format carries a paper code rather than a width and a height. Picking the nearest named stock would be a size you did not ask for.
The header row that stays in view on screen now repeats on paper.
A spreadsheet separates a view from a page. This target already froze the first table’s header row, and it left a printed page without one. It now writes the same rows as a print title, scoped to that one worksheet. Every printed page therefore carries the header the frozen view holds.
One intent, two spellings in the format. Both take the same rows, and a report that freezes nothing repeats nothing.
The workbook is now written by werkmap, quario’s own spreadsheet writer, in place of exceljs.
Output bytes differ from 0.8.x for every report. Compare or cache a rendered workbook by its content, and that fingerprint changes once.
What a reader opens is the same document: the same values and types, fonts, fills, alignments, merged ranges, frozen header row and floating images. Two details differ inside the file. A solid fill now carries the background slot Excel itself writes beside it, and the workbook no longer carries a theme part it never referred to.
Determinism improves. Before, only the document model was stable, because the old writer stamped every archive entry with the packing time. Now the same schema, data and options produce byte-identical output on one runtime — a runtime being the engine and the zlib build inside it.
The package’s dependencies drop from nine, and 22 MB installed, to one that has none of its own.
A group’s break now opens a worksheet, so one report can produce many.
A report that declares break produces a different workbook than it did. Before, every band landed on one worksheet named Report; now a boundary starts a new one. A report that declares no break is unchanged, down to the worksheet name.
The worksheet layout follows the page layout the same declaration produces in PDF, boundary for boundary — including break: "around" giving one worksheet per instance, and a report footer landing on a worksheet of its own when a trailing boundary falls before it. A group instance that draws nothing still opens its worksheet, empty, because it declared the boundary.
A worksheet a boundary opened is named from the group instance’s key: its text, with : \ / ? * [ ] replaced by spaces, runs of spaces collapsed, trimmed, cut to 31 characters. A key that survives none of that is named Report, and a repeated name takes (2). Keys come from your data, so a name a spreadsheet cannot spell is sanitised rather than refused — a render does not start failing because someone renamed a region.
Nothing caps how many worksheets a report produces. A group over a high-cardinality key opens one per instance, and the whole workbook is built in memory.
The unlicensed-render marking now repeats on every worksheet. Each worksheet also freezes its own first table header.
reset is still ignored, and now says why: it restarts page numbering, and this target writes no page numbers.
A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
Updated dependencies
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.A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
capabilities descriptor. The target exports what it makes of each declaration the spec’s support matrix names — resolved, approximated, withdrawn or unread — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (valign, break, page.margin, and column.width / slot.width where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.collapsed is withdrawn. Every record is written, and an order is all a record has, so dropping the rows would change the data rather than how it opens.A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
Updated dependencies
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.# and a name that no group declares as its label keeps its text and loses its link. Before, the document held a hyperlink to a bookmark that did not exist, and Word showed a link that went nowhere. A link to a declared label stays a link, also when it comes before the group in the document or sits in a page band.pageBox(page, at) and paper(page, opening, at), the rules every target with a page follows. pageBox checks a host’s page option. paper says which size, margin and orientation are in force for a render, and the page box they come to. @quario/layout and @quario/docx now use them, and their behaviour does not change.An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
A capabilities descriptor. The target exports what it makes of each declaration the spec’s support matrix names — resolved, approximated, withdrawn or unread — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (valign, break, page.margin, and column.width / slot.width where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.
A run that carries an href is a Word hyperlink. A URL becomes a relationship of the part the run lands in, marked as leaving the package, and one relationship serves every run pointing at the same place. An href beginning # becomes a w:anchor, and the first instance of the group carrying that label writes the bookmark it names, beside the heading that item already takes. A run whose text is empty writes no link and relates to nothing. The support matrix recorded this target as withdrawing href, and it now resolves it.
Two things a page band’s own part had wrong are fixed with it. A header or footer now carries its own relationships part, so a picture or a link inside a band resolves in the part that refers to it rather than only in document.xml’s. And [Content_Types].xml no longer writes an Override for a relationship that names no WordprocessingML part. That entry typed a picture’s bytes as a document part, which said nothing true about them.
negative and zero reach the runs. The target presents through the engine’s format(), so a cell declaring either reads the same in Word as on the page.
A collapsed group does not draw its content. An instance whose group declares collapsed renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.
The report header scales with the report default. A report-header run is written at 1.4× the report default’s size rather than a fixed 14, rounded to Word’s half-points. A document declaring no size keeps its bytes, because 1.4× the base 10 is the same 14. A document declaring "size": 20 now renders its report header at 28 where it rendered at 14, smaller than its own body.
A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
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.
<quario-viewer> gives the host page more control.
pageNumber reads the current page and goes to a page when you assign it. A number after the last page goes to the last page. A number that you assign before a render lands waits for that render. pageCount reads the page count.pagechange event fires when the page number or the page count changes. The first render that lands fires it too.link event fires before a URL link opens. event.preventDefault() stops the new tab. A host page uses this to drill through to another report.bar: false removes the bar. The page keys, the progress strip and the outline panel stay.slot="bar" show in the bar, before the view and export controls.Fix: an outline write after the first render now turns the outline on or off. Before, only the value at the first render counted.
<quario-viewer> now has page controls and a single-page view. The left of the bar shows first page, previous page, a page-number field, next page and last page. The field shows the current page and the page count, for example “3 of 120”. When the element is narrower than 360px, the bar removes the first-page and last-page controls. The Home and End keys do the same work.
The view menu replaces the zoom menu. It shows Fit width, Fit page, the percentages, Continuous and Single page. Fit page makes the whole page fit in the viewer. A new view property takes "continuous" (the default) or "single". In the single view, the page controls and the keys change the page, and the mouse wheel never does.
Breaking: the zoom property takes "width" where it took "fit". Before, zoom: "fit" fitted one page to the width of the viewer. Now zoom: "width" does that, and zoom: "page" fits the whole page. The viewer rejects "fit" and names the zoom property on the error panel. The name of the trigger changes from “Zoom, 62%” to “View, 62%”.
sheet() in @quario/landing takes an optional sixth argument, origin. It tells the sheet where the scroll extent starts, so a surface that shows one page at a time paints the pages near that page.
<quario-viewer> takes a params property. The viewer passes it to the render of the preview and to the render of each export. Before, the viewer passed no parameter values. A report that declares a required parameter failed to show and failed to export. An optional parameter always took its default. A params write renders the report again, as a data write does.
The strings record of <quario-viewer> takes a new percent key. Each percentage row of the view menu shows the result of this function. The function gets the percentage, for example (percent) => percent + " %" for French. The English is “50%”.
<quario-viewer> prints. Set print to true to add a Print row to the export menu. Print renders the report through your pdf target and opens the print dialog of the browser. The viewer opens no tab.
targets holds a pdf target and the browser can show a PDF in a frame.frame-src blob:. Without that grant, the viewer shows “Could not print” and fires an export error.strings takes two new keys: print and printFailed.A screen reader now reads a table on the viewer’s pages as a table. Its headings are the column headers. Each page holds its own part of a table. That part starts with the headings again. Each page of the layout list carries tables for this. A table there lists its rows, and each row lists its cells. A cell names the range of the page’s lines that it drew. An empty cell is an empty range, so each cell after it keeps its column.
<quario-viewer> searches the whole report.
query property reads and writes the search. The searchchange event fires with { query, match, matches }. A page without the bar keeps the search through both.strings keys: search, findField, matchCount, previousMatch and nextMatch. The --qv-match custom property colors the highlight.<quario-viewer> takes a new strings property. It replaces the English words of the chrome, key by key. A key that you leave out stays English. Fixed text takes a string, for example firstPage: "Erste Seite". A message that carries a value takes a function of that value, for example pageCount: (count) => "von " + count. An unknown key, or a string where the viewer expects a function, or the reverse, is a host mistake that the error panel names as strings.<key>. If a function throws, the viewer draws the English text and reports the error one time for each record, as a host-option error. The marking and the message of an error do not change. Set the lang attribute on the element to the language of your strings.
Breaking: the fifth argument of sheet() in @quario/landing is now name where it was named. Before, true named each page canvas “Page n of N” and false hid it from assistive technology. Now the argument takes a function from the page index and the page count to the name, pageName by default, or false as before. Replace true with pageName, or leave the argument out.
<quario-viewer> zooms by touch, by trackpad and by key.
A fast scroll through a long report stays smooth. Before, the viewer and the editor painted every page that came near the view during a fast scroll. On a slow device, that work dropped the frame rate a lot, and the page in view was blank on most frames anyway. Now a frame that moves more than half the height of the view paints no page. The page outline shows where each page is. The pages paint on the first frame that moves less, and on the frame after the scroll stops.
The editor’s properties panel puts the styles in groups: Text, Alignment, Fill, Format, Link, Spacing, Padding and Border. A group shows only the styles that the selected node declares. A group that declares nothing is one line. The + of a group shows its other styles, and the − hides them again. The group stays open when you select another node.
Each group shows its styles in the shape of their values. A value is beside its name. The four sides of padding are a grid with a mark for each side. The border is a table of sides by width, line and colour. Bold, italic, underline, strikethrough and uppercase are toggles with icons. A held toggle is on, an outlined toggle is off, and a dimmed toggle declares nothing. A style that holds an expression is a row of its own. A colour shows its value as text beside the swatch, and you can type a colour there. A colour that declares nothing shows a crossed square, not black. The fx toggle shows when the pointer or the focus is on its field. The rail is 296px wide.
The chrome of the editor and the viewer has a new finish. The bar and the panels are white on a grey backdrop in the light scheme. The text is darker, and the focus colour is a deeper blue. The scrollbars and the selected text use the palette. The stage of the editor does not show a focus ring when you click the sheet.
The editor has two new tokens, --qe-icon-active and --qe-active, which match the viewer’s --qv-icon-active and --qv-active. The other tokens keep their names, and a value that a host sets still wins.
<quario-editor> takes a new strings property. It replaces the English words of the chrome, key by key. A key that you leave out stays English. Fixed text takes a string, for example undo: "Rückgängig". Five messages carry a value and take a function of it: hidden, asText, showGroup, hideGroup and pageName. An unknown key, or a string where the editor expects a function, or the reverse, is a host mistake that the error panel names as strings.<key>. If a function throws, the editor draws the English text and reports the error one time for each record, as a host-option error. The marking, the message of an error, the messages of the engine and the names of the schema do not change. Set the lang attribute on the element to the language of your strings.
@quario/landing exports messages() and speaker(), the convention that the viewer and the editor share for their strings records.
The viewer now checks its strings record through @quario/landing. An unknown key now reads “expected a message the chrome draws”, where it read “expected a message the viewer draws”.
A link to # and a name goes only to a group instance that declares that name as its label. Before, the PDF and the viewer also matched an instance with no label whose outline title read the same, which is its first header’s text. A link to a name that no instance declares now goes nowhere, as the spec says.
In @quario/layout, a Mark carries label where its instance declares one. The new destinations(marks) and destination(href, byLabel) say where a link goes. The PDF target and the viewer use them.
The pages stay sharp when the device pixel ratio changes. Before, the viewer and the editor painted the pages again only when a box changed size. A window that moved to a monitor with a different ratio, or a page zoom when the host fixes the size of the element, kept the old backing store, and the text was soft. Now the viewer and the editor paint the visible pages again at the new ratio.
sheet() in @quario/landing has a new watch() method. It paints the reach again when the ratio changes, and it returns a function that stops the watch.
The README shows how your page draws a parameter panel from report.params. The recipe draws an input for each labelled string, number or boolean parameter and assigns the values to params. It also quotes the message that the error panel shows when a required parameter has no value.
The README shows two more recipes that use a parameter. In the first, a control on your page chooses the sort order of the rows. In the second, a checkbox hides the detail rows and keeps the group headers, the group footers and their subtotals. The viewer passes params to each export, so each downloaded file shows the same rows as the screen.
Updated dependencies
An outline step now lands on its group instance. Before, the layout gave each outline entry a y that the target measured up from the page bottom. The Mark type said that y reads down from the top, as an op’s y does. The viewer read it that way, so an outline row or an internal link scrolled near the foot of the page and not to the instance. The layout now gives mark.y from the top of the page. The viewer step lands on the header of the instance. The PDF target turns the value over for each bookmark and link destination, so the PDF bytes do not change. A host that reads marks from the layout target and turned y over itself must stop doing that.
An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
The viewer’s pages can be read and copied. The viewer now puts the report’s text over each page it paints. A screen reader reads it, a magnifier follows it, and a reader selects it and copies it. Nothing needs to be declared or turned on.
A screen reader meets each page as a group with the name “Page n of N”. It reads the page header first, then the body, then the page footer. The first line of a group header is a heading at the depth of that group. An image with an alt has that name, and an image without one is skipped as decoration. The unlicensed marking is read on every page and is never part of a copy.
A copy takes the text as the report wrote it. A paragraph that wraps on the page copies as one line, and so does a row of a table, with a space between its cells. The text covers the pages near the viewport, as the paint does. Every page that the selected text spans stays while the reader keeps the text selected. Select all therefore takes the pages near the viewport, and the PDF export is the whole report.
What changes for a host:
aria-label. The .qv-link class stays. The .qv-links layer is now .qv-mirror.# link to a label that no group carries is now plain text that a reader can select. Before, it had nothing over it.--qv-focus also tints selected text.@quario/layout gives each page a lines array. Each entry names the text ops of one laid-out line and the schema node the line belongs to. The first line of a group header also carries the depth of its outline entry. A line that continues as the same line of text carries a join: what a wrap dropped, or a space to the next cell of a table row or slot of a split. An image op carries its rendered alt. fontOf(font, size) returns the CSS font the painter draws a run in, and TextOp.href is now declared.
@quario/landing’s sheet() takes a fifth argument, named. Pass false and each page canvas is hidden from assistive technology instead of being an image named “Page n of N”.
Text sits in the middle of its line. A line is 1.4 times its largest font size. Before, all of that extra space went below the text, so a capital touched whatever stood above its line, an image for example. Now half of the extra space goes above the text and half below, as a browser lays out a line.
Every line keeps its height, so every page breaks where it did before. Each line of text is drawn 0.2 times its font size lower than before. The PDF, the viewer and the editor all change, because they draw the same layout. The HTML target already split the space this way.
Updated dependencies
A collapsed group does not draw its content. The sheet paints the layout’s pages, so an instance whose group declares collapsed reads as its header and footer alone.
A group’s label titles its outline row. The panel lists the layout’s marks, so an instance that declares a label reads under it rather than under its first header line.
A linked run is clickable, and reachable with a keyboard. Every run carrying an href now gets a region of its own over the words the page paints, so a reader clicks it or reaches it with Tab and opens it with Enter. A URL is an anchor opening in a tab of its own. An href naming a group’s label is a button that steps the sheet to that instance, the way an outline row does, and a label no instance carries is no region at all. The regions follow the reach as the pages do, and move with the zoom.
The size budget rises from 8.5 kB to 9.5 kB to hold them.
An optional outline panel. Set outline to true and the bar carries one more control, which opens a panel beside the sheet. The panel lists the report’s group instances, nested by depth, in document order, and a row scrolls the sheet to where that instance begins. The list is the group tree the PDF target writes as bookmarks, so a report declares nothing for it. An instance whose bands all resolve hidden is not in it. The module is off by default. The reader opens and closes the panel.
An error quario replaces keeps the one it replaced, as cause. A failure inside one of your own functions reaches you named where it happened — detail[0].value [{{ total() }}]: ... — which is a copy, rebuilt from the class and a prefixed message. The stack that says which line of your function threw used to go with the original. It now rides along: read error.cause for the throw as it happened, and the message for where quario was when it happened.
The viewer’s and the editor’s own property checks do the same. A page or fonts value either element refuses throws a TypeError naming the property, and that error now carries the layout’s own account of the value behind it.
renderComplete answers for the newest render at the moment it settles. A render that starts behind a pending await is the one the promise then waits for, so a host awaiting it while properties keep changing waits for the last change’s render. Before, the promise could settle for the render in flight when it was asked, or miss one that started behind it.
The viewer reports a target it cannot export, rather than dropping it. A targets entry whose name is not pdf, xlsx, csv, docx or html used to disappear: no button, no error. It is now a host mistake like any other malformed property. The error panel names the entry by its index and the name it carried, and the last good render stays up. An html target is still accepted and not read, so one list can serve both render and the viewer.
Updated dependencies
A viewer you insert again renders once more, unless its last render landed on the sheet. It used to do that only for a render that stopped mid-flight, which left three ways for the newest render to go missing with nothing owed.
A render that failed while the element was out of the document is one of them. The error panel reaches nobody there, so the verdict reached nobody, and the element came back showing the previous pages with no panel and no re-render owed. A property the host rejected while the element was out of the document behaved the same way. So did a property written while it was out of the document.
All three now leave the render owed, and inserting the element collects it. What you see on reconnect is the panel the failure earns, or the pages the newest properties produce.
A failure the panel already showed is owed again too. Removing and inserting such an element re-runs the render and reports the same failure once. It is not announced twice.
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.The editor’s properties panel puts the styles in groups: Text, Alignment, Fill, Format, Link, Spacing, Padding and Border. A group shows only the styles that the selected node declares. A group that declares nothing is one line. The + of a group shows its other styles, and the − hides them again. The group stays open when you select another node.
Each group shows its styles in the shape of their values. A value is beside its name. The four sides of padding are a grid with a mark for each side. The border is a table of sides by width, line and colour. Bold, italic, underline, strikethrough and uppercase are toggles with icons. A held toggle is on, an outlined toggle is off, and a dimmed toggle declares nothing. A style that holds an expression is a row of its own. A colour shows its value as text beside the swatch, and you can type a colour there. A colour that declares nothing shows a crossed square, not black. The fx toggle shows when the pointer or the focus is on its field. The rail is 296px wide.
The chrome of the editor and the viewer has a new finish. The bar and the panels are white on a grey backdrop in the light scheme. The text is darker, and the focus colour is a deeper blue. The scrollbars and the selected text use the palette. The stage of the editor does not show a focus ring when you click the sheet.
The editor has two new tokens, --qe-icon-active and --qe-active, which match the viewer’s --qv-icon-active and --qv-active. The other tokens keep their names, and a value that a host sets still wins.
<quario-editor> takes a new strings property. It replaces the English words of the chrome, key by key. A key that you leave out stays English. Fixed text takes a string, for example undo: "Rückgängig". Five messages carry a value and take a function of it: hidden, asText, showGroup, hideGroup and pageName. An unknown key, or a string where the editor expects a function, or the reverse, is a host mistake that the error panel names as strings.<key>. If a function throws, the editor draws the English text and reports the error one time for each record, as a host-option error. The marking, the message of an error, the messages of the engine and the names of the schema do not change. Set the lang attribute on the element to the language of your strings.
@quario/landing exports messages() and speaker(), the convention that the viewer and the editor share for their strings records.
The viewer now checks its strings record through @quario/landing. An unknown key now reads “expected a message the chrome draws”, where it read “expected a message the viewer draws”.
A fast scroll through a long report stays smooth. Before, the viewer and the editor painted every page that came near the view during a fast scroll. On a slow device, that work dropped the frame rate a lot, and the page in view was blank on most frames anyway. Now a frame that moves more than half the height of the view paints no page. The page outline shows where each page is. The pages paint on the first frame that moves less, and on the frame after the scroll stops.
Selecting a node before any page is on the sheet no longer throws. Before, a document that had problems from its first plan showed no pages, and a click on its issue threw an uncaught error. Now the click selects the node and the overlay paints nothing until a page is there.
The pages stay sharp when the device pixel ratio changes. Before, the viewer and the editor painted the pages again only when a box changed size. A window that moved to a monitor with a different ratio, or a page zoom when the host fixes the size of the element, kept the old backing store, and the text was soft. Now the viewer and the editor paint the visible pages again at the new ratio.
sheet() in @quario/landing has a new watch() method. It paints the reach again when the ratio changes, and it returns a function that stops the watch.
Updated dependencies
Text sits in the middle of its line. A line is 1.4 times its largest font size. Before, all of that extra space went below the text, so a capital touched whatever stood above its line, an image for example. Now half of the extra space goes above the text and half below, as a browser lays out a line.
Every line keeps its height, so every page breaks where it did before. Each line of text is drawn 0.2 times its font size lower than before. The PDF, the viewer and the editor all change, because they draw the same layout. The HTML target already split the space this way.
Updated dependencies
style key the report default feature exists for had nowhere to be edited. The rail now opens with a Document panel, above Properties and present whether or not anything is selected, offering exactly family and size with the same controls, fx toggle and inline errors every other field has. It writes the document root, so the preview repaints at the new base as you type.page.header, page.footer and empty render nothing on the preview — a page band is drawn by a paginating target alone, and empty renders only over a dataset with no rows — so until now the editor preserved them and offered no way in. The Document section lists all three; selecting one opens a panel holding its items, with the verbs that add, remove and reorder them, and the breadcrumb leads back up from an item. They are drop targets like any other band, under the same anchor rule: a page band binds page, and empty binds nothing row-scoped. The sheet still shows nothing for them, and the reveal toggle is unchanged — a band the preview never shows has nothing to have left out.href. A run or a cell that may carry a link gets a field of its own, whose placeholder is where an author meets the #label form for an internal target. A table row withholds it, as it withholds every declaration that presents a value rather than describing a band of cells.size. Its presence now means the same thing in every row: this name is declared, and clearing it hands the layer beneath back. A select keeps its blank option and gains no ×.The box names are read from the engine. Both packages used to decide for themselves which style names belong to the box. They now ask the engine’s isBoxName, so the three agree by construction. Nothing renders differently.
The format control offers the modifiers again. Choosing a kind now draws a control for each modifier that kind takes — the digit count, how a negative reads and how a zero reads on a number, currency or percent, and the form on a date — and each one writes the whole declaration back, so setting one no longer drops its neighbours. The panel had offered none since format grew from one modifier to three.
An error quario replaces keeps the one it replaced, as cause. A failure inside one of your own functions reaches you named where it happened — detail[0].value [{{ total() }}]: ... — which is a copy, rebuilt from the class and a prefixed message. The stack that says which line of your function threw used to go with the original. It now rides along: read error.cause for the throw as it happened, and the message for where quario was when it happened.
The viewer’s and the editor’s own property checks do the same. A page or fonts value either element refuses throws a TypeError naming the property, and that error now carries the layout’s own account of the value behind it.
renderComplete answers for the newest render at the moment it settles. A render that starts behind a pending await is the one the promise then waits for, so a host awaiting it during continuous editing waits for the last keystroke’s render. Before, the promise could settle for the render in flight when it was asked, or miss one that started behind it.
Updated dependencies
A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
An editor you insert again renders once more, unless its last render landed on the sheet. Two cases changed.
A property written while the element was out of the document used to plan and render where nothing could receive the list, and left nothing owed. The editor came back showing the previous document. It now owes that render and produces it on reconnect.
A document that compiled to no list — one with problems — left nothing owed for the same reason, so a standing debt survived it. It now keeps the debt until a render reaches the sheet.
A render that failed while the element was out of the document already came back re-rendering, and still does. What changed there is why: the debt is now what every run carries until it lands, rather than a case the disconnect check happened to catch.
The design surface paints only what you can see. An edit no longer slows down with the document. The editor laid one element over every rectangle the render drew, on every page. It rebuilt all of them behind each keystroke — 157,798 elements on a 987-page report. It now draws an element only where it shows something. Three things qualify: the selected node, the node under the pointer, and an item you drag. It draws them only over the pages on screen. A keystroke on that report drops from 946 ms to 503 ms. On a 99-page report it drops from 97 ms to 77 ms. Nothing about editing changes. There is simply far less to rebuild.
New: boxes(path) on <quario-editor>. It answers every rectangle the render drew for a node, in document order, as client rectangles. Use it to anchor your own UI to a node: a badge, a comment marker, a step in a tour. It answers for every page, on screen or not. It answers an empty array for a node that draws nothing, for a path the document does not have, and before a render lands. Await renderComplete first.
Four class names change. If you style the design surface directly, rename as follows.
.qe-hit is now .qe-selection..qe-boxes — the layer over the sheet — is now .qe-overlay..qe-overlay — the layer that holds grips, carets and placeholders — is now .qe-furniture..qe-box is gone. There is no element per rectangle any more, so there is nothing to select. Use boxes(path) to find where a node is drawn.This release does not change the --qe-* theming tokens, .qe-peek or .qe-lifted.
@quario/landing gains pagesIn(top, bottom, pad) on the paged sheet. It answers which pages a band of the sheet covers. The two surface elements run this package for you, so you do not call it directly.
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.A link to # and a name goes only to a group instance that declares that name as its label. Before, the PDF and the viewer also matched an instance with no label whose outline title read the same, which is its first header’s text. A link to a name that no instance declares now goes nowhere, as the spec says.
In @quario/layout, a Mark carries label where its instance declares one. The new destinations(marks) and destination(href, byLabel) say where a link goes. The PDF target and the viewer use them.
Breaking: every instance of a table now shares one set of column widths. Before, a table under a group sized its columns from the content of each instance alone. The columns therefore moved left and right from one group to the next. Now the layout sizes each column from the widest content that column holds in any instance of the render. The columns line up down the document. A table that renders once keeps the widths its own content asks for. The text that a report draws does not change, but the PDF of a report with a table under a group changes its geometry. The viewer and the editor show the same change, because they draw the same layout.
The layout reads the event stream once, as before. It measures the votes of a repeated table before it places the first instance, and it uses those measurements again when it places the rows. On the benchmark report, a flat report and a grouped report take the same time as before. A grouped report with no repeated cell text takes about 12% longer.
A screen reader now reads a table on the viewer’s pages as a table. Its headings are the column headers. Each page holds its own part of a table. That part starts with the headings again. Each page of the layout list carries tables for this. A table there lists its rows, and each row lists its cells. A cell names the range of the page’s lines that it drew. An empty cell is an empty range, so each cell after it keeps its column.
TextLine.join, so a copy out of the viewer ran the two lines together. Now that line joins the first line of the next page with the text the wrap dropped, as at any other wrap.pageBox(page, at) and paper(page, opening, at), the rules every target with a page follows. pageBox checks a host’s page option. paper says which size, margin and orientation are in force for a render, and the page box they come to. @quario/layout and @quario/docx now use them, and their behaviour does not change.An outline step now lands on its group instance. Before, the layout gave each outline entry a y that the target measured up from the page bottom. The Mark type said that y reads down from the top, as an op’s y does. The viewer read it that way, so an outline row or an internal link scrolled near the foot of the page and not to the instance. The layout now gives mark.y from the top of the page. The viewer step lands on the header of the instance. The PDF target turns the value over for each bookmark and link destination, so the PDF bytes do not change. A host that reads marks from the layout target and turned y over itself must stop doing that.
An image item now accepts SVG bytes. Before, source had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is <svg as an SVG, and the image event carries format: "svg". The engine reads the size from the root’s width and height when both are absolute lengths, and else from its viewBox. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says “PNG, JPEG or SVG”.
The HTML target puts an SVG in an <img> under data:image/svg+xml, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG <text> draws in the standard Helvetica. The viewer paints an SVG through a data: URI, so a page that shows one needs the Content Security Policy grant img-src data:. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
Text sits in the middle of its line. A line is 1.4 times its largest font size. Before, all of that extra space went below the text, so a capital touched whatever stood above its line, an image for example. Now half of the extra space goes above the text and half below, as a browser lays out a line.
Every line keeps its height, so every page breaks where it did before. Each line of text is drawn 0.2 times its font size lower than before. The PDF, the viewer and the editor all change, because they draw the same layout. The HTML target already split the space this way.
A document may now name its paper and turn it, and its margin may name each side.
{
"page": {
"size": "letter",
"orientation": "landscape",
"margin": { "top": 36, "right": 29, "bottom": 36, "left": 29 }
}
}
page.size takes "A4" or "letter". A [width, height] pair stays a target option. page.orientation takes "portrait" or "landscape", and it turns whichever paper is in force. page.margin still takes one number for all four sides.
The rule page.margin already followed now covers all three keys. The document’s key wins where it declares one, a target option applies where it does not, and both declaring the same key is a render error. orientation in a document beside a target’s size is not both.
Each target writes the paper: the PDF and the layout draw on it, HTML writes an @page size and per-side margin, and DOCX writes pgSz with w:orient and pgMar per side. XLSX writes the paper code, the orientation and each print margin. CSV withdraws all three.
The capability descriptors gain page.size and page.orientation, and both are names a document may mark required.
A target’s margin error now reads expected a non-negative number, or { top, right, bottom, left }, that leaves the page room.
Breaking: a split’s style now ranks over its band’s role default. The PDF, the viewer, the editor and the HTML fragment now agree with the DOCX and XLSX targets and with the spec.
Before, a split in a report header ignored the type it declared:
{
"type": "split",
"style": { "size": 8, "bold": false },
"slots": [
{ "type": "text", "value": "{{ $.input.seller }}" },
{ "type": "text", "value": "{{ $.input.customer }}" }
]
}
Its slots rendered bold at the report header’s 1.4× size. A group-header split with "bold": false rendered bold too. Now the slots render at 8pt and not bold, as the same declaration on each slot does. A split with no style renders as before.
In the reference stylesheet, each band-role rule now selects .q-<role>:not(.q-slot > *): a band’s text item or split, and never an item in a split’s slot. A slot’s item inherits its type from the split. Before, a slot’s item in a styled report-header split took 1.4× the split’s size. The group-header, group-footer and report-footer margins now also sit on a split rather than on each of its slots. A host rule on .q-item.q-report-header that loads after this sheet still wins.
The viewer’s pages can be read and copied. The viewer now puts the report’s text over each page it paints. A screen reader reads it, a magnifier follows it, and a reader selects it and copies it. Nothing needs to be declared or turned on.
A screen reader meets each page as a group with the name “Page n of N”. It reads the page header first, then the body, then the page footer. The first line of a group header is a heading at the depth of that group. An image with an alt has that name, and an image without one is skipped as decoration. The unlicensed marking is read on every page and is never part of a copy.
A copy takes the text as the report wrote it. A paragraph that wraps on the page copies as one line, and so does a row of a table, with a space between its cells. The text covers the pages near the viewport, as the paint does. Every page that the selected text spans stays while the reader keeps the text selected. Select all therefore takes the pages near the viewport, and the PDF export is the whole report.
What changes for a host:
aria-label. The .qv-link class stays. The .qv-links layer is now .qv-mirror.# link to a label that no group carries is now plain text that a reader can select. Before, it had nothing over it.--qv-focus also tints selected text.@quario/layout gives each page a lines array. Each entry names the text ops of one laid-out line and the schema node the line belongs to. The first line of a group header also carries the depth of its outline entry. A line that continues as the same line of text carries a join: what a wrap dropped, or a space to the next cell of a table row or slot of a split. An image op carries its rendered alt. fontOf(font, size) returns the CSS font the painter draws a run in, and TextOp.href is now declared.
@quario/landing’s sheet() takes a fifth argument, named. Pass false and each page canvas is hidden from assistive technology instead of being an image named “Page n of N”.
negative and zero reach the page. The measurer and the painter present through the engine’s format(), so a cell declaring either is measured and drawn as it reads: a negative in parentheses, a zero as a dash.collapsed renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.label titles its outline entry. Where an instance declares one, the entry takes it and no group-header item takes it back — the label exists to say what the visible text does not. Without one the entry falls back exactly as before: the first header item’s text, else name: key. A hollow instance is still no entry at all.href beside its x, y, w and h; a run wrapping across two lines is two ops, which is two rectangles."size": 20 now renders its report header at 28 where it rendered at 14, smaller than its own body. Before, the headline kept the target’s fixed size whatever the document declared. The PDF target paints this list, so its pages move with it.isBoxName, so the three agree by construction. Nothing renders differently.options.fonts.<name>.<variant>: .... Everything the parser does afterwards now reports the same way: shaping a run to measure it, and reading the face’s own metrics or character set. Such a failure used to reach you as the parser’s own error, with nothing in it to say which family you declared. The parser’s account is kept as cause.A font parser that will not load no longer reports itself as a missing package.
Passing fonts needs the optional peer fontkit, and every failure to load it used to arrive as options.fonts: install fontkit to measure TrueType families — advice a host had often already taken. A stale bundler cache, a 504 from a dev server, a network failure or a throw from inside the parser all read as an install that was already correct, and the real error was discarded.
The message now says what did not happen instead of why:
options.fonts: the font parser did not load. Install fontkit to use TrueType families
The error that actually threw is attached as cause, so the 504 or the stack behind it is one expand away in a console.
Why the message no longer names the cause: a failed dynamic import cannot be read as “the package is absent”. Node says so with ERR_MODULE_NOT_FOUND, but no browser carries a code at all, and the one message browsers do give covers a 404, a 500, a wrong MIME type, an unreachable host and a nested import failing inside a module that itself loaded fine.
loadFontkit is now exported. It loads the parser once and hands the same module to every caller, which is how @quario/pdf registers the parser this package measured with.
A group instance now moves whole to a fresh page rather than breaking across one, wherever a page could hold the whole of it. A short instance near the foot of a page used to split, leaving a line or two behind. It travels together now. Every target that paginates gains it, so the PDF target, the viewer and the editor all do.
The rule already applied inside a page-column region, where an instance that fits one strip opens the next instead of crossing the boundary. It applies outside one too now, where the fresh column is the next page rather than the next strip. The two cases read the same way, and an author writes nothing to get either.
An instance longer than a page still breaks, the way anything too tall does. The target learns an instance’s height by holding its content until the instance closes, and it gives up holding at one page of content, so that is the limit. When an outer instance gives up, each instance inside it is still kept whole on its own.
This is best-effort rather than a guarantee, and the spec’s list of this target’s best-effort behaviours says so. A group header still travels with its first content unit, which is the guarantee, and which is what decides for an instance too long to move.
A group’s break names a position, and break: "page" is retired. Its values are "before", "between", "after" and "around". Write break: "before" where you wrote break: "page". That is the whole of the migration, and it changes nothing the report produces.
The boundary between two consecutive instances always turns a page. before adds the leading edge of the run, so the first instance opens a page of its own. between adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. after adds the trailing edge, and around adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads after as between and around as before. A nested group takes its two edges from each instance of the group above it, rather than from the document. "page" said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
reset: "page" turns no page of its own, and now needs a break beside it. It says only that a new page.number / page.total sequence starts at this instance. A sequence owns whole pages, so reset requires break to be "before" or "around". Any other break, and reset with no break at all, is a definition error the compile reports. Add break: "before" to a group that declares reset alone today.
The HTML target adds q-break to an instance whose leading edge turns, as before, and the new q-break-after to one whose trailing edge turns. @quario/html/style.css gains .q-break-after { break-after: page } beside the rule it already shipped for .q-break.
The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
The render-event stream states the two edges rather than the four positions. group-start carries break where a page turns before the instance, and the new breakAfter where one turns after it, so a target reads boundaries and never the position that asked for them.
A long report with no borders lays out about a seventh faster. Both figures below come from one paired run. A thousand-page table drops from 447 ms to 380 ms. A hundred-page one drops from 48 ms to 37 ms. Every target that paginates gains it, so the PDF target, the viewer and the editor all do.
The box model built three property names for every side of every cell. That is twelve strings a cell, to discover that the cell declares no border. It reads the side’s width first now, and stops there when there is none.
A report that does declare borders gains less, because the early stop never fires for a side that resolves. It still saves the name building, which every cell paid. Nothing else changes: the pages, the boxes and the text are identical to the byte.
The longest reports lay out a little faster again. A thousand-page table drops from 376 ms to 350 ms, the median of three paired runs. Shorter reports gain less: a hundred-page one drops from 38 ms to 37 ms.
To decide how wide a column should be, the layout asked each cell how wide its text would be with nothing to wrap against. It answered that by laying the text out in full, then keeping one number and throwing the lines away. It now computes the number directly. The pages, the boxes and the text are identical to the byte.
Long reports lay out about a quarter faster. A thousand-page table drops from 620 ms to 473 ms. A hundred-page one drops from 64 ms to 52 ms. Every target that paginates goes through this, so the PDF target, the viewer and the editor all gain it. A keystroke in the editor is most of a layout pass. An author editing a long report feels it directly.
Measuring text is the most expensive thing the layout does, and it measured each piece of text four times. It measures it once now. Nothing else changes: the pages, the boxes and the text are identical to the byte.
Updated dependencies
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.
com.getquario/mcp. The registry checks that name in the published package before it accepts a listing. Nothing changes in how the server runs.{ "$image": "assets/logo.png" } reads a PNG or JPEG from inside the data root and decodes it to bytes before the render, as { "$base64": … } does. An agent no longer pastes a picture as base64 into every call. The path resolves against the data root, also inside a dataPath file, and the server checks it after it resolves symlinks. The server reads no file that is not a PNG or JPEG, and it reads each file once per call. Without a data root, $image is refused. A refusal names its place in the data, such as $.input.logo.An MCP server that exposes quario as two tools. npx @quario/mcp starts a local stdio server for an agent client such as Claude Code or Claude Desktop. validate_report checks a report definition and returns its problems and warnings, each at the path it sits at, and can check the definition’s required declarations against named targets. render_report renders a definition to html, pdf, xlsx, docx or csv and writes the output to a file, returning its path rather than its content.
The launch configuration is the host: the license key, the presentation defaults, the href schemes, the query budgets, the data root and the output directory are environment variables, and no tool argument can reach them. A call may still set locale, currency, timeZone and the target’s own options. Render data arrives inline or as a JSON file inside the data root, and an image arrives as a { "$base64": … } object that decodes to bytes before the render. The server never overwrites a file, and invalid configuration stops it at startup with the message on stderr.