Escape
HomeChangelog

Changelog

Every published release of the 9 packages the reference documents — 70 in all, read from the same install that builds these docs. The packages version independently and follow Semantic Versioning: a minor bump adds, a patch fixes.

CURRENT VERSIONS

quario0.9.0
@quario/html0.9.0
@quario/pdf0.9.0
@quario/xlsx0.8.0
@quario/csv0.8.0
@quario/docx0.1.1
@quario/viewer0.9.0
@quario/editor0.7.1
@quario/layout0.6.0

quario

0.9.0

quario 0.9.0

Minor Changes

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

Patch Changes

  • The engine verifies a license key against a new signing key.
0.8.0

quario 0.8.0

Minor Changes

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

Patch Changes

  • A PNG wider or taller than 65535 pixels is no longer misplaced. Its dimensions were read from the low half of each of IHDR’s four-byte fields, so a 70000-pixel panorama measured 4464 pixels and was laid out at that size, confidently and silently.
  • 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.
0.7.0

quario 0.7.0

Minor Changes

  • 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:

    • a format on a cell whose value can never present a single interpolation — "Due {{ due }}" under format: "date" — which the engine leaves unread;
    • a 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;
    • a table where every column declares a 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.

0.6.0

quario 0.6.0

Minor Changes

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

0.5.0

quario 0.5.0

Minor Changes

  • 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.
  • A table row’s 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.
0.4.0

quario 0.4.0

Minor Changes

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

0.3.0

quario 0.3.0

Minor Changes

  • 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.
  • The report header may pin a 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.
  • Per-side padding and border on the closed style vocabulary. 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.
  • A table total is N rows. Before, 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.
  • A visible text item occupies a line at its own 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.
0.2.0

quario 0.2.0

Minor Changes

  • A report default: one 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.
  • An 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.
  • A 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.
  • Every problem keeps its own located diagnostic. 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.
  • Events name the definition behind them. 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.
  • Located data-query errors carry padvinder’s code and span. A 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.
  • Cells render straight over the engine scope chain. A text cell no longer allocates a wrapper scope and an anchor pair per cell per row — quario’s chain already binds $ 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.
  • A compiled report’s 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.
  • A bare 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.
  • A bad literal pattern in the data query is a definition error. A typo’d I-Regexp written as a string literal in 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.
  • Each engine relocates its own diagnostic. A located error is now a copy made by the engine that raised it (xprsn, sjabloon, or padvinder), so it carries every field that engine puts on a diagnostic — nothing is lost in quario’s re-throw, today or when an engine adds a field — and it still passes that engine’s own 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.
  • A host error cannot pose as the report’s diagnostic. 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.
0.1.0

quario 0.1.0

Minor Changes

  • A compile-once report engine. You hand it a JSON report definition and data; it plans once and streams render events that every target consumes. Text cells go through sjabloon, everything else through xprsn, rows through padvinder. Unlicensed output carries a marking on the opening event; each target only decides where that wording goes.

@quario/html

0.9.0

@quario/html 0.9.0

Minor Changes

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

Patch Changes

0.8.0

@quario/html 0.8.0

Minor Changes

  • A picture states its own size, so a page stops reflowing around it. Each <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.

Patch Changes

  • 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
0.7.0

@quario/html 0.7.0

Minor Changes

  • 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;
    }
    
0.6.0

@quario/html 0.6.0

Minor Changes

  • A cell’s own 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.
  • Numbers presented through 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.
0.5.0

@quario/html 0.5.0

Minor Changes

  • A spanning cell emits 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.
  • A row’s box now reaches the cells instead of being dropped. Padding and border on 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.
  • Unstyled table cells sit at the top of their row. The reference stylesheet now pins 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.
0.4.0

@quario/html 0.4.0

Minor Changes

  • A split slot’s box now fills the split’s height. Before, a slot’s background and border stopped at that slot’s own content, leaving a gap under any slot shorter than its neighbours. The slot wrapper now carries inline 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.
  • A date string under 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.
0.3.0

@quario/html 0.3.0

Minor Changes

  • 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.
  • Padding and complete border sides map to inline CSS. 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.
  • A visible text item occupies a line, and a literal newline is a break. Before, an empty item collapsed and a newline became a space. The honor rules now hang off .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.
  • A declared 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.
