Formatting
Intent
A cell declares what kind of value it holds and lets each target present it. The number stays a number, so the workbook can still compute with it and the page still reads as money. Formatting the value yourself produces a string, and a string is the same string on every surface.
Design decisions
format is a closed kind, never a pattern string. The four kinds are
number, currency, percent and date. A pattern would have to mean the
same thing to a page, a PDF and a worksheet, and those three present numbers
through different machinery. A kind is a statement about the value, which every
target can honour in its own way.
A format needs one value to speak about. It’s read only where its run
renders exactly one value token. A cell that mixes text and an interpolation
under a format now renders the value plainly on every target. The page used
to format it and the sheet didn’t. To format a value inside a sentence, give
it a run of its own.
A kind carries at most one modifier. The three number kinds take digits,
a whole number from 0 to 20. date takes form. Nothing takes two, which
keeps the declaration small enough to read at a glance in a definition under
review.
Precision converges across every target that presents. Absent a digits of
its own, number and percent present two places and currency presents the
minor units of the code that cell wears. A figure therefore reads with the same
cents on the page and in the grid.
Form doesn’t converge across every target. HTML and PDF honour a date’s
form exactly, and a worksheet approximates it. There the four forms map to
number formats a spreadsheet re-presents in the reader’s own application
language. The shape stays the author’s and the language becomes the reader’s,
which is the property a workbook should keep.
Locale and timezone belong to the instance. They describe who is reading,
not what the value is, so they sit on quario({ locale, timeZone }) beside the
license key. One compiled report then serves more than one market without being
rewritten.
A currency code describes the value, so a cell may declare one. This is the
single piece of instance configuration a document may override. A listing whose
rows arrive in different currencies writes "currency": "[email protected]" and keeps its
numbers, with no host function in the path.
date is the only kind that revives a string. A date is the one type JSON
can’t carry. Numbers arrive as numbers, so the other three kinds have nothing
to revive and never read a string.
A modifier a kind can’t read is a definition error. { "kind": "date", "digits": 2 } fails, located, naming the cell. A kind presents a value, so
the engine refuses a declaration it couldn’t present rather than quietly
ignoring it.
format layers whole. A cell that names format restates the entire
declaration. A column declaring { "kind": "currency", "digits": 0 } under a
cell declaring plain "currency" leaves that cell the default count, because
the cell replaced the object rather than merging into it. A run that names
format restates it the same way, and a run that names none inherits the
cell's.
A failed format expression drops out, so the layer below stands. Only
the whole declaration may be an expression, never a modifier inside it. When
that expression resolves to nothing naming a kind, the cell keeps the layer
beneath. A cell inside a currency column still presents as money. It never
said it wasn’t money.
round settles the stored number, not the presented one. The HTML and PDF
targets present through format, so round never changes what they show. The
XLSX and CSV targets keep the number the engine hands them, so an unrounded
sum shows there in full. No target rounds a value on your behalf, because that
would turn presentation into data.
API walkthrough
format is legal wherever a node has a cell value, which means text items,
column cells, headers, and total cells. It’s a definition error on an image, on
a table row’s style, and on the report default, where family and size
stay the only names. It’s also one of the eleven inline names a styled run may carry.
Declaring a kind on a cell
"format": "number" presents two fraction digits. The bare kind is
shorthand for { "kind": "number" }.
"format": "currency" presents the minor units of whichever code the cell
wears. Without a code from either the cell or the instance, the kind
contributes nothing and the value renders as it arrived.
"format": "percent" presents a stored fraction as a percentage, so
0.125 reads as 12.5%. The value in the workbook stays 0.125 under a
percent number format.
"format": "date" presents a Date, or a string it can revive. With no
form it presents medium.
{ "kind": "…", "digits": n } overrides the count the three number kinds
would otherwise present, minor units included. On percent, digits counts
the places of the presented percentage rather than the stored fraction, which
is what both Intl and a worksheet already mean.
{ "kind": "date", "form": "…" } takes short, medium, long or
full.
{
"header": { "value": "VAT" },
"value": "{{ @.vatRate / 100 }}",
"style": { "align": "right", "format": { "kind": "percent", "digits": 0 } }
}A computed count goes on the whole declaration as a hash literal, since the modifier itself can’t be an expression.
{ "value": "{{ @.amount }}", "style": { "format": "={'kind':'number','digits':@.dp}" } }Denominating a money cell
"currency": "EUR" names what a money cell holds. It’s read only under
format: "currency", and a cell’s own code beats the instance’s.
"currency": "[email protected]" takes the code from the row. A literal that’s not
three uppercase letters is a definition error, while an expression result that
isn’t stays lenient at render. An unusable code drops that cell to plain
display. Presenting the figure under the instance’s currency would label an
amount in a denomination nobody named.
The instance’s currency is the default for documents that only ever hold
one denomination.
A target reading the code itself branches on whether the key is present, not on
its value. An unusable code crosses the stream as null rather than as absent.
The engine exports currencyOf(style, options) so a target answers that once
instead of getting it wrong.
Presenting a date
Two string shapes revive, and they’re the two that ECMAScript reads the same way on every machine.
2026-08-14 is a calendar date. It revives as UTC midnight, so an instance
in a western timezone presents the day before. That’s not a quirk of the kind.
It’s what an injected new Date("2026-08-14") already does.
2026-08-14T12:30:00Z is a timestamp naming its offset, and +02:00 works
the same way.
2026-08-14T00:00:00 is zoneless, and doesn’t revive. It means local
time, so it would present a different day per machine. A loose 14/8/2026, a
partial 2026-08, a lowercase t or z, and a day that doesn’t exist all
fall through the same way, rendering as authored.
A date inside a line of text needs a run of its own. Written as one string,
Issued {{ $.input.issued }} renders the ISO date, because its run holds text
and a value together. Split it, and the cell’s format reaches the run holding
the date. A bare date cell becomes a typed date in the workbook, while a mixed
cell becomes text carrying the presented date.
{ "value": [{ "value": "Issued " }, { "value": "{{ $.input.issued }}" }], "style": { "format": "date" } }Epoch milliseconds stay a number under every kind. A month heading with no day still needs a registered function, because none of the four forms prints one.
const funcs = {
/** @param {string} ym A year and month, `2026-08`. */
period: (ym) => {
const [year, month] = ym.split('-').map(Number)
return new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' })
.format(new Date(year, month - 1, 1))
},
}
const report = q.report(definition, funcs)Registered functions are configuration you trust, the same standing as the
definition itself. A function of the same name as a built-in shadows it, so a
host that already supplies its own round keeps it.
Rounding what a cell computes
round(x, n?) rounds to n decimal places, halves away from zero, and n
defaults to 0. It rounds the decimal you wrote rather than the double it’s
stored as, so round(2.675, 2) is 2.68.
floor(x, n?) and ceil(x, n?) are the directed pair, towards
negative and positive infinity.
abs(x) takes no places argument.
An n outside 0 to 100, or one that’s not a number, is an error naming the cell
and the call. One cell holds as many calls as you write. A non-finite x passes
through untouched instead of folding to zero, which leaves the cell as visible
display text rather than a confident wrong total.
Watch the gap between the two counting systems. A percent cell presents the
value multiplied by 100, so matching a two-decimal percent display takes
round(x, 4), not round(x, 2).
min and max are reducer names and aren’t scalar functions here.
min(1, 2) is a render error saying so. Register your own if a report needs
one, and note that a registered function shadows a built-in of the same name.
Configuring the instance
quario({ locale }) defaults to en-US when a kind presents a
value.
quario({ currency }) is the document-wide default code, overridden by a
cell’s own.
quario({ timeZone }) defaults to UTC.
import { quario } from 'quario'
const q = quario({ locale: 'en-IE', currency: 'EUR', timeZone: 'Europe/Dublin' })Custom patterns such as #,##0.00 remain the province of a registered
function. A formatted value is a string, so such a cell is display text on
every surface. In a worksheet it lands as a text cell with no number
format, even when the cell also declares a kind.