Escape

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, 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

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.

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.