---
title: "The XLSX target — quario"
description: "Rendering a quario definition to a workbook where numbers stay numbers, with typed cells, sized columns, a frozen header and no formulas."
url: "https://getquario.com/docs/diving-deeper/xlsx-target"
---

# The XLSX target

## Intent

`@quario/xlsx` flattens the banded walk onto a worksheet and writes the
values as native cells. A recipient who opens the file can sort it, filter it,
and total a column. That’s the whole reason to send a workbook rather than a
PDF.

## Design decisions

**A group boundary is a worksheet boundary.** A `break` starts a sheet here
rather than turning a page. It starts one without ever ending one, so
`"around"` yields one worksheet per instance rather than two. The worksheet
layout follows the page layout the same declaration produces in PDF, boundary
for boundary. One definition still describes one document.

**A grid has no page of its own, but it does have paper.** A pinned
report-header `height` and page columns do nothing here, and `reset` withdraws
too, because this target writes no page numbers for a sequence to restart.

Page bands are the exception. A worksheet has paper a reader prints it on.
`page.header` and `page.footer` therefore become that worksheet’s own print
header and footer, rather than rows in the grid. The same paper takes print
setup from `xlsx({ page })`, or from the document’s own `page.size`,
`page.orientation` and `page.margin`. Scaling to fit stays the host’s, because
it changes how this target prints a document, not what the document is.

**An authored `width` percentage is still withdrawn, and the content sizes the
column instead.** A percentage is a share of the table’s width, and a
worksheet’s columns belong to the whole sheet. Column A carries the flattened
band items and the table’s first column alike. No table remains for a share to
be a share of. Rather than resolve your percentage against a number you
never wrote, the target measures what each column actually holds. A reader
stops widening a name column by hand, and an amounts column stops arriving
as `####`.

**Chrome merges, data doesn’t.** A spanning cell in a header row or a total
row goes into the first column it covers, and the target merges the range.
The data region never merges, so sort, filter, and column selection keep
working over it.

**String cells are never formulas.** Text beginning with `=` arrives as inert
literal text with no injection surface and no apostrophe mangling. The CSV
target guards differently, because a CSV field has no type to hide behind.

**`uppercase` is withdrawn rather than invented.** A spreadsheet font has no
text transform, and capitalising the cell’s own text would turn presentation
into data. The cell would stop round-tripping and would sort differently.

**Padding and flow spacing are withdrawn on the same ground.** A grid has no
flow, and a cell has no box to inset.

**Every cell takes 10 points unless something declares otherwise.** Leaving
it to the writer’s default would have this target and the PDF target
supplying different numbers for one declaration.

