Escape
HomeDocsFormatting

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.

a VAT column that reads 21%, not 21.00%
{
  "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.

a per-row digit count
{ "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.

a date inside a sentence
{ "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.

report.js
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.

report.js
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.

Declare the kind. Keep the number.

npm install quario
Getting started
© 2026 quario · KvK 61815977