@quario/xlsx
v0.10.1
Published
Tiny spreadsheet render target for quario. Numbers stay numbers, dates stay dates, and the header row is frozen.
Maintainers
Readme
@quario/xlsx
The spreadsheet render target for quario. Renders a
report definition to an .xlsx workbook with cells typed as a number, boolean, or Date, and a frozen
header row.
Numbers stay numbers, dates stay dates, and booleans stay booleans. Your users get a workbook they can keep working in, not a grid of strings.
Contents
Install
npm install quario @quario/xlsxwerkmap arrives as the one dependency. It is quario's own spreadsheet writer. It writes a workbook and never reads one. It has no dependencies of its own, and it turns no string into code. The engine is a peer, installed beside it. ESM-only, Node 22+, and browser-ready through any standards-based ESM bundler. The renderer returns bytes, so you decide where they go.
The writer is an implementation detail. This package confines werkmap to a single internal module, so a later version can swap it behind this exact API without a breaking change.
Quick start
import { writeFileSync } from "node:fs";
import { xlsx } from "@quario/xlsx";
import { quario } from "quario";
const report = quario().report(schema, funcs);
const bytes = await report.render(xlsx({ meta: { title: "Sales 2026" } }), data);
writeFileSync("sales.xlsx", bytes);Any schema the other targets render works here unchanged. A spreadsheet has no pages on screen,
so page.header and page.footer become the worksheet's print header and footer: text on
every printed page, in the section its align names, with {{ page.number }} and
{{ page.total }} as the reader's own live fields, a first page of its own where a band differs
there, and nothing in the grid.
One tip that changes the output a lot: interpolate the bare value, not a formatter. A cell
written {{ @.amount }} becomes a real numeric cell. {{ currency(@.amount) }} becomes text,
because the formatter already turned the number into a string. Let the spreadsheet do the
formatting it is good at.
API
xlsx(options?)
The target factory takes this target's host options, validates them at the call, and returns the
target you pass to render. The factory refuses an option it does not know. It throws a TypeError at the call for an unknown key. It throws one also for a key with a value of the wrong type. It also reads meta at the call. One configured target
therefore always writes the same document. report() compiles once and report.render(xlsx(options), data) resolves the
workbook bytes. Compile at startup, render per request. Definition problems throw at
report(), at compile time.
const report = quario().report(schema, funcs);
const bytes = await report.render(xlsx(), data);The compiled report carries stream (the raw event generator), names, functions, and
paths, like every quario report. Engine-level options (query budgets, the license key)
live on the instance, and q.license settles with the verification result.
Rendering is asynchronous and returns the loop between batches, so a large report never blocks the host. Render-time failures reject with located errors.
Options
{
meta: { title, author, subject }, // optional document properties — strings only, never a date
page: { size, margin, orientation, fit }, // print setup, written to every worksheet
filter: true, // an autofilter over a worksheet's one table
}Autofilter
xlsx({ filter: true }) puts an autofilter over a worksheet's one table: its header row,
and the data rows under it. It adds the reader's dropdown controls and removes no row. That is what
keeps it an affordance rather than a filter. This is normative.
An autofilter sorts as well as filters, so this option is the sort control too. quario writes every value once, in document order, as a literal, and writes no formula, so a reader who reorders the region sees a running total that no longer runs and a row count that no longer counts. Every row is still there. Offer the option over a table whose columns do not depend on the rows before them.
A total row sits outside the range. A total is not data, and a filter that hides one surprises a reader. A filter that sorts it into the middle of the data is worse.
A worksheet that holds more than one table filters nothing. An autofilter needs one header row over one contiguous body, and such a worksheet has neither: covering the first table alone leaves a reader working a control that does not reach the rest, and covering everything puts a second table's header row inside the filtered body as though it were data. The mapping is withdrawn rather than approximated.
A break is how you get one. It opens a worksheet for each group instance, and each of those holds
one table. A report without a table filters nothing, and neither does a table with no data row.
The key is off unless you ask. Switching it on for every report would put a dropdown row into every workbook this target writes, and that changes what an existing report produces.
Print setup
page.margin is the document field, and xlsx({ page: { margin } }) applies where the document
omits it. Declaring both is a render error. page.size is a host option only, and this target names
A4 and letter. A [width, height] pair is withdrawn, because a worksheet prints on named stock.
orientation takes portrait or landscape. page.fit takes true or false, and it scales
a worksheet to one page wide. This README writes page.fit with its object and never bare, because
an image item carries a fit of its own that scales a picture. Print setup reaches every worksheet, which is where the format puts it.
A report that declares none of these keys carries no print setup, so a reader applies its own
defaults.
Mapping
Worksheets carry the banded walk as rows in render order: report-header items, then per group
instance its header items, nested content, and footer items, then the table's header row, data
rows, and total rows, then report-footer items. Grouping exists as that order and as row
outline levels: an instance's header and footer at its parent's level and its content one
deeper, so a reader collapses an instance from the margin and keeps its header and footer in view.
Group boundaries add no rows of their own. A report that declares no break produces exactly one
worksheet, named Report.
A group boundary is a worksheet boundary. A boundary starts a worksheet and never ends one, so
break: "around" yields one worksheet per instance rather than two. A worksheet that a boundary
opened takes the group's label as its name, or the instance's key where none is declared, in
both cases sanitised, and SCHEMA.md states every step of that.
A group that declares collapsed keeps its rows and hides them, so a reader opens the group
again. The control that reopens them goes on the row after the group, which is the instance's
footer where it declares one; an instance with no footer puts it on whatever follows, and one
whose content ends the worksheet has no row to carry it.
reset is withdrawn, because this target writes no page numbers for a sequence to restart. A
report header's height is withdrawn, because a grid has no page top to pin from.
Each item takes one row with its cell in the first column, unmerged. A split takes one row with its slots as the cells across it (slot widths withdrawn, a slot rendering nothing an empty cell). A spanning header or total cell is a merged range. Two band roles carry an omakase default that the author's own style always overrides: report-header items render bold at 1.4× the report default's size, group-header items render bold.
Typed cells
A cell whose value is one bare interpolation writes its pre-stringify value as a number, boolean, or Date. Every other cell (mixed literal and interpolation, a lone null, any non-numeric value) joins to display text.
String cells are never formulas. Cell text beginning with =, or anything else, arrives as
inert literal text: no formula-injection surface, and no apostrophe mangling either.
A hidden cell keeps its column slot as an empty cell that retains its style.
Number formats
format writes a number format so the grid presents what the other targets do. date builds
its pattern from the cell's form (dd mmm yyyy undeclared), with month and weekday names
spelled by the reader's application. The number kinds build theirs from the declaration's
fraction-digit count: an undeclared number is #,##0.00, percent is 0.00%, and
{ "kind": "number", "digits": 3 } is #,##0.000. currency is the code the cell wears
— its own currency when it declares one, else the instance's — over that currency's minor
units unless digits says otherwise: "USD"#,##0.00, "JPY"#,##0, "BHD"#,##0.000. A kind
on the wrong type, or a code the engine cannot read, writes no format.
A number kind's negative and zero write the format's other sections, which is how the grid
says what the page says: { "kind": "number", "digits": 2, "negative": "parens", "zero": "dash" }
becomes #,##0.00;(#,##0.00);"-", and the cell stays a number. A declaration that names neither
writes one section, exactly as it did before.
Images
An image item is not a cell: it takes one worksheet row as its anchor and floats over the sheet as a
drawing at its natural pixel size. Placement here is best-effort. fit, alt, and style are
unused, and all of it may change without a breaking change (see the
support matrix). Bytes too short to
carry a size never reach this target: the engine reads every image's size and rejects them for
every target alike.
Styles
| Declaration | Maps to |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| family | sans → Calibri, serif → Times New Roman, mono → Courier New. Any other name passes through for the host application to resolve |
| size, bold, italic, underline, strikethrough | Font (strikethrough → writer's strike) |
| color, background | Font colour, solid fill |
| align | Horizontal alignment |
| valign | Vertical alignment. Undeclared writes nothing, so the spreadsheet application keeps its own default |
| border* | Cell borders (solid → thin, dashed, dotted, with width approximated). A row's borders arrive on its cells already |
| format, currency | A number format — see Number formats |
| uppercase, padding*, spaceBefore, spaceAfter | Not read: a grid has no text-transform, no cell inset of its own, and no flow |
Style blocks layer row under cell, as everywhere in quario, with the report default outermost. This target writes every cell at 10 points unless something declares otherwise, and a cell whose display contains a newline wraps.
Keeping the header in view
The first table's header row anchors the frozen view. Everything from that row up stays visible while the body scrolls, the spreadsheet analogue of the PDF target's repeated headers. A report without a table freezes nothing. This is normative.
This target repeats the same rows on paper. It writes them as a _xlnm.Print_Titles defined name,
scoped to that one worksheet. A spreadsheet separates a view from a page, so one intent needs two
spellings here. Both spellings cover the same rows, and both are normative.
The percentage is withdrawn, and content sizes the column
Column width percentages size nothing here. A percentage is a share of the table width, and a
worksheet's columns are global to the sheet. Column A carries the flattened band item rows and the
table's first column alike, so there is no table for a share to be a share of.
The target sizes each worksheet column from the content that column holds instead. A cell votes with a count of the characters it shows, and the widest vote wins. The count is in the format's own unit, which is a count of characters of the workbook's default font.
A table's own cells vote: the header cells, the data cells and the total cells. A band item, a split slot, an image anchor and the marking row do not vote, because none of them is a table column. A cell that covers more than one column does not vote either.
Text votes the length of its longest line. A number and a date hold no text of their own, so each
votes the characters the engine presents it as — the same text that declaration produces on a
rendered page. A boolean votes nothing, because the reader's application spells TRUE and FALSE in
the reader's own language. A vote therefore follows the report's declared locale, and the same report
and data still measure the same on every run.
The winning vote gains one character of slack, and a column stops at 60 characters. A column that nothing voted on keeps the application default. All of this is best-effort and may change without a breaking change.
Page columns are withdrawn
A page column count
sizes and splits nothing here, and will not change. Page columns are strips of a page, and this
target has no page to strip. A break still opens a worksheet here, and the Mapping section above
describes that.
Unlicensed marking
An unlicensed render writes the wording from report-start.marking twice: as a styled banner in
row 1 (so the whole report, frozen view included, sits one row lower than a licensed render), and
as the workbook's description property. Both are normative. Banner styling is best-effort. A
licensed render has no banner and no description marking.
Determinism
The document model and the bytes are both deterministic. This target pins the workbook's dates
rather than stamping them, and meta is opt-in. werkmap stamps no archive entry with the packing
time.
The same schema, data, and options give you the same bytes on one runtime. A runtime here is the
JavaScript engine and the zlib build inside it, because werkmap deflates through the platform's own
CompressionStream. Compare digests on one runtime. Compare extracted content across two.
The full contract is The XLSX target, with each declaration's fate in the support matrix.
Documentation
The quario documentation is the reference.
The report schema is the normative
specification of what a report may declare, and
@quario/xlsx is this package's own API.
License
Commercial software with readable source. Evaluation is free, unlimited, and watermarked. Per-developer licenses at getquario.com. See the bundled LICENSE.
Pass your license key once, on the instance. quario verifies it offline:
const q = quario({ license: "quario_..." });
await q.license; // { licensed: true, licensee: "Acme BV", id: "1-ACME" }