0.2.0

@quario/html 0.2.0

Minor Changes

  • 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.
  • The report default rides a report root. Every fragment is now wrapped in one <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.
  • Splits render as a flex row of sized slots. A split becomes <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.
  • A family name that could not be written as CSS now falls back instead of being rewritten. A 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 }).
  • The fragment now has a root element. <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.
  • The reference stylesheet’s baseline face moved onto .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.
  • A bare 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.
  • The reference stylesheet sets a base font family. A fragment dropped into a page with no font of its own inherited the browser default — a serif — where every other target rendered a grotesque. @quario/html/style.css now sets sans-serif on report text, as an ordinary rule any host overrides by source order.
0.1.0

@quario/html 0.1.0

Minor Changes

  • HTML fragments for compiled reports. Semantic tables, stable 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.

@quario/pdf

0.9.0

@quario/pdf 0.9.0

Minor Changes

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

Patch Changes

  • 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

0.8.0

@quario/pdf 0.8.0

Minor Changes

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

Patch Changes

  • 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
0.7.0

@quario/pdf 0.7.0

Minor Changes

  • Styled runs reach the page. A cell value written as a list of styled runs draws each run in its own face, size and colour, underlines and strikes through per run rather than across the line, and paints a run’s background as a highlight behind its text.
  • A 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.
0.6.0

@quario/pdf 0.6.0

Minor Changes

  • A cell’s own 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.
  • Numbers presented through 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.
  • An image these bytes cannot embed now fails with the item’s name on it. A PNG whose pixel data is corrupt past the size in its header used to reject the render with 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.
0.5.0

@quario/pdf 0.5.0

Minor Changes

  • A spanning cell draws as one box across the columns it covers, through @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.
  • A row’s box is drawn by the row’s cells, through @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.
  • Embedding TrueType fonts 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, 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.
0.4.0

@quario/pdf 0.4.0

Minor Changes

  • A split slot’s box now fills the split’s height. A slot’s background and border stopped at that slot’s own content and now take the split’s height, the way a table cell’s take their row’s. See the @quario/layout changelog for the rule and for what it costs a report that relied on the short box.
  • A date string under 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.
  • The layout moved to @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.
  • 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.
  • A padded or bordered item or row that breaks across a page keeps its box. Content too tall for one page is drawn as slices, and a slice now carries the box sides the break left it — the top on the first, the bottom on the last. Previously an item that overflowed lost its box entirely, so the same declaration drew a border on short data and none on long; a bordered table cell drew all four sides on every slice, putting an edge across the middle of its own text; and a row’s own border vanished when the row was too tall for a page. A slice’s lines are also drawn inside the horizontal padding they were wrapped for, rather than flush to the column. This changes output for reports where such content breaks.
0.3.0

@quario/pdf 0.3.0

Minor Changes

  • 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.
  • A height-declared report header pins from the page top. Leftover sits under the items; the next band starts at the pin. The half-line group gap drops after it. 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.
  • The box is honoured in layout. Padding and border inset wrap width; a named padding 0 beats the cell omakase (PADX 6 / PADY 2) on that side. Incomplete sides draw nothing.
  • The last data row keeps the whole emitted total block. A stack taller than a page degrades like a tall row.
  • An unstyled table has no rules. This target used to draw two 0.5 pt rules, under the header and above the total, that no declaration could ask for or refuse. Those strokes are gone. The strokes an author wants are the box they declared. Cell padding omakase stays on undeclared cell sides.
  • A visible text item occupies a line at its own 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.
  • A missing TrueType glyph draws as ?, 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 ?.
  • CR, LF, and CRLF are one hard line break. Cell text already broke on LF; a CRLF left a CR on the previous line, and a lone CR did not break. All three are now one break, matching SCHEMA.md Cell values. Wrapping still applies within each line.
0.2.0

@quario/pdf 0.2.0

Minor Changes

  • The report default sets the document’s face and base size. A report’s top-level 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.
  • Splits lay out across the content width. Each slot is measured and wrapped inside its own share, all slots draw from a common top, and the split takes the height of its tallest slot so the band below clears them all. A split is never broken across a page: one that does not fit the remaining height moves whole, exactly as an image does, and one taller than any page renders in full past the bottom margin. The split’s own style is the layer under each slot’s, and its background fills the whole split behind them.
  • A bare 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.