**The bytes are deterministic now, not just the document model.** quario’s own
spreadsheet writer, [werkmap](https://github.com/getquario/werkmap), now writes
the workbook. It replaced exceljs, and the nine dependencies and 22 MB that
came with it. The target already pinned dates and kept `meta` opt-in. But the
old writer stamped every archive entry with the packing time. The same schema, data,
and options now produce byte-identical output on one runtime. A digest therefore
becomes a fingerprint you can cache or compare against. Every report’s bytes
differ from 0.8.x, so that fingerprint changes once.

## API walkthrough

### Rendering

```bash title="shell"
npm install @quario/xlsx
```

Import the `xlsx` factory and render the same compiled report you render
everywhere else. The result is a `Uint8Array`, and writing the file belongs to
you. `xlsx(options)` takes `meta: { title, author, subject }` — strings, never
a date — plus `page` for print setup and `filter` for an autofilter. An option
this target doesn’t know, or one of the wrong type, throws a `TypeError` naming
the path it came from.

### What lands where

A report that declares no `break` produces one worksheet named `Report`, with
the walk flattened onto it in render order. First come the report-header items,
then, per group instance, its header items, nested content, and footer items.
Then come the table’s header row, data rows and total rows, and last the
report-footer items.

Group boundaries add no rows of their own, but the tree reaches the worksheet
as row outline levels. An instance’s header and footer sit at its parent’s
level, and everything between them sits one level deeper. A reader collapses and
expands an instance from the margin while its header and footer stay in view,
the way a subtotal keeps its label. An ungrouped worksheet’s bytes don’t move.

A group declaring `collapsed` arrives shut, and this is the one target that
keeps those rows rather than dropping them. It writes them hidden at their
outline level, and a reader opens them again. A group declaring a `label` names
its worksheet, taking the tab in place of the key’s text through the same
sanitising below.

A report that does declare a `break` opens a worksheet at each boundary. The
report header still lands on `Report`, since it precedes the first one. Each
instance after that takes a sheet named from its own key. The name is the key’s
text, with `: \ / ? * [ ]` replaced by spaces, runs of spaces collapsed, then
trimmed and cut to 31 characters. A key that survives none of that takes the
name `Report`, and a repeated name takes ` (2)`.

Keys come from your data, so the target sanitises a name a spreadsheet can’t
spell rather than refusing it. A render doesn’t start failing because somebody
renamed a region.

Nothing caps how many worksheets a report produces, and quario holds the whole
workbook in memory. A group over a high-cardinality key is worth a thought.

Each band item takes one row with its cell in the first column, unmerged. A
split takes one row, and its slots become the cells across it from the first
column. The target doesn’t read the width shares.

### Keeping a number a number

A cell whose value is one bare interpolation writes its pre-stringify value as
a native cell. A finite number stays a number, a boolean a boolean, a `Date` a
date.

Every other cell joins to display text. That includes a lone null and anything
a registered function already formatted. It’s the strongest practical reason
to declare a `format` kind rather than format a value yourself.

A cell of more than one styled run lands as rich text, each run’s font merged
over the cell’s. Bold, italic, size, face, and colour survive into the grid. A
run holding one interpolation under a `format` presents its value there, so a
sentence can carry a formatted amount without becoming a number cell. A run’s
`background` isn’t read, because spreadsheet rich text carries fonts only.

```json title="typed and untyped, side by side"
{
  "columns": [
    { "header": { "value": "Amount" }, "value": "{{ @.amount }}",
      "style": { "format": "currency" } },
    { "header": { "value": "Label" }, "value": "Total: {{ @.amount }}" }
  ]
}
```

The first column reaches the grid as a number under a currency number format.
The second is text, because a literal run and a value can’t be one cell’s
worth of number. Written as runs with the amount in a run of its own, that
second cell stays text and presents the amount inside it.

### Number formats

A declared kind becomes the cell’s number format. `date` builds its pattern
from the form, and the reader’s own application spells the month and weekday
names in its own language. The shape stays yours and the language becomes
theirs.

The number kinds build theirs from the resolved fraction-digit count. An
undeclared `number` is `#,##0.00`, `percent` is `0.00%`, and `currency` is the
code that cell wears over that currency’s minor units, so `"USD"#,##0.00` but
`"JPY"#,##0`.

A cell declaring `negative` or `zero` gets a sectioned format instead —
`#,##0.00;(#,##0.00);"-"` — so the grid presents what the page presents **and
the cell stays a number**. A host formatting recipe producing the same text
would leave text where an amount belongs. A declaration naming neither writes
one section, so no existing sheet moves.

### Linking out of a cell

A cell carries one hyperlink and a rich-text run carries none, so a cell links
where all of its text points at one place. That’s the cell’s own `href`, a lone
run’s, or several runs that agree. A run pointing somewhere inside a longer cell
stays inert, the way a run-level `background` does. A URL leaves the workbook as an
external relationship.

An `href` naming a group’s `label` points at the worksheet that label opened.
The target resolves it once every worksheet is open, so a report may point
forward through itself. A label that opened no worksheet drops its link and
leaves the text as it is.

### Offering a filter

`xlsx({ filter: true })` puts an autofilter over a worksheet’s one table: its
header row and the data rows under it, never its totals. A filter that hides a
total surprises a reader, and one that sorts it into the middle of the data is
worse.

It offers rather than filters. It adds the reader’s dropdowns and removes no
row, so everything you wrote is still in the document. It stays off unless you
ask, since enabling it would change what every existing report produces.

The same control sorts, which is what an autofilter has always done. That’s
worth a thought before you enable it. quario writes every value once, as a
literal, so a running total a reader reorders no longer runs.

A worksheet holding more than one table filters nothing. An autofilter needs one
header row over one contiguous body, and such a worksheet has neither. A grouped
report that declares no `break` renders one table per instance onto a single
sheet. A `break` is how you get one table to a sheet, and so how you get a
filter.

### Setting up the printed page

`xlsx({ page })` writes print setup to every worksheet the report opens. `size`
is `A4` or `letter`, and the target refuses any other name rather than printing
on the reader’s default. `margin` is one number for all four sides, or `top`,
`right`, `bottom` and `left`, in points, and the target refuses one that
leaves the page no room. `orientation` turns the paper. A document that
declares its own `page.size`, `page.orientation` or `page.margin` wins.
Declaring the same key in both is a render error, as in every target with a
page. `fit` scales a worksheet to one page wide,
and as many pages tall as it takes. It’s a different key from the `fit` an image
item carries.

A `[width, height]` page size withdraws here, because the format carries a paper
code rather than a measurement. Picking the nearest named stock would give you a
size you didn’t ask for. A report that declares no print setup carries none, and
a reader’s application applies its own defaults rather than this target’s.

`page.header` and `page.footer` print on every page. The target reads each band
at page 1 and at page 2 of 2, as the DOCX target does. A bare
`{{ page.number }}` or `{{ page.total }}` becomes the reader’s own live page
number or count. Anything computed from a page value freezes at that reading,
and a band that differs on the first page gives the first page its own. A page
band holds text items only, one line each, in the section its `align` names,
with `bold` and `size` as its look. A split, an image, and every other style
withdraw there. A band storing past the format’s 255 characters is a render
error naming it.

### Sizing the columns

A cell votes with a count of the characters it presents, and the widest vote wins.
A table’s own cells vote: the header, data, and total cells, since those are the
only ones a column holds. Band items, split slots, image anchors, and the
marking row all stay silent. A band item lands in column A as a line of prose.
A data column stretched to fit prose isn’t what the report asked for. A cell
spanning columns stays silent too.

Text votes its longest line, so a cell that wraps gets a column wide enough for
the widest line rather than the whole string. A number and a date hold no text
of their own, so each votes the characters quario presents it as. That’s the
same text the declaration produces on a rendered page, which keeps an amounts or
a dates column from arriving as `####`. A boolean votes nothing, because the
reader’s application spells `TRUE` and `FALSE` in its own language.

A vote therefore follows the locale and time zone you configured the render
with. The winning vote gains one character of slack and stops at 60. One very
long cell therefore can’t produce a column nobody can scroll past. A column
nothing voted on keeps the application default. All of this is best-effort,
like every presentation number this target picks that no author wrote.

### Freezing the header

The first table’s header row anchors the frozen view, so everything from that
row up stays visible while the body scrolls. It’s the spreadsheet analogue of
the PDF target’s repeated headers. A report without a table freezes nothing, and
every worksheet a break opened freezes its own.

The same rows repeat on paper, written as a print title scoped to that
worksheet. A spreadsheet separates a view from a page, so one intent needs the
two spellings. Both take the same rows.

### The unlicensed marking

An unlicensed render writes the wording twice, as a styled banner in row 1 and
as the workbook’s description property. The whole report therefore sits one row
lower than a licensed render, frozen view included. The banner repeats on every
worksheet the report opens. That’s worth knowing if you are diffing output
between the two states.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Docs","item":"https://getquario.com/docs"},{"@type":"ListItem","position":3,"name":"The XLSX target","item":"https://getquario.com/docs/diving-deeper/xlsx-target"}]}
```