0.1.0

@quario/pdf 0.1.0

Minor Changes

  • Paginated PDFs from the same compiled report. The band flow places headers, groups, and tables; pdf-lib writes the file. Page furniture and an outline from the group tree. Optional TrueType embedding through @pdf-lib/fontkit.

@quario/xlsx

0.8.0

@quario/xlsx 0.8.0

Minor Changes

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

Patch Changes

0.7.1

@quario/xlsx 0.7.1

Patch Changes

  • 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
0.7.0

@quario/xlsx 0.7.0

Minor Changes

  • Styled runs become rich text. A cell of more than one styled run is written as rich text, each run’s font merged over the cell’s — so bold, italic, underline, strikethrough, size, face and colour survive into the grid. A run-level 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.
  • A run inside a sentence presents its value. A run holding one interpolation under a format shows the same decimals here as on the page, where a mixed cell used to show the bare value.
0.6.0

@quario/xlsx 0.6.0

Minor Changes

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

0.5.0

@quario/xlsx 0.5.0

Minor Changes

  • Chrome merges, data does not. A cell spanning several columns in the header row or a total row is written into the first column it covers, the rest of the range is written empty, and the range is merged. Nothing in the data region merges, so sort, filter and column selection keep working over it.
  • valign writes the cell’s vertical alignment for table cells and split slots. Undeclared writes nothing, so the spreadsheet application keeps its own default.
0.4.0

@quario/xlsx 0.4.0

Minor Changes

  • A date string under 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.
0.3.0

@quario/xlsx 0.3.0

Minor Changes

  • 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.
  • Cell borders map; padding and flow spacing do not. 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.
  • A table total emits N rows. Each total-row is one worksheet row, as each data row is.
  • A cell whose display contains a newline wraps. 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.
0.2.0

@quario/xlsx 0.2.0

Minor Changes

  • The report default reaches every cell. A report’s top-level 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.
  • A split is one row, its slots the cells across it. The grid’s own reading of values placed beside each other. Slot 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.
  • Every cell is written at 10 points unless something declares otherwise. The size was previously left to the writer’s own default of 11. Now that a report can declare its own size, this target and the PDF carrying different numbers for one declaration is exactly what the targets’ agreement rule forbids. A worksheet’s row height follows its font size, so existing unstyled sheets come out slightly tighter.
  • Display-text 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.
  • Cells carry their font face explicitly. Text declaring no 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.
0.1.0

@quario/xlsx 0.1.0

Minor Changes

  • A workbook from the same compiled report. Bands become rows; a bare interpolation that is a number, boolean, or Date stays that type. The first table freezes its header row. exceljs writes the file. Column width percentages are unread: a worksheet has no table for a share to be a share of.

@quario/csv

0.8.0

@quario/csv 0.8.0

Minor Changes

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

Patch Changes

0.7.1

@quario/csv 0.7.1

Patch Changes

  • 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
0.7.0

@quario/csv 0.7.0

Minor Changes

  • Styled runs flatten to their joined text. A cell value written as a list of styled runs writes the runs’ text as one field. This target withdraws every style, and a run’s is no different.
0.6.0

@quario/csv 0.6.0

Minor Changes

0.5.0

@quario/csv 0.5.0

Minor Changes

  • Spans keep the record rectangular. A cell covering several columns writes its value in the first field it covers and leaves the rest empty, so every record is the same width. This target reads no geometry, so there is nothing here for a merge to mean.
0.4.0

@quario/csv 0.4.0

Minor Changes

0.3.0

@quario/csv 0.3.0

Minor Changes

  • A table total emits N records. Each 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.
0.2.0

@quario/csv 0.2.0

Minor Changes

  • A split is one record, its slots the fields. Slots are values beside each other, which is what a record is. Slot widths go unread, as every width is here. Images stay unread too, but a slot holding one still writes an empty field, so the fields either side keep their positions.
  • Display-text 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.
0.1.0

@quario/csv 0.1.0

Minor Changes

  • CSV records in render order. A bare interpolation stays typed text (a number’s decimal, a Date as ISO 8601 UTC); everything else is display text, then an apostrophe on formula-like cells, then RFC 4180 quoting. The unlicensed marking is a trailing line, so record 1 stays the report’s.

@quario/docx

0.1.1

@quario/docx 0.1.1

Patch Changes

  • 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

0.1.0

@quario/docx 0.1.0

Minor Changes

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

Patch Changes

@quario/viewer

0.9.0

@quario/viewer 0.9.0

Minor Changes

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

Patch Changes

0.8.0

@quario/viewer 0.8.0

Minor Changes

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

Patch Changes

  • 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
0.7.0

@quario/viewer 0.7.0

Minor Changes

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

0.6.0

@quario/viewer 0.6.0

Minor Changes

  • Numbers presented through 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.
  • A page the browser cannot fully paint no longer fails the render. An image whose pixel data is corrupt past the size in its header left that page blank and rejected 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.
0.5.0

@quario/viewer 0.5.0

Minor Changes

  • Previewing with 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.
  • The bar’s controls are drawn icons. The zoom trigger, the menu’s check and the error panel’s dismiss are drawn from Lucide’s set rather than by hand, at one weight and one grid, and the dismiss no longer relies on the host’s font to draw a multiplication sign. They paint in currentColor, so --qv-icon and --qv-icon-active still recolour them.
  • An export button names its format in words. Each one is a download icon with 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.
  • A zoom no longer leaves a page part-drawn. Changing the zoom while a page was still painting could leave that page carrying content at the old scale on a canvas sized for the new one — the rest of it blank — and nothing repainted it afterwards: scrolling away and back left it as it was. A page whose paint is still in flight is now replaced rather than reused, so the superseded paint has nowhere to land.
0.4.0

@quario/viewer 0.4.0

Minor Changes

  • 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.
  • A split slot’s box now fills the split’s height. A slot’s background and border stopped at that slot’s own content and now take the split’s height, the way a table cell’s take their row’s. See the @quario/layout changelog for the rule and for what it costs a report that relied on the short box.
  • The sheet has pages, and they break where the PDF’s do. The viewer depends on @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.
  • A long report no longer asks for a backing store per page. The viewer paints the pages near the viewport — the ones on screen and one screenful either side — and keeps every other page sized but blank. A report of a few hundred pages used to ask the browser for gigabytes of canvas; past what it would grant, pages came up blank with nothing reported, which on a HiDPI display took only a moderately long report. Every page still holds its place, so the scrollbar and the scroll extent are the whole report from the first frame, and a zoom step now repaints a handful of pages instead of walking the document.
0.3.0

@quario/viewer 0.3.0

Minor Changes

  • Report and group containers are flex columns. Adjacent authored spaceBefore / spaceAfter margins add rather than collapse, matching the html reference stylesheet.
  • The adopted sheet occupies a line and preserves authored newlines. Same min-height: 1lh and white-space: pre-line the html reference stylesheet now carries on .q-item, and pre-line on table cells.
0.2.0

@quario/viewer 0.2.0

Minor Changes

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

0.1.0

@quario/viewer 0.1.0

Minor Changes

  • An embeddable <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.

@quario/editor

0.7.1

@quario/editor 0.7.1

Patch Changes

  • 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

0.7.0

@quario/editor 0.7.0

Minor Changes

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

Patch Changes

  • 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

0.6.0

@quario/editor 0.6.0

Minor Changes

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

0.5.0

@quario/editor 0.5.0

Minor Changes

  • 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:

    • When a report fails to render, the event now carries the problem list the document actually earned — which is empty, since a document with problems never reaches the render at all. It used to repeat whatever the last render that worked had found, so a problems.length check could be answering for an older document.
    • When nothing could be checked — a property the editor rejects, or the engine itself throwing — no 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.

0.4.0

@quario/editor 0.4.0

Minor Changes

  • Table columns keep a merged cell intact. A total row whose label runs across several columns, or a header that covers its neighbours, now survives every column verb. Adding a column inside such a cell widens it; adding one at its edge gives the new column a cell of its own. Removing a column narrows the cell rather than breaking it, and removing the one a header was written on keeps that label on the columns it still covers. Duplicating a column under a merged header widens the header instead of leaving two of them. Reordering columns inside a merged region is free, because nothing there is lost by the move; dragging a column that has a cell of its own into the middle of a merged one is refused, and the drop indicator will not go there. A merged cell can be preserved and edited around, but not yet created in the editor.
  • A column a header covers is no longer offered a header of its own in the properties rail, where an empty field used to invite an edit that could only produce an error.
  • Vertical alignment in the style rail for table cells and total rows: a three-button group beside the horizontal one, writing valign.
  • The controls that were text characters are drawn icons. Undo and redo, the gutter’s grip and its insert +, 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.
  • Reveal, 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.
  • Adding a total row selects the row it added. The selection landed on the row above the new one, so the properties panel showed the wrong cells and a second Add total row inserted in the wrong place. Adding a column had always selected the new column; the two verbs now answer the same way.
  • A 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.
  • The style rail offers 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.
0.3.0

@quario/editor 0.3.0

Minor Changes

  • fonts. The font mapping a host passes to pdf({ fonts }), so the author designs in the faces the document will embed.
  • A split slot’s box now fills the split’s height. A slot’s background and border stopped at that slot’s own content and now take the split’s height, the way a table cell’s take their row’s. See the @quario/layout changelog for the rule and for what it costs a report that relied on the short box.
  • The design surface is the layout list. The editor depends on @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.
  • A page is drawn the size the viewer draws it. The design surface used to put a point on a pixel, so a page stood about a quarter smaller here than in the viewer showing the same report. A point is now 96 dpi over 72 on both, and the two agree. Breaking: an embedding sized around the old, smaller sheet needs its container re-checked.
  • 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.
0.2.0

@quario/editor 0.2.0

Minor Changes

  • The style rail carries the box. Padding is a count from zero, border style a closed choice, border colour a picker, on every node that wears the vocabulary. An image shows align, background, and the box, not the typeface names it cannot wear.
  • 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.
  • A table total is N rows the editor can add and remove. Column operations splice each row’s cells. Removing the last row drops the key rather than leaving total: [].
  • Report and group containers are flex columns. Adjacent authored spaceBefore / spaceAfter margins add rather than collapse, matching the html reference stylesheet.
  • The adopted sheet occupies a line and preserves authored newlines. Same min-height: 1lh and white-space: pre-line the html reference stylesheet now carries on .q-item, and pre-line on table cells.
0.1.0

@quario/editor 0.1.0

Minor Changes

  • An embeddable <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.
  • The preview answers to the keystroke, undo to the gesture. Typing in the properties panel and dragging a colour picker move the report and its problem list live, so a half-written expression shows what is wrong with it as it is written. Neither takes an undo step or reaches the host until the gesture ends — on blur, on Enter, on the picker’s release, or after a half-second pause — so one edit is one undo, not one per character. Interrupting an edit never loses it: pressing another control or selecting something else leaves the typing behind as its own undo step rather than folding it into what came next.
  • 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.

@quario/layout

0.6.0

@quario/layout 0.6.0

Minor Changes

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

Patch Changes

0.5.0

@quario/layout 0.5.0

Minor Changes

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

Patch Changes

  • A page’s right and bottom edge could be left unpainted. Where a caller rounds the backing store up to a whole device pixel — A4 at 100% asks for 793.71 and takes 794 — 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.
  • 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
0.4.0

@quario/layout 0.4.0

Minor Changes

  • Per-run typography, decoration and highlights. A styled run resolves its own face, size and colour, and a line takes the largest size among its runs. 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.
  • An outline 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.
0.3.0

@quario/layout 0.3.0

Minor Changes

  • The text join reads a cell’s own currency code, ahead of the instance’s default, so every target built on this package presents a per-cell denomination.
  • A group instance that renders nothing no longer takes up space. A group whose header and footer items all resolve 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.
  • Numbers presented through format now show a fixed two fraction digits, matching every other target1,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.
  • An image failure now names the item that asked for the bytes. A file too short to carry a size failed saying only that the size could not be read, naming no item, so a report with two pictures gave no way to tell which one was bad. The message is now prefixed with the item’s source path, as every other render error is.
  • An image the browser cannot decode no longer costs the whole page. A PNG or JPEG whose pixel data is corrupt past the size in its header lays out like any other — the size is all that is read of it — and used to throw out of 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.
0.2.0

@quario/layout 0.2.0

Minor Changes

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

0.1.0

@quario/layout 0.1.0

Minor Changes

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

Start folding data into documents.

npm install quario
Getting started
© 2026 quario · KvK 61815977