# simple-excel
> A streaming, dependency-free xlsx reader and writer for browsers and Node, built for large files that must open cleanly in Excel, Google Sheets, LibreOffice and Numbers.
This file contains all documentation content in a single document following the llmstxt.org standard.
## Comparison
There are good reasons to use another library. This page is the honest version of the README table: what each
one does well, where it breaks, and which of those breakages matter for your case.
Everything here comes from two documents in the repository — a cross-language survey of how xlsx libraries
achieve bounded memory ([`research/03-library-landscape.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/03-library-landscape.md))
and a detailed evaluation of `@office-kit/xlsx` against this corpus
([`research/10-office-kit-evaluation.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/10-office-kit-evaluation.md)) —
plus the benchmark runs in
[`research/06-performance-baseline.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/06-performance-baseline.md).
### At a glance
| | simple-excel | SheetJS CE | ExcelJS | @office-kit/xlsx | write-excel-file / read-excel-file |
| ------------------------------------------- | ---------------------- | --------------------------------- | ------------------- | ------------------------------- | ---------------------------------- |
| Streaming write (flat memory at 1M rows) | yes | no | yes | no (buffers until `finalize()`) | no |
| Streaming read | yes | no | yes | yes | no |
| Runtime dependencies | 0 | 0 | 9 | 3 | 1 / 4 |
| Bundle size (min+brotli) | 29.0 KB | not measured | not measured | ≤ 120 KB (its README) | not measured |
| Maintained on npm | yes | no (fixes on the vendor CDN only) | inactive since 2023 | yes (pre-1.0) | yes |
| Verified against Excel with a public corpus | yes | no | no | no (validator + fixtures in CI) | no |
| Styles | write | Pro only | yes | yes | basic, write only |
| Formulas, charts, pivots, editing | no | partial | partial | yes | no |
| Legacy formats (.xls, .xlsb, .ods, CSV) | no, detected and named | yes, silently | CSV only | no; names `.xls`, not `.xlsb` | no |
### Measured performance
100k rows × 20 columns of Salesforce-shaped data (ids, unicode names, decimals, booleans, dates, long text with
newlines, 10% nulls — about 1.7 KB of text per row), Node v24.18.0 on an Apple M4, median of three runs, each
engine in a fresh process. "Footprint" is the peak RSS above the pre-run baseline; "first byte" is how long until
the sink receives anything. The other engines are from the merged run
[`2026-09-12-macbook-air-phase-e-combined`](https://github.com/jetstreamapp/simple-excel/tree/main/bench/results/2026-09-12-macbook-air-phase-e-combined),
whose `phase-e-scale` part supplies the 1M column; simple-excel's 100k columns are from
[`2026-09-12-macbook-air-inline-default`](https://github.com/jetstreamapp/simple-excel/tree/main/bench/results/2026-09-12-macbook-air-inline-default),
the shipped default configuration.
| Engine | Write time | Write footprint | Read time | Read footprint | 1M × 20 write | First byte |
| ----------------------- | ---------- | --------------- | ---------- | -------------- | ------------- | ---------- |
| SheetJS 0.20.3 | 9.75 s | 3,313 MB | 6.17 s | 1,837 MB | `RangeError` | n/a |
| ExcelJS 4.4.0 | 2.49 s | 328 MB | 9.53 s | 193 MB | not run | 1.61 s |
| @office-kit/xlsx 0.11.0 | 4.36 s | 1,241 MB | 6.57 s | 449 MB | `RangeError` | 3.1 ms |
| write-excel-file 2.3.10 | 3.21 s | 1,139 MB | 6.80 s | 5,290 MB | not run | n/a |
| **simple-excel** | **3.08 s** | **58 MB** | **1.64 s** | **810 MB** | **31.9 s** | **0.5 ms** |
Three footnotes on that row. simple-excel's 810 MB read footprint is the 100,000 materialized records the typed
read returns, not the parser: streaming the same file with `sheet.rows()` costs 1.48 s and 46 MB. Its write time
is the platform `CompressionStream` with the default inline strings; in Node, `nodeDeflater(1)` brings it to
1.73 s for a file about 20% larger, and the optional bounded shared-string table (`strings: 'auto'`) to 2.77 s.
The 1M × 20 column was measured with `strings: 'auto'` before inline became the default. The 1M × 20 write grows the JS heap by 77 MB, and the 18M-cell shape (900,000 × 20)
completes in 27.0 s for 76 MB.
The `RangeError` is `Invalid string length`: V8 refuses to build a string past about 512 MiB, and both SheetJS and
office-kit build one — the sheet XML in SheetJS's case, the joined shared-string table in office-kit's. It is not
a tuning problem; it is the architecture. In a Chrome module worker the same split shows up as a crash: writing
1,000,000 × 20 to a Blob costs simple-excel 39.6 s and +254 MB of renderer RSS, while SheetJS takes the renderer
down with it.
### SheetJS CE
The default choice for a decade, and still the most tolerant reader in existence: it will read `.xls`, `.ods`,
CSV bytes and SpreadsheetML 2003 without being asked.
**Where it breaks.** It materializes the whole workbook, and builds the sheet XML as one string. That fails at a
predictable size — around 958k rows or 18M cells — with `RangeError: Invalid string length`. There is no
streaming read or write in the community edition.
**Maintenance.** npm is frozen at 0.18.5, which carries CVE-2023-30533 (prototype pollution) and CVE-2024-22363
(ReDoS). The fixes exist, but only on the vendor's own CDN, so `npm install xlsx` gets you the vulnerable
version and a lockfile that cannot be audited normally.
**Use it** for small files where its format tolerance is the feature — a "paste anything in" import path. Be
aware that reading `.xls` and CSV _silently_ means users get different type coercion depending on what they
uploaded, with no signal to you or to them.
**Migrating.** Object mode reproduces `sheet_to_json`'s key naming exactly (`__EMPTY`, `__EMPTY_1`, duplicate
`_1`/`_2` suffixes, `defval`, `blankrows: false`), and `dates: 'local'` matches its `cellDates: true` output. The
[reading page](./reading.md#header-naming) has the details. Three documented differences remain: SheetJS returns
the raw number for the fake 1900-02-29, collapses CRLF to LF, and decodes `_X0041_` with a capital X (none of
which this library does).
:::caution
SheetJS 0.20.3 cannot open a zip64 archive at all. If it is a consumer of your files, leave `zip64: 'auto'` alone.
:::
### ExcelJS
The most complete of the open-source JavaScript libraries: full styles, formulas, images, data validation, both a
DOM API and genuinely streaming reader and writer classes.
**Where it breaks.** The streaming façade sits over DOM code, and every intermediate string is not bounded:
`Invalid string length` on write is reported even through the stream API
([#1868](https://github.com/exceljs/exceljs/issues/1868)), and stream reads have OOM'd on a 6M-row file
([#355](https://github.com/exceljs/exceljs/issues/355)). Its measured first byte at 100k rows is 1.61 s, so the
"streaming" writer is doing a great deal before anything reaches the sink. Its shared-string reader once pushed
each rich-text run as a separate entry, shifting every later index
([#1431](https://github.com/exceljs/exceljs/issues/1431)).
**Maintenance.** 4.4.0 shipped in October 2023; the maintainers have said they are inactive
([#2969](https://github.com/exceljs/exceljs/issues/2969)). Nine runtime dependencies, including `archiver`,
`unzipper` and `jszip`.
**Use it** when you need the feature surface — editing workbooks, charts, images, conditional formatting — and
your files are of ordinary size.
### @office-kit/xlsx
The most interesting alternative, and the one this library was seriously evaluated against. MIT, TypeScript,
about 2,100 tests, an openpyxl fixture corpus as a submodule, and a three-tier validator in CI. One responsive
maintainer shipping same-day fixes.
**Where it breaks.** The streaming writer is streaming in API shape only. `appendRow` accumulates the worksheet
XML in memory and the zip entry is deflated and handed to the sink at `finalize()` — with `toFile()`, the output
file is 0 bytes until the very end. Every string is interned in an unbounded shared-string table that
`finalize()` serializes with one `Array.join`, which is the `RangeError` above. On the Salesforce-shaped dataset
that means a 1,241 MB footprint for 100k rows.
Two reader gaps also mattered here: it does not decode `_xHHHH_` escapes, so control characters and CRs come back
as literal `_x000d_` text; and it cannot open Salesforce report exports at all, because Apache POI writes
` ` — a legal `ST_UnderlineValues` member — which its parser rejects for the whole workbook.
Dates come back as raw serials from both APIs, so the consumer has to resolve number formats itself.
**Use it** when you need its feature surface (styles, conditional formatting, data validation, charts, pivot and
VBA passthrough) on files that fit comfortably in memory, and you are not reading POI-generated files. It is
pre-1.0 and says its APIs may shift.
### write-excel-file / read-excel-file
A pair of small, focused, actively maintained libraries with a pleasant API. `read-excel-file` in particular has
a schema-mapping feature that is genuinely nice.
**Where they break.** Neither streams. `write-excel-file` builds XML from a fully materialized array of rows;
`read-excel-file`'s own README scopes it to "small to medium" files, and it measured a 5,290 MB footprint reading
100k × 20 — 2.9× SheetJS. `write-excel-file` also writes 1900-02-28 as serial 60, the leap-bug off-by-one.
**Use them** for reports of a known, modest size where the API and the schema mapping earn their place.
### What simple-excel gives up
Being honest about the other direction. There are no formulas, charts, pivot tables, images, comments,
hyperlinks, conditional formatting or data validation, on either side. You cannot open a workbook, change three
cells and save it — reading and writing are separate operations, and unknown parts are not preserved. Number
formats are stored but never rendered, so there is no display text, only values. Rich text is flattened on read
and cannot be written. `.xls`, `.xlsb`, `.ods` and CSV are detected and named, never parsed. And it is pre-1.0,
with one maintaining organisation.
The full list is [`research/07-reference-architecture.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/07-reference-architecture.md) §9.
### Choosing
| If… | Then |
| ------------------------------------------------------------------- | ----------------------------- |
| The file might be large, or you cannot bound it | simple-excel |
| You are in a browser tab, worker or extension and memory is finite | simple-excel |
| You need a dependency-free path for untrusted uploads | simple-excel |
| You need to edit an existing workbook in place | ExcelJS or `@office-kit/xlsx` |
| You need charts, pivots, conditional formatting or data validation | ExcelJS or `@office-kit/xlsx` |
| You need to accept `.xls`, `.ods` or CSV through the same code path | SheetJS, knowingly |
| The file is small and a schema mapper would save you work | read-excel-file |
---
## Compatibility
"It opens in Excel" is not a claim you can make from a unit test. This library is developed against a committed
corpus of real files and an oracle that runs actual applications over the output — Excel itself included. This
page is what has been checked, against what, and how.
The current results live in [`research/05-compatibility-matrix.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/05-compatibility-matrix.md),
which is regenerated from the newest oracle run rather than hand-maintained.
### The readers in the oracle
| Reader | What it stands for |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `simple-excel` | This library through its public API — the same typed dump the test suite builds, so the two cannot disagree |
| `excel` | Microsoft Excel 16 (read-only licence) driven by AppleScript: opens the file, and fails on a repair prompt or a recovery-log entry |
| `libreoffice` | LibreOffice 26.2 headless — a re-save has to succeed |
| `validator` | Open XML SDK schema validation (`@xarsh/ooxml-validator`, Microsoft 365 profile) |
| `sheetjs` | SheetJS CE 0.20.3 with `cellDates: true, cellText: false` |
| `openpyxl` | Python openpyxl 3.1 with `rich_text=True` |
| `calamine` | Rust calamine via python-calamine with `skip_empty_area=False` |
| `office-kit` | `@office-kit/xlsx` document API (0.11.0 in the recorded runs; the repo now pins 0.23) |
| `office-kit-stream` | `@office-kit/xlsx` streaming API (`loadWorkbookStream` + `iterRows`) |
Google Sheets, Apple Numbers, Excel for Mac and Excel for the web (standing in for Excel on Windows) cannot be
automated locally, so they were checked by hand on 2026-09-12. All passed; the one expected failure is Drive
refusing to convert the zip64 variant (below).
### What the writer has passed
As of the `phase-b-writer` oracle run, the canonical workbook written by this library (in its inline-string,
shared-string and zip64 variants) is:
- **Open XML SDK validator: PASS** on all three. Most other writers fail it — ExcelJS and office-kit both emit
`CT_Font` children out of schema order (`EC-STYLES-FONT-ELEMENT-ORDER`).
- **Excel: PASS** on all three — opened with no repair prompt and no recovery-log entry.
- **LibreOffice: PASS** on all three (headless re-save succeeds).
- **openpyxl, calamine, SheetJS**: read the standard variants with the residual differences that are each
reader's own conventions, not the file's — SheetJS returns a serial rather than a `Date` for the fake
1900-02-29, collapses CRLF and decodes `_X0041_`; calamine disagrees about blank versus empty string; openpyxl
does not decode `_xHHHH_` escapes the way Excel does. Those are catalogued in
[`research/04-edge-case-catalog.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/04-edge-case-catalog.md)
and allowed per fixture, not silently ignored.
- **SheetJS on the zip64 variant: cannot open it**, and **Google Drive fails to convert it** to a Google Sheet — see
[the zip64 caveat](./streaming-and-memory.md#the-zip64-caveat). Neither affects files written with the default
`zip64: 'auto'`.
- **office-kit**: reads all three at 94% of the ground-truth cells (`phase-d-office-kit-on-ours`). The gap is its
own date handling — it puts a time-only value on 1899-12-31 rather than 1899-12-30 — plus the two writer
conventions below. Its _streaming_ API additionally leaves `_xHHHH_` escapes undecoded when the strings are
inline, so the inline variant reads at 88% there.
- **Two conventions of ours show up in every reader's dump**, and are deliberate: a pre-1900 date is written as
ISO text rather than a serial ([ADR-003](https://github.com/jetstreamapp/simple-excel/blob/main/research/adr/ADR-003-date-semantics.md)),
and a `Date` that falls in a DST gap serializes an hour later, because that instant does not exist locally.
**Google Sheets** imported `canonical.xlsx` and `canonical.inline.xlsx` cleanly and identically (manual check, 2026-09-12): every difference on screen was Sheets'
own behaviour — 15-significant-digit display of large numbers, its one-day offset for Excel serials below 61
(`EC-DATE-1900-LEAP-BUG`), seconds rounding under `hh:mm:ss` — with the stored values intact. The zip64 variant is
the exception: Drive refuses to convert it (`EC-ZIP64-SMALL`), which the default `zip64: 'auto'` never produces.
**Excel for Mac (365)** was also opened by hand (2026-09-12) with no repair prompt; its 15-digit display, its
zeroing of sub-precision subtractions and its `9.99999999999999E+307` formula cap all showed, with the stored values
intact in its re-save. **Apple Numbers 14.4** opened `canonical.xlsx` with one banner, "hidden sheets were made
visible" (Numbers has no hidden sheets); dates were right, time-only values sit on its 1899-12-31 marker day, and its
own limits apply — 15-significant-digit precision (`EC-NUMBERS-15-DIGIT-PRECISION`) and no representation of the max
double (`EC-NUMBERS-INF-VALUE`). **Excel for the web** (Microsoft's server-side engine, standing in for Excel on Windows, which the project cannot
run) re-downloaded both goldens with every value, escape, control character, hidden sheet and sheet name intact:
the re-downloads read back at 650/658, the same as the originals. Its only on-screen remark was the cosmetic
"text that looks like a formula" outline on the cells that deliberately hold `=`-prefixed text. The procedure and
results log live in
[`fixtures/golden/simple-excel/STEPS.md`](https://github.com/jetstreamapp/simple-excel/blob/main/fixtures/golden/simple-excel/STEPS.md).
### What the reader has passed
As of the `phase-d-simple-excel-*` oracle runs, this library reads every fixture in the corpus (the six Google
Sheets, Numbers and Excel-for-the-web re-exports of our goldens added since are covered by the corpus suite):
| Set | Result |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 12 hostile files | **12 of 12 rejected** with the classified error the manifest asks for — nothing crashed, hung or was accepted (the zip bomb is rejected against the read limits you configure; the default cap is a generous 1 GiB per part) |
| 15 structural edge cases | **13 PASS**; the two that differ are fixtures whose point is that a reader has to choose (a shared-string index out of range, which we read blank like Excel, and a 1904-epoch workbook) |
| 22 goldens | every remaining difference is a deviation the generator wrote into the file, each one allowed for that fixture by name in `test/corpus-policies.json` |
| our own 3 goldens | 650 of 658 cells; the 8 are the two writer conventions above |
| 3 Jetstream assets | both workbooks stream; the CSV asset is refused with `NOT_XLSX`, which is the designed behaviour for non-xlsx bytes |
Two examples of what "the generator wrote it that way" means: a file from ExcelJS (and anything re-saved from it,
including by Excel itself) carries dates built from UTC fields, so 68 cells in it mean something other than the
ground truth in every reader; openpyxl refuses control characters at write time, so those cells are empty in the
file for everybody. The per-category breakdown for each fixture is in
[`research/05-compatibility-matrix.md`](https://github.com/jetstreamapp/simple-excel/blob/main/research/05-compatibility-matrix.md).
### What the reader has been checked against
The corpus contains files produced by Excel 365 on macOS (including a Strict Open XML save and a 1904-date-system
workbook), Google Sheets, Apple Numbers 14.4, LibreOffice 26.2, Salesforce report exports (Apache POI), SheetJS,
ExcelJS, openpyxl, XlsxWriter, write-excel-file and `@office-kit/xlsx`, plus hand-built files for structural edge
cases and a set of deliberately hostile ones.
Every fixture has a recorded verdict, and the corpus suite fails if a fixture stops matching it. The hostile set
has to produce a _classified error_ — never a crash, never unbounded memory. Some of the things in there:
| Fixture family | What it is |
| ------------------------------------- | ------------------------------------------------ |
| `hostile-truncated-central-directory` | Archive cut before its directory |
| `hostile-duplicate-sheet-entries` | Two entries named `xl/worksheets/sheet1.xml` |
| `hostile-zip-bomb-30mb-sheet` | 30 MB of sheet XML deflated about 190:1 |
| `hostile-deeply-nested-rich-text` | 50,000 nested `` runs in one shared string |
| `hostile-crc-mismatch` | Entry bytes that do not match their recorded CRC |
| `hostile-biff8-xls-renamed` | A real `.xls` under an `.xlsx` name |
| `hostile-xlsb-renamed` | A real `.xlsb` under an `.xlsx` name |
| `hostile-not-a-zip` | SpreadsheetML 2003 XML with an `.xlsx` name |
Several well-known readers fail these. SheetJS happily parses the renamed `.xls` and `.xlsb` as if nothing were
wrong; the Open XML SDK validator crashes with a stack overflow on the deeply nested rich text.
### Which browsers it has been run in
The browser is the primary target, so the whole surface is exercised in a real browser rather than only in Node.
`npm run smoke:browsers` runs one page — write 20,000 mixed rows to `collectToBlob()`, read them back, do the
same in a module worker, read an Excel- and a Sheets-authored file, reject a password-protected file and a zip
bomb, stream into a `WritableStream` — and fails on any difference. It is described in
[Contributing](./contributing.md#browser-smoke-test).
| Browser | Version | Result |
| ------------------------------- | --------------------- | ---------------------------------------------------------------- |
| Chromium (Playwright, headless) | 153.0.8010.12 | 72/72 checks |
| Firefox (Playwright, headless) | 155.0 | 72/72 checks |
| WebKit (Playwright, headless) | 26.6 (Safari 26.6 UA) | 72/72 checks |
| Safari (macOS, by hand) | — | run `node test/browser/run.mjs --serve` and open the printed URL |
Run 2026-09-12 on macOS 26 (Apple silicon). Every check passed in all three: `CompressionStream('deflate-raw')`,
`DecompressionStream`, `WritableStream` and `Blob` are present everywhere, module workers load the library
unchanged, a `Blob` written in a worker transfers to the page by reference, and the reader returns the same
values for every trap cell — unicode, a CRLF kept as `\r\n`, a control character, an `_x0041_` literal, `-0`
(written and read as a plain zero, since Excel has no negative zero), `1e21`, `0.1 + 0.2`, `9007199254740991`,
a time-only `Date`, and a 40,000-character cell truncated to 32,767 with the `...(truncated)` suffix.
Two engine differences show up, neither of them a defect:
- **The compressed size differs per engine.** The same deterministic workbook is 1,654,492 bytes in Chromium,
1,712,097 in Firefox and 1,686,065 in WebKit, because the bytes come from each engine's own
`CompressionStream('deflate-raw')` tuning. The XML inside is identical. See
[the deterministic-output caveat](./writing.md#deterministic-output).
- **`performance.measureUserAgentSpecificMemory()` is Chromium-only**, and only in a document (it is not exposed
in a dedicated worker). Where it exists, writing 200,000 rows (a 15.7 MB file) in a worker leaves an
agent-cluster peak of about 4 MB, 1.7 MB of it the worker — the flat-memory claim, measured in a browser
rather than in Node.
To check real Safari, which cannot be automated here, run `node test/browser/run.mjs --serve` and open the
printed URL: the page shows the same table.
### How the checking works
#### The fixture corpus
`fixtures/manifest.json` records a sha256 and the provenance of every file — which application and version wrote
it, or which script generated it. `npm run fixtures:check` fails if any file drifts from its hash. A fixture also
carries the policies that apply to it (for example, that a golden is compared with 32,767-character truncation
applied) and the diff categories that are expected for that generator.
#### The typed dump and the ground truth
`fixtures/canonical/canonical.json` is the ground truth: the exact values a canonical workbook is supposed to
contain, including the awkward ones — control characters, `_x` escape literals, formula-looking text, a 17-digit
integer, the maximum double, dates in the 1900 leap window, a time-only value, a DST-gap datetime.
Every reader in the oracle dumps every fixture into the same typed shape, and `oracle/diff.mjs` compares it
against the ground truth, classifying each mismatch (`crlf-normalized`, `date-tz-offset-shift`,
`blank-vs-empty-string`, `escape-sequence-mangled`, …). Those categories are what the matrix's percentages are
made of, and each one maps to a catalog entry that explains whose fault it is.
#### Golden bytes
With `deterministic: true` the writer's output is reproducible: fixed zip timestamps and fixed `docProps` dates.
The golden-bytes suite pins sha256 hashes of that output, so an accidental change to the XML or the container is
caught immediately. Updating a golden is deliberate — it requires an environment flag and a validator and oracle
pass first.
#### The oracle run
`npm run oracle` drives the applications: Excel under AppleScript with repair detection, LibreOffice headless, a
Python virtual environment for openpyxl and calamine, the Open XML SDK validator, SheetJS and office-kit in
Node. It needs a Mac with Excel and LibreOffice installed, so it is a local pre-release gate rather than a CI
job; the results folder is committed with each run.
CI runs the subset that does not need those applications: the unit and corpus suites, the hostile suite, the
SheetJS parity suite, the validator, and the golden-byte pins.
### Two things worth knowing
:::note[The validator is a strictness gate, not a compatibility predictor]
Excel opens every golden in the corpus, including the ones the Open XML SDK validator rejects. The validator
catches real schema mistakes, which is why the writer is held to a clean result — but a validator failure
elsewhere does not mean Excel will refuse the file, and a validator pass does not mean it will accept it.
:::
:::note[Round-tripping through another application is not a repair]
Excel re-saving a workbook written by another library preserves that library's serials exactly — an ExcelJS
file's seven-hour date shift, an office-kit file's negative time serials
(`EC-EXCEL-RESAVE-PRESERVES-SHIFTED-SERIALS`). A bad writer's dates do not get fixed by opening and saving in
Excel. Whatever went into the file is what stays there.
:::
---
## Contributing
The repository is [jetstreamapp/simple-excel](https://github.com/jetstreamapp/simple-excel). `AGENTS.md` at the
root is the short version of this page for anyone (or anything) editing the code; `research/11-build-plan.md` is
the contract every module is built to, and is worth reading before changing anything in `src/`.
### Layout
| Path | What is in it |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/` | The library. Browser-first: no Node globals, no `node:` imports, no DOM beyond `TextEncoder`/`TextDecoder`, the compression streams and a duck-typed `Blob`. Zero runtime dependencies |
| `src/node/` | The `/node` entry — the only place `node:` imports are allowed |
| `src/**/__tests__/` | Unit tests, next to the module they cover |
| `test/` | Integration suites: corpus reads, golden bytes, round trips, hostile inputs, SheetJS parity, memory |
| `fixtures/` | The fixture corpus and `manifest.json` (sha256 and provenance for every file) |
| `oracle/` | The compatibility oracle: SheetJS, office-kit, openpyxl, calamine, the Open XML SDK validator, LibreOffice, Excel |
| `bench/` | The benchmark harness; this engine's adapters are `bench/engines/simple-excel.mjs` and `simple-excel-zlib.mjs` |
| `research/` | Format primer, library landscape, edge-case catalog, compatibility matrix, performance baseline, reference architecture, ADRs, build plan |
| `docs/` | This site — a Docusaurus project with its own `package-lock.json` |
Inside `src/`, the layering is strict: `zip/` and `compress/` know nothing about spreadsheets, `xml/` knows
nothing about SpreadsheetML, `sml/` owns the format rules, and `write/` and `read/` are thin facades over them.
### Commands
```bash
npm test # unit + integration (vitest)
npm run test:corpus # corpus, golden-bytes, hostile and parity suites only
npm run typecheck
npm run lint
npm run format # always run this after editing a source file
npm run build # esbuild bundles (esm + cjs, core + node entry) and declarations
npm run fixtures:check # fails on manifest sha256 drift
npm run smoke:browsers # the same page in Chromium, Firefox and WebKit
npm run build && npm run bench -- --engines sheetjs,simple-excel --sizes 1k,10k,100k
npm run oracle -- --tag kind:golden --label # local only
npm run research:regenerate # re-renders research 04, 05 and 06 from the committed JSON
```
`npm run oracle` needs tooling that is not on CI: a Python virtual environment for openpyxl and calamine,
LibreOffice, and Microsoft Excel with Automation permission granted. It is a local pre-release gate; its results
folder is committed with each run.
The docs site is a separate npm project:
```bash
cd docs && npm ci && npm run build
```
### Rules that are easy to get wrong
- **The catalog is the arbiter.** Escaping, date and container rules live in `research/02-format-primer.md` and
`research/04-edge-case-catalog.md`, and most entries were verified against Excel itself. When a test disagrees
with your intuition, the catalog wins.
- **Streaming is the design, not an option.** A writer pushes bytes to its sink as rows arrive and never holds a
worksheet or an unbounded shared-string table. A reader pulls rows through an async iterator.
- **Errors are classified.** Throw `XlsxError` with a `code`, never a bare `Error`. The encrypted-file message
must contain the words `password-protected`.
- **Hot-path discipline.** No per-cell object allocation, no regex per cell, no string concatenation past the
chunk boundary. Run `npm run bench` before and after any change to the writer or reader core.
- **Deterministic output.** With `deterministic: true` the bytes are reproducible; the golden-bytes suite depends
on it.
- Every exported symbol needs an explicit type (`isolatedDeclarations`), and there is no `any` in `src/`.
### Tests
Unit tests sit beside their module and are named for the catalog entry they prove (`EC-...`), so a failure points
straight at the rule it broke. The integration suites in `test/` are manifest-driven: every fixture has a
recorded verdict, and a reader change that moves one fails the build.
Golden bytes are sha256 pins of the writer's deterministic output. Updating them is deliberate —
`UPDATE_GOLDENS=1`, and only after a validator and oracle pass.
### Adding a fixture
A fixture is a real file plus its provenance. Put the file under `fixtures/` in the folder that matches its kind
(`golden/`, `edge/`, `hostile/`, `jetstream/`) and register it:
```bash
node fixtures/register.mjs golden/my-app/canonical.xlsx \
--id golden-canonical-my-app \
--generator "MyApp 1.2.3" \
--provenance "script:generators/canonical-my-app.mjs" \
--license MIT \
--tags kind:golden,generator:my-app \
--expected canonical/canonical.json
```
| Flag | Meaning |
| ------------------ | ------------------------------------------------------------------------------------ |
| `--id` | Stable identifier; re-registering the same id replaces the entry |
| `--generator` | Name and version of whatever produced the file |
| `--provenance` | Where it came from: `script:generators/x.mjs`, an application and date, a bug report |
| `--license` | SPDX licence of the file; default `MIT` |
| `--tags` | Comma-separated, e.g. `kind:hostile`, `generator:excel`, `policy:truncate-32767` |
| `--expected` | Ground-truth dump to compare reads against (usually `canonical/canonical.json`) |
| `--expected-error` | For a hostile fixture: the `XlsxErrorCode` reading it must produce |
| `--notes` | Anything a future reader needs to know |
Size and sha256 are always recomputed from disk, so `npm run fixtures:check` will tell you if the file later
drifts.
Then run `npm run test:corpus`. A new fixture with an `--expected` dump has to read at its recorded verdict, and
a new `kind:hostile` fixture has to produce its `--expected-error` classified error rather than a crash.
If the fixture demonstrates a behaviour that is not already in the catalog, add an entry to
`fixtures/edge-cases.json` and run `npm run research:regenerate` — `research/04` is rendered from that file, not
edited by hand. The same goes for `research/05` (rendered from the newest oracle run of each label) and
`research/06` (rendered from the newest `*-combined` benchmark run).
### Browser smoke test
Node covers the format rules; it cannot tell you whether the library works in the browsers the package claims to
support. `npm run smoke:browsers` builds `dist/`, serves the repository root cross-origin isolated, and runs one
page — `test/browser/smoke.html` — in Playwright's Chromium, Firefox and WebKit builds, printing a table per
browser and exiting non-zero on any failed check.
```bash
npm run smoke:browsers # build + all three browsers
node test/browser/run.mjs --browsers firefox,webkit # a subset, against the dist/ that is already built
node test/browser/run.mjs --serve # keep serving; open the URL by hand
npx playwright install firefox webkit # Firefox and WebKit are not installed by default
```
The page runs eight steps and evaluates every expectation in the page, against the values it wrote itself, so the
runner only ever sees pass/fail plus a readable detail:
| Step | What it proves |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Feature detection | `hasNativeDeflate()`, `CompressionStream`, `DecompressionStream`, `WritableStream`, `Blob` |
| Write (main thread) | 20,000 mixed rows to `collectToBlob()` — bold header, frozen row, autofilter, a date number format, a hidden second sheet, `deterministic: true` |
| Read back | `openWorkbook(blob)` (the `slice().arrayBuffer()` source path), then every sentinel cell: unicode, CRLF, a control character, an `_x0041_` literal, `-0`, `1e21`, dates, truncation at 32,767 |
| Module worker | the same write in a `type: 'module'` worker, the Blob transferred back and read on the page, plus one read of an `ArrayBuffer` transferred in |
| Real fixtures | an Excel-authored and a Google-Sheets-authored golden, fetched and read |
| Hostile input | `ENCRYPTED` for the password-protected file, `ZIP_BOMB` for the 30 MB sheet against an 8 MiB inflate cap |
| `fromWritableStream` | bytes streamed into a `WritableStream` equal a `collectToBytes()` run of the same workbook |
| Memory | 200,000 rows written in the worker while the page samples `performance.measureUserAgentSpecificMemory()` (Chromium only; recorded, never asserted) |
The files are `test/browser/smoke.html`, `smoke.js` (the page), `smoke.worker.js`, `smoke-workbook.js` (the
dataset and the read-back checks, shared by both threads) and `run.mjs` (the static server and the Playwright
driver). They are plain ESM served over HTTP and import `/dist/esm/index.mjs` by URL, so there is no bundler in
the loop and the page is exactly what a browser would load.
Two things the runner has to do deliberately: Chromium is launched with `channel: 'chromium'` because
Playwright's default headless shell rejects `measureUserAgentSpecificMemory()` with a `SecurityError`, and the
server sends `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` so the page is cross-origin isolated
(the same reason `bench/lib/chrome.mjs` does).
#### Checking real Safari
Playwright's WebKit is not Safari. For the real thing:
```bash
node test/browser/run.mjs --serve
```
then open the printed URL in Safari. The page renders the same table it hands the runner, and
`window.__smokeResults` in the Web Inspector console holds the full object. Record the Safari version and the
outcome in the "Browsers" section of `research/05-compatibility-matrix.md`.
### Benchmarks
`npm run bench` runs each engine in a fresh Node process with `--expose-gc`, one warm-up plus three timed runs,
reporting median wall time, peak RSS above baseline, output size and first-byte latency. The datasets are seeded
and shaped like real exports: `mixed` (the Salesforce record shape), `wide` (107 columns), `strings-unique` (the
shared-string worst case) and `numeric` (the XML-generation floor).
Add an engine by writing an adapter in `bench/engines/`; this library's are `bench/engines/simple-excel.mjs`
(platform `CompressionStream`) and `bench/engines/simple-excel-zlib.mjs` (`nodeDeflater(1)`), which share
`bench/engines/_simple-excel.mjs` and import `dist/`, so `npm run build` has to have run.
### Commits and releases
Conventional commit messages. `CHANGELOG.md`'s `[Unreleased]` headings decide the version bump —
`### Breaking Changes` major, `### Added`/`### Deprecated` minor, `### Changed`/`### Removed`/`### Fixed`/
`### Security` patch — so keep it accurate as you go. Releases are cut from `main` by CI.
A pre-commit hook blocks commits that fail `npm run format:check` or `npm run lint`.
---
## Dates and values
Spreadsheet cells hold fewer types than you would expect, and the mapping to JavaScript has several traps that
predate everyone reading this. This page is the whole contract.
### The value model
A cell reads back as:
```ts
type CellValue = string | number | boolean | Date | null;
```
and accepts, on write:
```ts
type CellInput = CellValue | CellError | bigint | undefined;
```
| Write input | In the file | Reads back as |
| ----------------------------- | ------------------------------------------------------ | --------------------------------------------- |
| `string` | Shared or inline string, `_xHHHH_`-escaped | `string` |
| `number` (finite) | `` with the shortest round-trip form | `number` |
| `number` (`NaN`, `±Infinity`) | The error cell `#NUM!` | `'#NUM!'` (or a `CellError`, per `errors`) |
| `boolean` | `t="b"` with `1` / `0` | `boolean` |
| `Date` (valid) | A serial under a date number format | `Date` (or a number with `dates: 'serial'`) |
| `Date` (invalid) | Nothing is written at all | absent |
| `null` / `undefined` | No `` element, unless a non-default style was given | `null` in array mode, `defval` in object mode |
| `bigint` | A number while `\|value\| <= 2^53`, otherwise text | `number` or `string` |
| `{ error: '#N/A' }` | `t="e"` — a real error cell | `'#N/A'`, `{ error: '#N/A' }` or `null` |
There is no "empty string versus blank" ambiguity to resolve on write: `''` writes an empty string cell, `null`
writes no cell. On read, object mode collapses both to `defval` unless you set `defval: null`
(`EC-BLANK-VS-EMPTY-STRING`).
### Dates
#### Serials
Excel does not store dates. It stores a **number** — whole days since an epoch, with the time of day as the
fraction — and a **number format** on the cell that makes it render as a date. A cell is a date only because its
format says so (`EC-DATE-DETECTION-VIA-NUMFMT`), which is why a reader that skips the style table hands you
`45361.39583` instead of a `Date`.
simple-excel resolves the format for you: `cellXfs[s] → numFmtId → is this a date format`, using the built-in id
table (14–22, 45–47, plus the locale ranges) and, for custom codes, a scan for `y`, `m`, `d`, `h`, `s` or `AM/PM`
outside quoted and bracketed sections. `General` and `@` are never dates.
All of the serial arithmetic is integer arithmetic on the proleptic Gregorian calendar. A time zone never reaches
it.
#### `dates` on write
```ts
const workbook = createWorkbookWriter(sink, { dates: 'local' });
```
A JS `Date` is an instant, but a spreadsheet cell holds a wall clock. `dates` says which fields of the `Date` are
the wall clock you meant:
| Value | The serial is built from |
| ------------------- | ----------------------------------------------------------- |
| `'local'` (default) | `getFullYear()`, `getMonth()`, `getDate()`, `getHours()`, … |
| `'utc'` | `getUTCFullYear()`, `getUTCMonth()`, `getUTCHours()`, … |
The default is "what you see in the debugger is what you see in Excel". Pick `'utc'` when your `Date`s were
constructed from UTC fields in the first place.
:::caution
Getting this wrong is silent and ugly. A library that takes UTC fields from a local-midnight `Date` puts the host
offset into the file as a time fraction — Excel shows `7:00 AM` for a date you meant as midnight — and turns
time-only values into negative serials, which Excel renders as `########`
(`EC-DATE-SERIAL-EPOCH-UTC-FIELDS`). That is a real observed behaviour of another library, not a hypothetical.
:::
#### `dates` on read
```ts
const workbook = await openWorkbook(file, { dates: 'local' });
```
| Value | A date cell becomes |
| ------------------- | ----------------------------------------------------------------------------- |
| `'local'` (default) | `new Date(y, m - 1, d, h, mi, s, ms)` from the serial's wall-clock components |
| `'utc'` | `new Date(Date.UTC(...))` from the same components |
| `'serial'` | The raw number. Never a `Date` |
`'local'` matches what SheetJS's `sheet_to_json` produces, so switching libraries does not shift every timestamp
in an application. `'serial'` is the honest choice when you are going to reformat the value yourself and do not
want a `Date` inventing a time zone on the way.
#### The 1900 leap-year bug
Excel believes 1900 was a leap year. It was not. Serial **60 is the fictional 1900-02-29**, so every serial below
61 is one day away from a correct calendar.
| Serial | Excel shows | simple-excel reads as |
| ------ | ----------- | --------------------- |
| 1 | 1900-01-01 | 1900-01-01 |
| 59 | 1900-02-28 | 1900-02-28 |
| 60 | 1900-02-29 | 1900-03-01 |
| 61 | 1900-03-01 | 1900-03-01 |
Serial 60 has no real date, so it maps to 1900-03-01 — the same value serial 61 gives — which is what most other
readers do. Writing is the mirror: 1900-01-01 through 1900-02-28 become serials 1–59, and only 1900-03-01 onward
lines up with the raw day count. Real dates are never shifted.
Several well-known writers get this off by one and write 1900-02-28 as serial 60, so Excel shows it as
1900-02-29 or 1900-03-01 (`EC-DATE-LEAP-WRITER-OFF-BY-ONE`). If your data lives in 1900 you should be suspicious
of every tool in the chain.
#### Before 1900
Serial 0 is Excel's `1/0/1900` placeholder and negative serials display as `########`; there is no representation
for 1899 or earlier in the 1900 system. A `Date` before the epoch is therefore **written as ISO text**
(`1899-12-31T12:00:00`) rather than as a number that would display as garbage (`EC-DATE-PRE-1900`). The value
stays readable and obviously not a date cell.
On read, a **negative serial in a date-formatted cell comes back as the raw number**, exactly what Excel shows as
`########`. So does a serial past 9999-12-31, Excel's last date: a phone number typed into a date-formatted column
reads as the number, not as a year-10000 or invalid `Date` (`EC-DATE-SERIAL-OVER-9999`). The writer mirrors it:
a `Date` after 9999 is written as ISO text (`10000-01-01T00:00:00`), like one before 1900. SheetJS instead re-derives a time of day from it, which matters for files SheetJS itself wrote: its
time-only cells are negative serials (`EC-DATE-TIME-ONLY-NEGATIVE-SERIAL`), and they read back here as numbers.
`Date`s before roughly 1901 also carry local-mean-time offsets in JavaScript — Los Angeles is `-7:52:58`, not
`-8:00` — so local-field conversions of such dates are off by minutes and seconds
(`EC-DATE-HISTORICAL-TZ-OFFSET`). If you work with pre-1901 dates, use `dates: 'utc'` on both sides or carry them
as text.
#### Time-only values
A duration or a clock time with no date is a serial below 1: `0.5` is noon. On read, those cells come back as a
`Date` on the marker day **1899-12-30**, because a `Date` has to have a date part:
```ts
// a cell holding 0.524268391204 (12:34:56.789)
value.getHours(); // 12
value.getFullYear(); // 1899 — the marker, not data
```
To write one, build a `Date` on that same marker day and the writer emits the fraction alone:
```ts
await sheet.writeRow([new Date(1899, 11, 30, 12, 34, 56, 789)]); // serial 0.5242…
```
:::caution
Readers disagree about the marker day — 1899-12-30 here and in office-kit, 1899-12-31 in SheetJS, a
`datetime.time` in openpyxl (`EC-TIME-ONLY-HAS-DATE-PART`). If you round-trip time-only values through another
tool, compare the time fields, never the whole `Date`.
:::
#### The 1904 date system
Workbooks saved by old Mac Excel use a different epoch, shifted by 1,462 days. The flag lives in the workbook
part; the reader honours it everywhere and exposes it:
```ts
workbook.date1904; // boolean
```
To write one, pass `date1904: true`. There is no reason to unless you are matching an existing file
(`EC-DATE-1904`).
#### The DST gap
Asking JavaScript for a local `Date` at a wall clock that does not exist — 02:30 on a US spring-forward Sunday —
silently gives you 03:30. That shift happens in `new Date(...)`, before any library sees the value
(`EC-DATE-DST-GAP`). Nothing downstream can undo it. If you are generating timestamps from wall-clock components,
either use `Date.UTC` and `dates: 'utc'`, or accept the hour.
### Strings
#### `_xHHHH_` escapes
XML 1.0 cannot carry control characters, and real data has them: text pasted out of another system, long-text
fields from a CRM. The format's answer is `ST_Xstring`, which spells an illegal character as `_x0001_`.
simple-excel writes those escapes and decodes them on read. Two consequences worth knowing:
- **A literal `_x0041_` in your data** would be read back as `A` if written as-is, so the writer escapes the
leading underscore: `_x005F_x0041_`. Overlapping runs are escaped too, which several writers get wrong
(`EC-XML-ESCAPE-LITERAL`, `EC-XML-ESCAPE-OVERLAP`).
- **Only lowercase `_x` + four hex digits + `_` is an escape.** `_X0041_` with a capital X is literal text and
stays literal. SheetJS decodes it anyway (`EC-XML-ESCAPE-CASE`), so a value that survives a round trip here may
not survive one through SheetJS.
Leading and trailing whitespace is preserved with `xml:space="preserve"`; without it some readers trim it away.
#### CRLF
A raw carriage return inside a text node is collapsed to a line feed by XML's own line-end normalization — the
`\r` is simply gone, in every reader. So the writer escapes CR as `_x000D_` (`EC-CRLF-NORMALIZED`), and the reader
decodes it without normalizing. `'a\r\nb'` written here reads back as `'a\r\nb'`.
Several other writers emit the raw CR and lose it, and SheetJS collapses `\r\n` to `\n` on read even when the
escape is present. If line endings matter to you, that is where they go missing.
#### The 32,767-character limit
Excel refuses a cell longer than 32,767 characters; a file containing one opens with a repair prompt. The writer
applies a policy instead: truncate with a visible suffix (the default, `'...(truncated)'`, counted and reported)
or throw `CELL_TOO_LONG`. See [Writing](./writing.md#long-cells-the-32767-character-limit).
Reading is not affected — a longer cell produced by some other tool is returned whole.
#### Rich text
Rich text is a sequence of styled runs inside one cell. On read the runs are flattened to plain text, which is
what every reader does and what a data pipeline wants. Writing rich text is not supported.
### Numbers
Numbers are written with JavaScript's shortest round-trip form (up to 17 significant digits), which Excel parses
exactly, with the exponent marker uppercased to match Excel's own output. `-0` is written as `0`, because the
format has no negative zero (`EC-NUM-NEGATIVE-ZERO`).
:::tip[Excel's display is not Excel's storage]
Excel **displays** and rounds to 15 significant digits: `9007199254740991` shows as `9.007199254741E+15`, and
editing that cell in Excel will commit the rounded value. The full-precision number is still in the file, and
this library returns what is stored (`EC-NUM-15-SIGNIFICANT-DIGITS`). If exact large integers matter, write them
as text — which `bigint` above `2^53` does for you.
:::
`NaN` and the infinities have no valid lexical form in the format. Rather than write something Excel will repair,
they become the error cell `#NUM!`. (Numbers.app does emit `inf `; Excel repairs those files and openpyxl
crashes on them — `EC-NUMBERS-INF-VALUE`.)
### Booleans
`true` and `false` are written as `t="b"` cells with `1` and `0`, and read back as booleans. A file that stored
`TRUE`/`FALSE` as text — a CSV import will do this — reads back as the strings, because that is what it is.
### Error cells
```ts
type CellErrorCode = '#NULL!' | '#DIV/0!' | '#VALUE!' | '#REF!' | '#NAME?' | '#NUM!' | '#N/A' | '#GETTING_DATA';
```
Write one by passing `{ error: '#N/A' }`; it becomes a genuine `t="e"` cell, not the text `#N/A`. Writers without
an error API write the text instead, and no reader can then tell the two apart (`EC-ERROR-CELL-AS-TEXT`).
On read, `OpenOptions.errors` decides the shape:
| `errors` | An error cell becomes | Row type |
| -------------------- | --------------------- | ---------------------------------------- |
| `'string'` (default) | `'#N/A'` | `CellValue` |
| `'object'` | `{ error: '#N/A' }` | `CellValue \| CellError` (typed for you) |
| `'null'` | `null` | `CellValue` |
`'string'` is the default because it is what a user sees in Excel and what most pipelines want to write straight
into a CSV. Use `'object'` when you need to distinguish an error from a string that happens to read `#N/A`.
`head()` is the exception: it always reports both the text and the code on its `RawCell`, whatever the workbook
was opened with.
### Formula-looking text
Text beginning with `=` is written as text and never as a formula. Inferring one is both an injection surface
(`=cmd|calc`) and a way to make Excel repair the file — openpyxl and LibreOffice's CSV import both do it, and both
produce files Excel logs a repair for (`EC-FORMULA-LIKE-TEXT-WRITTEN-AS-FORMULA`). If you export user data to CSV
elsewhere in your application, that path needs the same hygiene; this library only controls the xlsx one.
---
## Errors
Every failure the library raises is an `XlsxError` with a stable `code`. Branch on the code; show the message, or
your own copy.
```ts
import { isXlsxError } from '@jetstreamapp/simple-excel';
try {
const workbook = await openWorkbook(file);
} catch (error) {
if (isXlsxError(error)) {
switch (error.code) {
case 'ENCRYPTED':
return showPasswordHelp(error.message);
case 'LEGACY_XLS':
case 'XLSB':
case 'ODS':
return showConvertHelp(error.message);
default:
return showGenericFailure(error.message);
}
}
throw error;
}
```
```ts
class XlsxError extends Error {
readonly code: XlsxErrorCode;
readonly detail: Readonly> | undefined;
}
function isXlsxError(value: unknown): value is XlsxError;
```
`detail` carries whatever is useful for that code — the part name, the limit that was hit, the sniffed format,
the sheet names that do exist. It is for logs and for your own messaging, not a stable API.
`isXlsxError` is a duck-typed check (`instanceof`, or `name === 'XlsxError'`), so it still works across realms —
an error that crossed a worker boundary or came out of a bundled copy of the library.
### Messages are for end users
The messages name the fix, in the words of somebody who has a spreadsheet open, not a stack trace:
> This workbook is password-protected. Remove the password in Excel (File > Info > Protect Workbook) and save it
> again as .xlsx.
> This is an Excel Binary Workbook (.xlsb). Open it in Excel and save it as .xlsx.
> A cell holds 40000 characters, more than the 32767 Excel allows. Shorten it or write with
> `cellOverflow: 'truncate'`.
You can show them as they are. If you localize, branch on `code`.
### Input format errors
These fire when the bytes are not a modern xlsx. The format is sniffed before any parsing, so the user is told
what the file actually is instead of seeing a zip or XML failure.
| Code | Fires when | Message intent |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `ENCRYPTED` | A CFB container holding an `EncryptedPackage` stream — a password-protected workbook | Remove the password in Excel and save again as `.xlsx` |
| `LEGACY_XLS` | A CFB container without encryption: a BIFF `.xls` workbook | It is a legacy Excel 97-2003 file; re-save it as `.xlsx` |
| `XLSB` | A zip whose main part is binary (`.xlsb`) | It is an Excel Binary Workbook; re-save it as `.xlsx` |
| `ODS` | A zip with an OpenDocument `mimetype` entry | It is an OpenDocument spreadsheet; open it and save as `.xlsx` |
| `NOT_XLSX` | Anything else: CSV or other text, XML, HTML, `.docx`, `.pptx`, an empty file, a zip with no workbook part, or an input that is not bytes at all (a string, `null`, a plain object) | It is not a spreadsheet, and which of those it looks like |
`NOT_XLSX` carries `detail.format` — `'text'`, `'xml'`, `'html'`, `'empty'`, `'docx'`, `'pptx'`, `'zip'`,
`'unknown'` — so an application that has its own CSV parser can route on it rather than showing an error at all.
[`sniff()`](./reading.md#sniffing-before-you-open) lets you make that decision before calling `openWorkbook`.
:::info[The `password-protected` contract]
The `ENCRYPTED` message **always contains the literal words `password-protected`**. That is a deliberate,
tested contract, because applications (Jetstream among them) match on it. It will not change without a major
version.
:::
#### Why legacy formats are errors, not silent parses
SheetJS quietly parses `.xls`, `.ods`, SpreadsheetML 2003 and raw CSV bytes when they reach `XLSX.read` — which
is convenient right up to the point where a user uploads a `.xls`, gets different type coercion than everyone
else, and nobody can tell why. simple-excel supports `.xlsx` and `.xlsm` only (Strict OOXML is read-only) and
names everything else (ADR-007). Other libraries do not all get this right: office-kit fails on an `.xlsb` deep
inside its XML parser (its 0.11 release also reported every CFB file, legacy `.xls` included, as encrypted; 0.23
names both correctly).
### Container errors
| Code | Fires when |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `ZIP_TRUNCATED` | The central directory or an entry runs past the end of the input, or a deflate stream ends early |
| `ZIP_BOMB` | A part declares, or inflates to, more than `limits.maxInflatedBytes`, or inflates more than 4 KB past the size its header declares |
| `ZIP_DUPLICATE_ENTRY` | Two central-directory entries normalize to the same name |
| `ZIP_UNSUPPORTED` | An entry uses a compression method other than stored or deflate, or is itself encrypted |
| `ZIP_CRC_MISMATCH` | An entry's bytes do not match the CRC-32 recorded for it |
`ZIP_DUPLICATE_ENTRY` is a deliberate rejection rather than a guess. Readers disagree about duplicates — SheetJS
takes the first entry, office-kit, openpyxl and calamine take the last (`EC-ZIP-DUPLICATE-ENTRIES`) — so the same
file means different things depending on who opens it. That is exactly the shape of a smuggling bug, so it fails.
`ZIP_CRC_MISMATCH` is also stricter than some readers: SheetJS and office-kit do not check CRCs at all
(`EC-ZIP-CRC-MISMATCH`). A stream raises it after its last chunk, so a partially consumed iteration may have
yielded rows before the failure.
### XML errors
| Code | Fires when |
| --------------- | ---------------------------------------------------------- |
| `XML_DOCTYPE` | A `= 20.12`.
:::caution
Node has had `CompressionStream` since 18, but it only accepts the `deflate-raw` format from **20.12** (21.2 on the
21 line), which is why that is the floor. On an older Node the writer falls back to stored (uncompressed) parts, and
`openWorkbook` fails with `UNSUPPORTED_ENVIRONMENT`, because `DecompressionStream` rejects `deflate-raw` too. Pass
`nodeDeflater()` from the `/node` entry to keep written files compressed; it uses zlib. `nodeDeflater()` is a good
default for server-side writes anyway, because it lets you pick a compression level.
:::
#### When `CompressionStream` is missing
`hasNativeDeflate()` tells you whether `CompressionStream('deflate-raw')` works here; the class existing is not
enough, because a Node before 20.12 has it but rejects `deflate-raw`. If it does not work, the writer stores every
part uncompressed (zip method 0) instead of failing. The file is a valid `.xlsx` that every reader opens; it is
just several times larger. In Node you can always get real compression by passing the zlib-backed deflater:
```ts
import { createWorkbookWriter, hasNativeDeflate } from '@jetstreamapp/simple-excel';
import { nodeDeflater, toFile } from '@jetstreamapp/simple-excel/node';
const workbook = createWorkbookWriter(toFile('out.xlsx'), {
deflater: hasNativeDeflate() ? undefined : nodeDeflater(),
});
```
`collectToBlob()` throws `UNSUPPORTED_ENVIRONMENT` where there is no `Blob`; use `collectToBytes()` or a stream
sink there.
### Verifying the install
```ts
import { collectToBytes, createWorkbookWriter } from '@jetstreamapp/simple-excel';
const sink = collectToBytes();
const workbook = createWorkbookWriter(sink);
const sheet = workbook.addSheet('Sheet1', { header: ['A', 'B'] });
await sheet.writeRow([1, 2]);
await sheet.close();
await workbook.close();
const bytes = sink.result(); // a Uint8Array holding a complete .xlsx
```
`sink.result()` on `collectToBytes` is synchronous and only valid after `close()` has resolved; on
`collectToBlob` it returns a promise.
---
## simple-excel
`@jetstreamapp/simple-excel` reads and writes `.xlsx` files in browsers and in Node, with no runtime dependencies.
It writes a workbook by pushing rows into a sink as they arrive and reads one by pulling rows through an async
iterator. Nothing accumulates: there is no in-memory worksheet, no whole-sheet XML string and no unbounded shared
string table. A one-million-row export costs about as much memory as a one-thousand-row export — measured, that is
77 MB of JS heap growth to write 1,000,000 × 20 columns of Salesforce-shaped data, against 45 MB for 100,000 rows
of the same, where SheetJS throws `RangeError: Invalid string length`. (Those runs used the optional shared-string
table, before inline strings became the default.) The whole browser entry is 29.0 KB brotli with no dependencies.
[Streaming and memory](./streaming-and-memory.md) has the numbers and the method.
:::info
The library is at 0.1 on npm, and the API may still shift before 1.0. `CHANGELOG.md` records every release.
:::
### What it is for
Exporting and importing tabular data — query results, record loads, report extracts — in files that have to open
cleanly in Excel and come back out carrying the same values.
```ts
import { collectToBlob, createWorkbookWriter } from '@jetstreamapp/simple-excel';
const sink = collectToBlob();
const workbook = createWorkbookWriter(sink);
const sheet = workbook.addSheet('Accounts', { header: ['Id', 'Name', 'Created'], freeze: { rows: 1 } });
await sheet.writeRow(['001xx000003DGb2AAG', 'Acme', new Date(2024, 2, 10)]);
await sheet.close();
await workbook.close();
const blob = await sink.result();
```
```ts
import { openWorkbook } from '@jetstreamapp/simple-excel';
const workbook = await openWorkbook(file);
for await (const row of workbook.sheet(0).rows({ mode: 'object' })) {
console.log(row.Id, row.Name);
}
await workbook.close();
```
### When to pick it
Pick simple-excel when:
- the file is large, or you cannot predict how large it will be;
- it has to open in Excel without a repair prompt, and in Google Sheets, LibreOffice and Numbers too;
- you are running in a browser tab, a Web Worker or an extension service worker, where a multi-gigabyte heap spike
ends the session;
- you want to know exactly what happens to a date, a 40,000-character cell or a control character, because the
answer is written down;
- adding a dependency tree to a code path that touches untrusted uploads is not acceptable.
Pick something else when:
- you need to edit an existing workbook and preserve the parts you did not touch;
- you need formulas, charts, pivot tables, conditional formatting or data validation;
- you need to read `.xls`, `.xlsb` or `.ods` (simple-excel detects each and says what it is, but does not parse it);
- you need Excel's number formats rendered to display text.
[Comparison](./comparison.md) has the library-by-library reasoning.
### How the pieces fit
| Concept | What it is |
| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `ByteSink` | Where written bytes go: a Blob, a byte array, a `WritableStream`, a file. See [Writing](./writing.md) |
| `WorkbookWriter` | Owns the zip, the styles and, under `strings: 'auto'`, the shared strings; hands out one `SheetWriter` at a time |
| `SourceInput` | Where read bytes come from: `ArrayBuffer`, `Uint8Array`, `Blob`/`File`, or a `RandomAccessSource` |
| `Workbook` / `Sheet` | Lists sheets without touching sheet XML; streams rows on demand. See [Reading](./reading.md) |
| `XlsxError` | Every failure, carrying a stable `code`. See [Errors](./errors.md) |
### Where to go next
- [Install](./install.md) — package, entry points, environment requirements
- [Writing](./writing.md) — sinks, sheets, rows, styles, options
- [Reading](./reading.md) — opening, listing sheets, array and object rows
- [Dates and values](./dates-and-values.md) — the value model and its edge cases
- [Errors](./errors.md) — every error code and when it fires
- [Streaming and memory](./streaming-and-memory.md) — how both sides stay flat, and what to do with very large files
- [Compatibility](./compatibility.md) — what was verified, against what
- [Node](./node.md) — the `/node` entry
- [Comparison](./comparison.md) — the longer version of the README table
- [Contributing](./contributing.md) — repo layout, commands, fixtures
---
## Node
Everything in the core entry works in Node. The `/node` entry adds the things that need `node:` modules: a
file-backed source, two file-backed sinks, and a zlib deflater with a compression level.
```ts
import { createWorkbookWriter, fromFile, nodeDeflater, openWorkbook, toFile, toWritable } from '@jetstreamapp/simple-excel/node';
```
The `/node` entry re-exports the whole core surface, so you never need both imports.
### `fromFile(path)`
```ts
function fromFile(path: string): Promise;
```
A `RandomAccessSource` over a file, using positional reads on a `FileHandle`. The reader pulls only the ranges it
needs — the central directory, the package parts, then each sheet as it is iterated — so opening a 2 GB workbook
does not read 2 GB.
```ts
const source = await fromFile('/tmp/upload.xlsx');
const workbook = await openWorkbook(source);
for await (const row of workbook.sheet(0).rows({ mode: 'object' })) {
await handle(row);
}
await workbook.close(); // closes the file handle
```
`workbook.close()` closes the source. If you never open a workbook over it, call `source.close()` yourself.
### `toFile(path)`
```ts
function toFile(path: string): ByteSink;
```
Streams to a file through `fs.createWriteStream`, honouring back-pressure — a slow disk slows the row loop rather
than filling memory. `workbook.close()` ends the stream and resolves once the file is flushed.
```ts
const workbook = createWorkbookWriter(toFile('accounts.xlsx'));
const sheet = workbook.addSheet('Accounts', { header: ['Id', 'Name'] });
await sheet.writeRows(rows);
await sheet.close();
await workbook.close();
```
### `toWritable(writable)`
```ts
function toWritable(writable: NodeWritableLike): ByteSink;
```
The same sink over any Node `Writable`: an HTTP response, a socket, a `PassThrough`, a `gzip` stream. The
parameter is structurally typed (`write`, `end`, `destroy`, `once`, `off`), so the core types stay free of Node
types and you can pass a test double.
```ts
app.get('/export.xlsx', async (request, response) => {
response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
response.setHeader('Content-Disposition', 'attachment; filename="export.xlsx"');
const workbook = createWorkbookWriter(toWritable(response), { deflater: nodeDeflater(1) });
const sheet = workbook.addSheet('Export', { header });
try {
for await (const record of query()) {
await sheet.writeRow(toRow(record));
}
await sheet.close();
await workbook.close();
} catch (error) {
await workbook.abort(error);
throw error;
}
});
```
Streaming a response like this means the browser starts downloading immediately and the server never holds the
file. A stream error is sticky: the sink remembers it and every later call rejects with the original reason, so
you find out at the first `writeRow` rather than at the end.
### `nodeDeflater(level?)`
```ts
function nodeDeflater(level?: number): DeflaterFactory;
```
A deflater over `zlib.createDeflateRaw`, so you get a compression level — which the platform `CompressionStream`
does not expose (ADR-005). Levels are zlib's 1–9.
```ts
const fast = createWorkbookWriter(toFile('big.xlsx'), { deflater: nodeDeflater(1) });
const small = createWorkbookWriter(toFile('small.xlsx'), { deflater: nodeDeflater(9) });
```
Level 1 is roughly 2–3× faster than the default on xlsx-shaped XML, which is highly repetitive and compresses
well even at the low setting. For a large export that is usually the right trade.
`nodeDeflater` also honours `compression: 'store'`: with that option the factory returns a pass-through and the
level is ignored.
### When you need it
The core entry works in Node without this one — `collectToBytes()` and `fromWritableStream(Writable.toWeb(...))`
both do the job. Reach for `/node` when you want:
| Want | Use |
| --------------------------------------------- | --------------------- |
| To read a file without loading it into memory | `fromFile` |
| To write a file without buffering it | `toFile` |
| To stream into a response or socket | `toWritable` |
| A compression level | `nodeDeflater(level)` |
### Node version
`package.json` declares `engines.node >= 20.12`, because Node's `CompressionStream` and `DecompressionStream` only
accept the `deflate-raw` format from 20.12 (21.2 on the 21 line). On an older Node the writer detects that and
falls back to stored (uncompressed) parts, and `openWorkbook` fails with `UNSUPPORTED_ENVIRONMENT` because it cannot
inflate entries. `nodeDeflater()`, which goes through zlib, keeps written files compressed anywhere, and it is the
right default for server-side writes regardless, since it is the only way to choose a compression level.
---
## Reading
```ts
import { openWorkbook } from '@jetstreamapp/simple-excel';
const workbook = await openWorkbook(file);
for (const { name, index, kind, hidden } of workbook.sheets) {
console.log(index, name, kind, hidden);
}
for await (const row of workbook.sheet('Accounts').rows({ mode: 'object' })) {
console.log(row.Id, row.Name);
}
await workbook.close();
```
Opening a workbook reads the zip central directory and the small package parts (content types, relationships,
`workbook.xml`, `styles.xml`). It does **not** touch sheet XML, so listing sheets is cheap on a 200 MB file. The
shared-string table is parsed lazily — on the first `t="s"` cell of the first sheet that has one — and sheet
contents inflate only while you iterate. Dropping out of the loop cancels the underlying stream.
### `openWorkbook(input, options?)`
```ts
function openWorkbook(input: SourceInput, options?: OpenOptions): Promise;
function openWorkbook(input: SourceInput, options: OpenOptions & { errors: 'object' }): Promise>;
```
Before anything else the input is sniffed, so a file that is not a modern xlsx fails with a classified error
naming what it actually is — `ENCRYPTED`, `LEGACY_XLS`, `XLSB`, `ODS` or `NOT_XLSX` — instead of a zip or XML
parse error three layers down. A `.docx` and a `.pptx` are named too. See [Errors](./errors.md).
#### Sources
`SourceInput` is `ArrayBuffer | SharedArrayBuffer | ArrayBufferView | Uint8Array | Blob | RandomAccessSource`.
| Input | How it is read |
| ------------------------------- | --------------------------------------------------------------------------------------- |
| `Uint8Array` or any typed array | Zero-copy subarray views over the same bytes; nothing is duplicated (`Buffer` included) |
| `DataView` | Same, over the bytes it views |
| `ArrayBuffer` | Same, over a view of the buffer |
| `SharedArrayBuffer` | Each range is copied as it is read, because the decompressor refuses shared memory |
| `Blob` / `File` | `slice().arrayBuffer()` per range, so the bytes never all have to be resident |
| `RandomAccessSource` | Anything with `{ size, read(offset, length) }` — including Node's `fromFile()` |
The checks work across realms, so a buffer from an iframe, a worker or an Electron bridge is accepted. `Blob` is
duck-typed: any object with `size`, `slice` and `arrayBuffer` works, which matters in test environments and in
Electron. Anything else, a string included, is refused with `NOT_XLSX` (`EC-INPUT-TYPE`); a binary string from an
old API has to become bytes first.
`sourceFrom(input)` is exported if you want the `RandomAccessSource` yourself, and `RandomAccessSource` is the
extension point for a source that is not bytes in hand:
```ts
import { sourceFrom } from '@jetstreamapp/simple-excel';
import { fromFile } from '@jetstreamapp/simple-excel/node';
const fromBytes = sourceFrom(uint8Array);
const fromDisk = await fromFile('/tmp/upload.xlsx'); // already a RandomAccessSource
```
:::caution
The reader needs random access, because a zip's directory is at the end of the file. A pipe or an HTTP body
cannot be read incrementally — buffer it, save it to disk, or expose it as a `RandomAccessSource` backed by
ranged requests.
:::
### Options
```ts
const workbook = await openWorkbook(file, {
dates: 'local',
errors: 'string',
limits: { maxInflatedBytes: 256 * 1024 * 1024 },
signal: controller.signal,
onWarning: warning => console.warn(warning.code, warning.sheet, warning.ref),
});
```
| Option | Values | Default | Meaning |
| ----------- | -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| `dates` | `'local' \| 'utc' \| 'serial'` | `'local'` | Which `Date` fields carry the wall clock, or `'serial'` for the raw number. See [Dates and values](./dates-and-values.md) |
| `errors` | `'string' \| 'object' \| 'null'` | `'string'` | How an error cell comes back: its text (`'#N/A'`), a `{ error }` object, or `null` |
| `limits` | `ReadLimits` | see below | Caps that make a hostile file fail fast instead of exhausting the host |
| `signal` | `AbortSignal` | — | Checked at open and between chunks; an aborted signal ends the read with `ABORTED` |
| `onWarning` | `(warning: ReadWarning) => void` | — | Told about problems the reader repaired instead of rejecting; see below |
`errors: 'object'` also changes the static type: the workbook becomes `Workbook` and row
values widen to include `{ error: '#N/A' }`. The `error` field holds whatever error literal the file has. Newer Excel
errors such as `#SPILL!` or `#CALC!` come through as they are, although `CellErrorCode` only lists the standard
ones, so a `switch` over it needs a default branch.
#### Warnings
Some damage Excel shrugs off, and so does the reader: the cell reads blank and the read carries on. `onWarning`
lets you notice. Each `ReadWarning` has a `code`, a `message`, the `sheet` and the `ref` of the first cell that hit
it, and each code is reported at most once per sheet however many cells hit it.
| `code` | What happened |
| ------------------------ | ------------------------------------------------------------------------------------ |
| `SST_INDEX_OUT_OF_RANGE` | A shared-string cell points past the end of the table; it reads as `''`, as in Excel |
| `SHARED_STRINGS_MISSING` | A shared-string cell in a workbook that has no shared-string part; it reads as `''` |
A sheet full of blank text cells with one of these warnings usually means a damaged file, not an empty one.
#### Limits
| `ReadLimits` | Default | Guards |
| ---------------------- | ------------------- | ---------------------------------------------------- |
| `maxInflatedBytes` | 1 GiB per part | Zip bombs; exceeded raises `ZIP_BOMB` |
| `maxSharedStringChars` | 256 Mi UTF-16 units | A shared-string table designed to exhaust memory |
| `maxEntries` | 10,000 | Archives with an absurd number of parts |
| `maxXmlDepth` | 256 | Deeply nested rich-text runs (`EC-XML-DEEP-NESTING`) |
| `maxTextLength` | 64 Mi UTF-16 units | One text node swallowing the heap |
Raise them for a legitimately huge file you trust; lower them for user uploads. Exceeding a limit raises
`LIMIT_EXCEEDED` (or `ZIP_BOMB` for the inflate caps), and the message names what was hit.
### Listing sheets
```ts
interface SheetInfo {
readonly name: string;
readonly index: number; // position in workbook order, from 0
readonly kind: 'worksheet' | 'chartsheet';
readonly hidden: boolean;
}
```
`workbook.sheets` is the workbook's own order. `workbook.date1904` says which date system the file uses (see
[Dates and values](./dates-and-values.md#the-1904-date-system)).
**Hidden sheets** are listed, not filtered — `hidden` is `true` for both `state="hidden"` and
`state="veryHidden"`. Deciding whether to show them is the application's call. Note that hiding is not a privacy
feature: Numbers makes hidden sheets visible on import and loses the state on export
(`EC-NUMBERS-HIDDEN-SHEETS-SHOWN`).
**Chartsheets** appear in the list with `kind: 'chartsheet'`. They hold a chart, not a grid, so iterating one
yields no rows. Filter them out when you are looking for data:
```ts
const dataSheets = workbook.sheets.filter(({ kind, hidden }) => kind === 'worksheet' && !hidden);
```
`workbook.sheet(nameOrIndex)` takes a name or a 0-based index. Names are matched exactly first and then
case-insensitively, because Excel itself compares sheet names without case. Anything unmatched raises
`SHEET_NOT_FOUND`, and the message lists the names the workbook does have.
### Rows
#### Array mode
```ts
for await (const cells of sheet.rows()) {
// cells: (string | number | boolean | Date | null)[]
}
```
Each row is a dense array, 0-based, with trailing empties trimmed and interior holes as `null`. Rows with no
cells are skipped unless `blankRows: true`, which yields them as `[]` so that positions still line up.
| `RowsOptions` | Type | Default | Meaning |
| ------------- | ------------------- | --------- | ------------------------------------------------------------------------------------- |
| `startRow` | number | `1` | 1-based first row to yield; earlier rows stream past without being materialized |
| `maxRows` | number | — | Stop after this many rows |
| `blankRows` | boolean | `false` | Yield rows with no cells (array mode only) |
| `maxColumns` | number | `16384` | A cell past this column raises `LIMIT_EXCEEDED` rather than being dropped silently |
| `formulas` | `'value' \| 'text'` | `'value'` | `'value'` reads the cached result; `'text'` yields the formula text for formula cells |
#### Object mode
```ts
for await (const row of sheet.rows({ mode: 'object' })) {
// row: Record
}
```
Object mode takes one row as headers and keys every later row by it.
| `ObjectRowsOptions` | Type | Default | Meaning |
| ------------------- | ---------------------- | ---------------------- | --------------------------------------------------------- |
| `headerRow` | number | first row with a value | 1-based row holding the headers |
| `defval` | `CellValue` | `''` | Value for cells absent from a row |
| `headerNaming` | `'sheetjs' \| 'index'` | `'sheetjs'` | How headers become keys |
| `dropEmptyHeaders` | boolean | `false` | Drop columns whose header is empty instead of naming them |
Without `headerRow`, the header row is the first row at or after `startRow` (default 1) that holds any value, an
empty string or an error included. A sheet whose first row or two are blank therefore still finds its headers,
which is what `sheet_to_json` does when the sheet's range starts below row 1 (`EC-HEADER-ROW-FIRST-NONEMPTY`). It
does better than `sheet_to_json` when row 1 is formatted but empty: SheetJS then names every column `__EMPTY` and
reads the real header row as data. Pass `headerRow` to pin the row; an explicit `headerRow` on a blank row gives
`__EMPTY` columns, as before.
`startRow`, `maxRows`, `maxColumns` and `formulas` apply as well. The header row is consumed, not yielded: the
first object is the row after the header row, or `startRow` if that is later. `blankRows` does not apply — object
mode never yields empty records, which matches SheetJS's `blankrows: false`.
##### Header naming
`headerNaming: 'sheetjs'` reproduces SheetJS's `sheet_to_json` keys exactly, so a migration does not rename every
field in an application:
- A header cell with text becomes that text. Non-string headers are spelled the way Excel spells them (`TRUE` /
`FALSE`, an ISO-style timestamp for a date, the code for an error cell such as `#N/A`, whatever `errors` says,
`EC-HEADER-ERROR-CELL`). Numbers keep their JavaScript spelling (`123456789012`), where SheetJS shows Excel's
General format (`1.23457E+11`).
- Every header becomes an own property of the record, `__proto__` and `constructor` included; the record's
prototype never changes (`EC-HEADER-PROTO-KEY`).
- An **absent** header cell becomes `__EMPTY`, and each later absent header gets a counter: `__EMPTY_1`,
`__EMPTY_2`, … (the first has no suffix). A header cell that holds an _empty string_ is a different thing: its
key is the empty string.
- A **duplicate** header keeps the first occurrence as-is and suffixes the rest: `Name`, `Name_1`, `Name_2`, …
skipping any suffix that is already taken.
`headerNaming: 'index'` ignores the header text and keys every column by its 0-based column index as a string
(`'0'`, `'1'`, `'2'`). Use it when the file has no usable header row but you still want objects.
`dropEmptyHeaders: true` drops every column whose header is empty — absent or empty text — instead of naming it.
That is usually what you want for a report export with trailing blank columns.
:::note
The column list comes from the widest of the header row, the widest data row and the sheet's declared
``. Excel, Google Sheets and Numbers all re-save with a dimension wider than the data, so a file from
one of them can grow trailing `__EMPTY` columns. This is deliberate: it is what `sheet_to_json` does, and object
mode is built to match it. The first column is the dimension's first column, so an all-blank column A that Excel
left out of the range does not become `__EMPTY` either (`EC-HEADER-DIMENSION-START-COLUMN`); a value that turns up
left of it is still kept, under a blank-header name.
`toObjects()` gives every record every key: a column that only appears in a later, wider row is added to the
earlier records with `defval`. Streaming `rows({ mode: 'object' })` cannot go back, so there a record only has the
keys named by the time it was read.
:::
##### `defval` and blank cells
A cell that is missing from the row and a cell holding an empty string are different things in the file, and
readers disagree about which they return (`EC-BLANK-VS-EMPTY-STRING`). Object mode settles it with `defval`:
every named column appears on every record, and a cell with no value gets `defval`, which defaults to `''`. Pass
`defval: null` if you need to tell "absent" from "empty text".
#### `toObjects`
When the sheet is small enough to hold in memory, `toObjects` collects it in one call and hands back the headers
it used:
```ts
const { rows, headers, truncated } = await sheet.toObjects({ maxRows: 10_000 });
```
`truncated` is `true` when `maxRows` stopped the read before the sheet ended — which is how you tell "the file
has 10,000 rows" from "the file has more and you only asked for 10,000".
### `head()`: reading a prefix
```ts
const cells = await sheet.head(5); // Map, keyed 'A1', 'B1', …
const objectName = cells.get('B1')?.value;
const operation = cells.get('B2')?.value;
```
`head(rowCount)` returns the first rows as an A1-keyed map and then **stops inflating** — a preview of a
million-row sheet reads only its first chunks. It is the right tool for template headers, for sniffing which
columns a file has before committing to a full read, and for files whose interesting metadata sits in a few
scattered cells above the data.
```ts
interface RawCell {
readonly value: CellValue;
readonly error?: CellErrorCode; // present when the cell held an error; `value` is then the error text
readonly formula?: string; // formula text, when the cell has one
}
```
Blank cells with no formula are simply absent from the map. `head()` always reports formula text and always gives
an error cell both its text and its code, whatever `errors` mode the workbook was opened with.
### Formulas
Formulas are read-only. A formula cell carries a cached result in the file, and that is what you get by default:
```ts
for await (const cells of sheet.rows()) {
// the cached value of =SUM(A1:A9), not the text
}
for await (const cells of sheet.rows({ formulas: 'text' })) {
// 'SUM(A1:A9)' for formula cells, values for the rest
}
```
Nothing is evaluated, and formulas are never written. Two caveats from the corpus: some writers never record a
cached value at all (openpyxl), and some applications replace formulas they cannot express with their cached
value on import (Numbers, `EC-NUMBERS-FORMULAS-REPLACED`).
### Cancelling a read
Breaking out of the loop cancels the inflate stream, so an early `break` costs only what was already read:
```ts
for await (const cells of sheet.rows()) {
if (looksWrong(cells)) {
break; // the rest of the sheet is never inflated
}
}
```
For a read you need to cancel from elsewhere, pass a `signal` to `openWorkbook`. It is checked between chunks and
ends the iteration with `ABORTED`.
Call `workbook.close()` when you are done; it releases the source's handles (for a file-backed source, the file
descriptor). Reading any sheet of a closed workbook fails with `ABORTED`, whatever the source.
### Sniffing before you open
`sniff(head)` classifies the first bytes of an input without parsing it. Use it when you accept arbitrary uploads
and want to route CSV to your own parser rather than show an error:
```ts
import { SNIFF_BYTES, sniff } from '@jetstreamapp/simple-excel';
const head = new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
switch (sniff(head)) {
case 'zip':
return openWorkbook(file);
case 'text':
return parseCsv(file);
default:
return showUnsupported();
}
```
`'text'` covers CSV in any common encoding: UTF-8, UTF-16 with a byte-order mark, and legacy single- and multi-byte
encodings such as Windows-1252 (what Excel for Windows saves CSV as) or Shift-JIS, recognised by the absence of NUL
and control bytes (`EC-SNIFF-LEGACY-TEXT`). Decoding it is up to your CSV path: try UTF-8 with
`new TextDecoder('utf-8', { fatal: true })` and fall back to `'windows-1252'`.
It returns `'zip' | 'cfb-encrypted' | 'cfb-legacy' | 'xml' | 'html' | 'text' | 'empty' | 'unknown'`. The
`SniffResult` type also lists `'xlsx' | 'xlsb' | 'ods'`, which `sniff` itself never returns: all three are zips,
and only the package contents tell them apart, so `openWorkbook` makes that distinction after opening the
archive. Pass at least `SNIFF_BYTES` (65,536 bytes): the encrypted-versus-legacy decision scans all of it for the
`EncryptedPackage` stream name.
### What the reader tolerates
Real files are not tidy. The reader deliberately accepts things the schema does not, because Excel does:
- `` and `` without `r` attributes — positions are inferred (`EC-MISSING-R-ATTRIBUTES`).
- A namespace prefix on every element, and Strict OOXML namespaces and relationship types
(`EC-PREFIXED-ELEMENTS`, `EC-STRICT-NAMESPACES`).
- Relationship targets that are absolute (`/xl/worksheets/sheet1.xml`) or use backslashes
(`EC-ABSOLUTE-REL-TARGETS`, `EC-BACKSLASH-REL-TARGETS`).
- A missing ``, or one that disagrees with the cells — it is a hint, never the truth (`EC-NO-DIMENSION`).
- Part names whose case differs from the relationship that points at them (`xl/SharedStrings.xml`): OPC names are
case-insensitive, so the exact name wins and a case-insensitive match is the fallback (`EC-PART-NAME-CASE`).
- A numeric `` of only whitespace reads as blank (`EC-EMPTY-V-ELEMENT`); numeric text Excel never writes, such
as `0x1A`, reads as its text rather than as a number (`EC-NUMBER-RADIX-PREFIX`).
- A workbook with no `xl/sharedStrings.xml` at all, every string inline (`EC-SST-ABSENT-INLINE-ONLY`).
- Extra parts with no extension, legacy VML comment parts, `[Content_Types].xml` as the last entry rather than
the first (`EC-PART-NONSTANDARD-NAMES`, `EC-ZIP-CONTENT-TYPES-LAST`).
- Apache POI's spellings: `customWidth="true"` instead of `1`, six-digit colours, ` ` — which is
what Salesforce report exports contain (`EC-POI-BOOLEAN-ATTRIBUTE-SPELLING`, `EC-POI-RGB-6-HEX`).
What it does not tolerate is on the [Errors](./errors.md) page: a DOCTYPE, duplicate entries, a CRC mismatch, an
unsupported compression method, a cell reference past column XFD (`EC-REF-BEYOND-XFD`), and anything past the
limits.
---
## Streaming and memory
The design constraint is one sentence: **nothing that grows with the number of rows is ever held in memory.**
That rules out the usual xlsx architecture, where a workbook object is built up, serialized to one giant XML
string, and zipped. That approach fails at a predictable size — `RangeError: Invalid string length` around 512 MiB
of string, `Invalid array length` around 18M cells — and both SheetJS and `@office-kit/xlsx` hit it at roughly the
same point (`research/06-performance-baseline.md`).
### How the writer stays flat
Each `writeRow` appends cell XML to a small string builder. At 64 Ki characters the builder is joined, encoded to
UTF-8, pushed through the deflater and handed to the sink, and the builder is dropped. Bytes are leaving the
process while you are still producing rows.
What is resident at any moment:
| Structure | Size |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| The row builder | ≤ 64 Ki characters |
| The deflater's internal window | a few tens of KB |
| The zip central directory | one record per part — tens of parts, a few KB |
| The shared-string table | none by default (`strings: 'inline'`); under `'auto'` bounded by `sstBudget`. See [Writing](./writing.md#shared-strings) |
| The style registry | one entry per distinct `CellStyle` you registered |
None of those is a function of row count. The shared-string table is the only one that could have been, which is
why it is off by default and budgeted when turned on.
### How the reader stays flat
A sheet is a zip entry; the reader opens it as a stream, inflates a chunk, pushes the decoded text through a
tokenizer and yields the rows that completed. The parser keeps one row at a time. Breaking out of the loop
cancels the inflate stream, so `head()` and an early `break` cost only what was already read.
The one structure proportional to the file is the **shared-string table**, which cells index into by number; it
has to be resident while a sheet is being read. It is parsed lazily — only when a sheet actually contains a
`t="s"` cell — and capped by `limits.maxSharedStringChars` (256 Mi UTF-16 units by default). A file written with
inline strings has no table at all and reads with a genuinely constant footprint.
What you do with the rows is your own memory budget. `for await (const row of sheet.rows())` is flat;
`await sheet.toObjects()` is not, because it returns an array of every record.
### Back-pressure
`ByteSink.write` returns a promise, and the writer awaits it. A sink that resolves slowly slows the producer
down instead of letting chunks queue up in memory. That is the whole mechanism, and it is why a sink must not
resolve `write` before it has actually taken the bytes.
```ts
const slowSink: ByteSink = {
async write(chunk) {
await sendSomewhere(chunk); // the writer waits here
},
async close() {},
async abort() {},
};
```
The same applies on the way in: `fromWritableStream` awaits the stream writer's `ready` before each write, and
the Node sinks wait for `'drain'`, so a slow disk or a slow socket propagates all the way back to your row loop.
:::caution
A sink takes ownership of every chunk it is handed and the writer never copies. If your sink retains chunks
asynchronously and you are reusing a scratch buffer, copy first.
:::
### Choosing a sink
#### Browser: download when finished
```ts
import { collectToBlob, createWorkbookWriter } from '@jetstreamapp/simple-excel';
const sink = collectToBlob();
const workbook = createWorkbookWriter(sink);
// ... write rows ...
await workbook.close();
const blob = await sink.result();
saveAs(blob, 'accounts.xlsx');
```
`collectToBlob` folds pending chunks into a sub-`Blob` every 32 MiB and drops the arrays. Chromium can page Blob
data out to disk, so the JS heap stays flat even for a multi-hundred-megabyte file — the final `Blob` is a list
of parts, not one allocation.
:::caution
Firefox and Safari keep Blob data in memory. `collectToBlob` still keeps the JS _heap_ flat there, but the
process footprint grows with the file. For files that might pass a few hundred megabytes on those browsers,
stream to disk instead. `sink.bytesWritten` lets you warn a user before that point.
:::
#### Browser: stream straight to disk
On Chromium, the File System Access API gives you a real `WritableStream` to a file the user picked. Memory stays
constant no matter how large the export gets:
```ts
import { createWorkbookWriter, fromWritableStream } from '@jetstreamapp/simple-excel';
// must be called inside the user gesture, before generation starts
const handle = await window.showSaveFilePicker({ suggestedName: 'accounts.xlsx' });
const sink = fromWritableStream(await handle.createWritable());
const workbook = createWorkbookWriter(sink);
// ... write rows ...
await workbook.close(); // closes the file
```
The same adapter takes any `WritableStream` — an OPFS `createWritable()`, a `TransformStream` feeding
an upload, an Electron IPC bridge. `createWorkbookWriter` also accepts a `WritableStream` directly and wraps it
for you.
The other direction exists too: `toWritableStream(sink)` exposes a sink as a `WritableStream` for `pipeTo`
consumers.
#### Node: a file or any writable
```ts
import { createWorkbookWriter } from '@jetstreamapp/simple-excel';
import { nodeDeflater, toFile, toWritable } from '@jetstreamapp/simple-excel/node';
const toDisk = createWorkbookWriter(toFile('accounts.xlsx'), { deflater: nodeDeflater(1) });
const toResponse = createWorkbookWriter(toWritable(httpResponse));
```
`toFile` streams through `fs.createWriteStream`; `toWritable` takes any Node `Writable` — a response, a socket, a
`PassThrough`. Both honour back-pressure. See [Node](./node.md).
#### In memory
`collectToBytes({ maxBytes })` concatenates once at the end and throws `LIMIT_EXCEEDED` past its cap (1 GiB by
default). It is the right choice for tests, for small files, and for contexts with no `Blob` — but the cap is
there because a contiguous allocation is exactly what streaming exists to avoid. Set it to what you can actually
afford.
### Workers
The core entry touches no DOM API beyond `TextEncoder`, `TextDecoder`, the compression streams and a duck-typed
`Blob`, so it runs unchanged in a Web Worker, an MV3 extension service worker and an Electron renderer. Moving a
large export or import off the main thread is worth it: the row loop is CPU-bound and will otherwise block
rendering for seconds.
:::caution
Instantiate the worker with a literal `new Worker(new URL('./export.worker.ts', import.meta.url), { type: 'module' })`.
Bundlers pattern-match that exact shape, and an MV3 extension's `script-src 'self'` blocks `blob:` workers
outright, so an inlined worker will not run there (ADR-004).
:::
A `Blob` can be transferred back to the main thread by reference, so `collectToBlob` inside a worker costs no copy
on the way out.
### Reading a very large file
- Iterate with `for await`; do not call `toObjects()` on a file you have not sized.
- Use `head(n)` to inspect headers or template metadata before committing to a full read.
- `break` out of the loop as soon as you know enough — the rest of the sheet is never inflated.
- Lower `limits` for untrusted uploads; raise `maxInflatedBytes` deliberately for a large file you trust.
- Prefer a `Blob`/`File` or a `RandomAccessSource` over materializing an `ArrayBuffer`: the reader only pulls the
ranges it needs.
### Writing a very large file
- Announce `rowCount` on `addSheet` when you know it, so the zip64 decision can be made up front. It is only a
hint: the writer never writes `` from it, so a count that turns out wrong costs nothing.
- Use `onProgress` (every 5,000 rows) for the UI, and an `AbortSignal` so a user can cancel.
- Leave `strings` on its inline default unless the data is very low-cardinality and file size matters more than
write speed; then `strings: 'auto'` builds a bounded table. Excel re-shares strings the first time the user
saves anyway.
- Use `nodeDeflater(1)` in Node when throughput matters more than a few percent of file size.
### The zip64 caveat
An xlsx part over 4 GiB needs zip64 headers, and Excel only accepts them when they were declared in the _local
file header_, before the entry's first byte. So the decision is made up front, from the `rowCount` you declared
(ADR-002).
:::caution
**SheetJS 0.20.3 cannot open a zip64 archive at all** — "Unsupported ZIP file", even for a small one — and
**Google Drive fails to convert one to a Google Sheet** on upload (`EC-ZIP64-SMALL`). Excel, LibreOffice, openpyxl
and calamine all read them. That is why `zip64: 'auto'` only turns it on for a sheet whose declared size makes a
part over 4 GiB plausible (about 40M cells; Google Sheets tops out at 10M cells, so such a file could not be
imported there regardless). If any consumer of your files is SheetJS or Google Sheets, leave it on `'auto'` and do
not set `zip64: true`.
:::
A sheet written with an unknown row count stays 32-bit, and a part that does pass 4 GiB fails with
`ENTRY_TOO_LARGE`, whose message says to enable zip64 (`zip64: true`) or split the data across sheets. Excel's own
1,048,576-row limit means such a sheet would need more than 4 KB of XML per row. That is rare, but long text gets
there: `'auto'` sizes a sheet by its cell count, so 140,000 rows of 32,000-character cells pass 4 GiB even with an
accurate `rowCount`. See [Writing](./writing.md#zip64) for how to decide when your rows carry long text.
### Numbers
The claim on this page is measurable, so here is the measurement. Apple M4, Node v24.18.0, 20 columns of
Salesforce-shaped data (about 1.7 KB of text per row), rows pulled from a generator so nothing but the writer is
resident, peak sampled every 25 ms. Run
[`2026-09-12-macbook-air-phase-e-combined`](https://github.com/jetstreamapp/simple-excel/tree/main/bench/results/2026-09-12-macbook-air-phase-e-combined),
with the streamed-source timings taken one size per process. These rows were measured before inline strings
became the default (with the bounded shared-string table, now `strings: 'auto'`); the heap and RSS figures are the
ones `research/06-performance-baseline.md` quotes.
| Rows written | Time | Output | JS heap growth | Peak process RSS |
| ------------------- | ------ | ------ | -------------- | ---------------- |
| 100,000 (2M cells) | 3.8 s | 42 MB | 45 MB | 145 MB |
| 900,000 (18M cells) | 35.4 s | 384 MB | 76 MB | 215 MB |
| 1,000,000 | 39.1 s | 427 MB | 77 MB | 214 MB |
Ten times the rows, the same heap. That is the whole design in one table. Reading is the same shape: streaming
the 1,000,000-row file back with `sheet.rows()` takes 10.3 s and grows the heap by 64 MB.
For contrast, on the same machine SheetJS writes 100k × 20 in 9.8 s with a 3,313 MB peak, and throws
`RangeError: Invalid string length` at both 1M × 20 and 18M cells. In a Chrome module worker writing to
`collectToBlob()`, simple-excel finishes 1,000,000 × 20 in 39.6 s for +254 MB of renderer RSS; SheetJS crashes
the renderer.
The gates all of this is measured against (`research/06-performance-baseline.md`) are, for 100k × 20 versus
SheetJS 0.20.3 with the same options: write time ≤ 1.0× (measured 0.32×), write peak memory ≤ 0.25× (0.02×),
read time ≤ 1.0× (0.27×), read peak memory ≤ 0.5× (0.44×), first byte at the sink under 100 ms (0.5 ms), and
1M × 20 completing in both Node and a Chrome module worker (it does). The original absolute targets in the build
plan — 100k × 20 written in 1.2 s, read in 1.5 s — were set without a dataset and are retired; on this one the
default configuration writes in 3.08 s and reads in 1.64 s, and 06 records where the time goes.
:::caution
Reading back a file this large needs the inflate cap raised: 1,000,000 Salesforce-shaped rows inflate to 2.19 GB
of sheet XML, past the 1 GiB default, and `openWorkbook` refuses it with `ZIP_BOMB`. Pass
`limits: { maxInflatedBytes: 4 * 1024 * 1024 * 1024 }` for a file that size when you trust it.
:::
---
## Writing
A write has three moving parts: a **sink** that receives bytes, a **workbook writer** that owns the zip container,
and one **sheet writer** at a time that turns rows into XML.
```ts
import { collectToBlob, createWorkbookWriter } from '@jetstreamapp/simple-excel';
const sink = collectToBlob();
const workbook = createWorkbookWriter(sink);
const sheet = workbook.addSheet('Accounts', {
header: ['Id', 'Name', 'Amount', 'Created'],
freeze: { rows: 1 },
autoFilter: true,
columns: [{ width: 22 }, { width: 40 }, { width: 12 }, { width: 20 }],
});
await sheet.writeRow(['001xx000003DGb2AAG', 'Acme', 1234.5, new Date(2024, 2, 10, 9, 30)]);
await sheet.close();
const result = await workbook.close();
const blob = await sink.result();
```
Bytes reach the sink while rows are still being written: the first chunk of `xl/worksheets/sheet1.xml` is handed
over long before `close()`. Nothing is buffered except the zip central directory (a few KB) and, under
`strings: 'auto'`, whatever the shared-string budget allows.
### `createWorkbookWriter(sink, options?)`
```ts
function createWorkbookWriter(sink: ByteSink | WritableStream, options?: WorkbookWriterOptions): WorkbookWriter;
```
A `WritableStream` is accepted anywhere a `ByteSink` is; it is wrapped with `fromWritableStream` for
you.
`WorkbookWriter` is:
| Member | Meaning |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| `addSheet(name, options?)` | Opens a sheet. One at a time — close it before adding the next |
| `registerStyle(style)` | Interns a `CellStyle` and returns a `StyleId` you pass to `writeRow` |
| `close()` | Finalizes shared strings, styles, workbook parts and the central directory; closes the sink |
| `abort(reason?)` | Tears the whole thing down and aborts the sink |
### Sinks
A sink is three methods:
```ts
interface ByteSink {
write(chunk: Uint8Array): Promise;
close(): Promise;
abort(reason?: unknown): Promise;
}
```
`write` may resolve late, and the writer waits, so a slow sink slows the whole pipeline down instead of queueing
in memory. Four built-in sinks cover the common cases.
| Sink | Result | Use it for |
| ------------------------------ | ------------------------------ | ---------------------------------------------------------------- |
| `collectToBlob(type?)` | `result(): Promise` | Browser downloads. Folds chunks into sub-Blobs every 32 MiB |
| `collectToBytes({ maxBytes })` | `result(): Uint8Array` (sync) | Tests, small files, contexts with no `Blob`. Default cap 1 GiB |
| `fromWritableStream(stream)` | whatever the stream does | File System Access, OPFS, `Writable.toWeb()`, a network response |
| `toWritableStream(sink)` | a `WritableStream` | The other direction, for `pipeTo` consumers |
```ts
import { collectToBlob, collectToBytes, fromWritableStream } from '@jetstreamapp/simple-excel';
// browser download
const blobSink = collectToBlob();
// ... write ...
const blob = await blobSink.result();
// in memory, with an explicit ceiling
const bytesSink = collectToBytes({ maxBytes: 64 * 1024 * 1024 });
// ... write ...
const bytes = bytesSink.result();
// straight to disk through the File System Access API
const handle = await window.showSaveFilePicker({ suggestedName: 'accounts.xlsx' });
const fileSink = fromWritableStream(await handle.createWritable());
```
`collectToBlob` and `collectToBytes` expose `bytesWritten` while the write is running. `collectToBytes` throws
`LIMIT_EXCEEDED` past `maxBytes`; `collectToBlob` throws `UNSUPPORTED_ENVIRONMENT` at construction if the platform
has no `Blob`. `collectToBytes().result()` is synchronous and throws `WRITER_STATE` until `workbook.close()` has
resolved; `collectToBlob().result()` can be called early and resolves when the workbook closes. After an abort, or a
failed write (which aborts the sink), both fail with the abort reason.
[Streaming and memory](./streaming-and-memory.md) covers which sink to choose for a given size and platform, and
the Node file sinks are in [Node](./node.md).
### Sheets
```ts
const sheet = workbook.addSheet('Accounts', options);
```
| `SheetOptions` | Type | Default | Notes |
| -------------- | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| `header` | `readonly CellInput[]` | — | Written as row 1, bold unless you say otherwise |
| `headerStyle` | `StyleId \| false` | bold | `false` writes the header unstyled; a `StyleId` replaces the default |
| `columns` | `readonly ColumnOptions[]` | — | `{ width?, hidden?, style? }` per column, left to right |
| `freeze` | `{ rows?, cols? }` | — | `{ rows: 1 }` freezes the header row |
| `autoFilter` | `boolean` | `false` | Filter over the header row and every written column |
| `hidden` | `boolean` | `false` | `state="hidden"` in the workbook part |
| `rowCount` | `number` | — | Data rows, excluding the header. A hint for up-front zip64 sizing; never written as ``, so it need not be exact |
`ColumnOptions.width` is Excel's character-width unit, and Excel's schema caps it at 255: a wider value is written
as 255, and a negative, `NaN` or infinite width throws `WRITER_STATE` (`EC-COLS-WIDTH-OVER-255`). `addSheet` also
checks `freeze` (whole numbers that leave a scrolling cell inside the grid), `rowCount` (a whole number, 0 or more)
and every style id it is given, and a refused `addSheet` leaves the workbook as it was. `ColumnOptions.style` is the
column's default format (` `): it applies to cells the writer does not emit, such as ones a user types in
later. Cells you write keep the style passed to `writeRow`.
`SheetWriter` is:
| Member | Meaning |
| --------------------------- | -------------------------------------------------------------------------------- |
| `name` | The name actually used, after sanitizing and de-duplication |
| `nextRow` | 1-based index of the row the next `writeRow` will produce |
| `writeRow(values, styles?)` | One row |
| `writeRows(rows)` | An `Iterable` or `AsyncIterable` of rows, written in order with back-pressure |
| `merge(range)` | Registers a merged range in A1 notation, checked at once; emitted at sheet close |
| `close()` | Resolves to a `SheetWriteSummary` (`{ name, rows, columns }`) |
#### Sheet names
Excel's rules are enforced for you, so `addSheet` never fails on a name a user typed. `: \ / ? * [ ]` and control
characters (tab and line breaks included) become `_`,
leading and trailing apostrophes are stripped, the name is trimmed to 31 characters, the reserved name `History`
(any casing) becomes `History_`, an empty name becomes `Sheet`, and a case-insensitive collision gets ` (2)`,
` (3)` … fitted inside the 31-character budget. Read `sheet.name` if you need to know what was used — the mapping
matters when you later look the sheet up by name.
#### Sheet-name and cell-reference helpers
The rules above are exported, along with the A1 arithmetic the writer uses, for callers that key data by sheet
name before writing or build merge ranges from coordinates:
| Export | What it does |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sanitizeSheetName(name, taken)` | Returns the name `addSheet` would pick. `taken` is a `Set` of the **lower-cased** names already used; the result is added to it, lower-cased |
| `isValidSheetName(name)` | `true` when the name needs no sanitizing. It does not check for collisions |
| `MAX_SHEET_NAME_LENGTH` | `31` |
| `formatRef(row, col)` | 0-based row and column to an A1 reference: `formatRef(2, 1)` is `'B3'` |
| `formatRange(startRow, startCol, endRow, endCol)` | 0-based corners to a range, normalized so the top-left comes first: `formatRange(0, 0, 0, 2)` is `'A1:C1'` |
| `parseRef(ref)` | `'B3'` to `{ row: 2, col: 1 }` (a `CellRef`, 0-based); `$` anchors are tolerated; `null` when the text is not an upper-case A1 reference inside the grid |
| `parseRange(range)` | `'A1:C3'` to `{ start, end }` (a `CellRange`, normalized); a single reference parses as a one-cell range; `null` when malformed |
| `columnLetters(index)` / `columnIndexOf(letters)` | `0` to `'A'`, `26` to `'AA'`, and back; `columnIndexOf` returns `-1` for anything that is not upper-case column letters inside the grid |
| `MAX_ROWS` / `MAX_COLUMNS` | `1,048,576` / `16,384` |
`formatRef`, `formatRange` and `columnLetters` throw `ROW_OUT_OF_RANGE` for a coordinate outside the grid, and
`sanitizeSheetName` throws `INVALID_SHEET_NAME` for a name that is not a string.
```ts
import { formatRange, sanitizeSheetName } from '@jetstreamapp/simple-excel';
const taken = new Set();
const names = ['Accounts', 'accounts', 'Q1/Q2'].map(name => sanitizeSheetName(name, taken));
// ['Accounts', 'accounts (2)', 'Q1_Q2']
sheet.merge(formatRange(0, 0, 0, 2)); // 'A1:C1'
```
### Rows and values
```ts
await sheet.writeRow(['001xx000003DGb2AAG', 'Acme', 1234.5, true, new Date(), null]);
await sheet.writeRows(records.map(record => [record.Id, record.Name]));
await sheet.writeRows(asyncGeneratorOfRows());
```
Rows are strictly sequential: row _n_ is serialized and gone before row _n+1_ starts. There is no way to revisit a
written row, and there are no cell coordinates in the API — position in the array is the column. A row has to be an
array: an object such as `{ Id, Name }` throws `WRITER_STATE` rather than writing an empty row (`EC-ROW-NOT-ARRAY`),
so map records to arrays in header order first. A row array you reuse and change between calls is safe even when
you do not await each `writeRow`.
`CellInput` is `string | number | boolean | Date | null | undefined | bigint | CellError`:
| Input | What is written |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `string` | Inline string by default, a shared string under `strings: 'auto'` (see below); `_xHHHH_`-escaped, `xml:space="preserve"` when needed |
| `number` | `` with the shortest round-trip form. `NaN` and `±Infinity` become the error cell `#NUM!` |
| `boolean` | `t="b"` with `1` / `0` |
| `Date` | A serial under a date number format. An **invalid `Date` writes nothing** — no `` at all. Before 1900 or after 9999: ISO text |
| `null` / `undefined` | No `` element, unless a non-default style was given for that cell (then an empty styled cell) |
| `bigint` | A number while `\|value\| <= 2^53`; a string beyond that, so no digits are silently lost |
| `{ error: '#N/A' }` | A real error cell (`t="e"`) for the `CellErrorCode` literals. Another `#` code (`#SPILL!`) is written as its text; anything else throws |
Anything else, such as a plain object, an array, a function or a symbol, throws `WRITER_STATE` with a message that
names the sheet, the cell and the type (`Sheet "Accounts" cell C7: a value of type object cannot be written…`).
SheetJS wrote an object as a blank cell and an array as its first element; the writer will not guess, so convert
such values (for example with `JSON.stringify`) before writing (`EC-CELL-UNSUPPORTED-TYPE`).
Text that starts with `=` stays text. The writer never infers a formula from a value — that is both a
formula-injection surface and a reliable way to make Excel show its repair dialog
(`EC-FORMULA-LIKE-TEXT-WRITTEN-AS-FORMULA`).
[Dates and values](./dates-and-values.md) has the full value model, including what Excel does with control
characters, CRLF and pre-1900 dates.
#### Per-cell and per-row styles
`writeRow`'s second argument is either one `StyleId` for the whole row or one per cell:
```ts
const currency = workbook.registerStyle({ numFmt: '#,##0.00' });
const warn = workbook.registerStyle({ fill: { color: '#FFF3CD' } });
await sheet.writeRow([id, name, amount], [undefined, undefined, currency]);
await sheet.writeRow([id, name, amount], warn); // whole row
```
`undefined` in the per-cell array means the default style.
### Styles
```ts
const styleId = workbook.registerStyle({
font: { bold: true, italic: false, size: 11, color: '#1B4F72', name: 'Calibri' },
fill: { color: '#EAF2F8' },
border: { top: 'thin', bottom: 'thin', color: '#B3B6B7' },
alignment: { horizontal: 'center', vertical: 'center', wrapText: true },
numFmt: '#,##0.00',
});
```
| `CellStyle` field | Shape |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `font` | `{ bold?, italic?, underline?, strike?, size?, color?, name? }`; `color` is `#RRGGBB` |
| `fill` | `{ color: '#RRGGBB' }` — solid fill only |
| `border` | A line style for all four sides, or `{ top?, bottom?, left?, right?, color? }` |
| `alignment` | `{ horizontal?: 'left' \| 'center' \| 'right', vertical?: 'top' \| 'center' \| 'bottom', wrapText? }` |
| `numFmt` | A format code (`'0.00'`, `'yyyy-mm-dd'`) or a built-in numFmt id |
Line styles are `'thin' | 'medium' | 'thick' | 'dashed' | 'dotted' | 'double' | 'hair'`.
`registerStyle` interns: two identical `CellStyle` objects return the same `StyleId`, so calling it inside a row
loop is safe but pointless. `StyleId` 0 is the default style. Register every style you need up front and keep the
ids: `writeRow`, `columns` and `headerStyle` only accept ids that `registerStyle` returned (`EC-STYLE-ID-RANGE`).
`registerStyle` checks what Excel would otherwise repair or refuse, and throws `WRITER_STATE` naming the field: a
font size outside 1–409, a font name that is empty, longer than 31 characters or holds a control character, a
colour that is not `#RRGGBB`, a `fill` without a colour, an alignment or border value outside the lists above, a
number format code that is empty, longer than 255 characters or holds a control character, a numeric `numFmt` that is not a built-in or
registered id, and more than 64,000 styles in one workbook (`EC-STYLE-FIELD-RANGE`). A refused style registers
nothing and does not affect the workbook.
#### Number formats
A `numFmt` string that matches a built-in code resolves to the built-in id and emits no `` element — so
`'0.00'` becomes id 2, `'@'` becomes id 49. Anything else is allocated a custom id from 164 upward. You can also
pass a built-in id directly (`numFmt: 14`).
Number formats are **not rendered** on read: a cell's value is the number, never the displayed text. See
[Dates and values](./dates-and-values.md#numbers).
#### The default date format
A `Date` written without a date style is given one automatically, because a bare serial under the General format
shows as a five-digit number in Excel. The default code is **`yyyy-mm-dd hh:mm:ss`**, registered once per
workbook. If the style you pass for that cell has no number format of its own (a bold, filled row style, say), the
date cell keeps its font, fill, border and alignment and gains the default date format (`EC-DATE-STYLE-MERGE`). If
the style already carries a date or time number format, yours is used as it is:
```ts
const dateOnly = workbook.registerStyle({ numFmt: 'yyyy-mm-dd' });
await sheet.writeRow([new Date()], dateOnly); // your format wins
await sheet.writeRow([new Date()]); // yyyy-mm-dd hh:mm:ss
```
### Merges
```ts
sheet.merge('A1:C1');
```
A1 notation in either case (`'a1:c1'` works), registered while the sheet is open and emitted at close in the
order added. `merge()` checks the range at once and throws `WRITER_STATE` for a range that is not `A1:C1`-shaped,
a single cell (`'A1:A1'`), a range outside the grid, or one that overlaps or repeats a range already merged on the
sheet: Excel opens the repair dialog for each of these (`EC-MERGE-OVERLAP`). A refused merge does not affect the
rest of the workbook.
### Workbook-level rules
`workbook.close()` refuses two workbooks Excel would repair, with `WRITER_STATE`, and aborts the sink: one with no
sheet at all (`EC-WORKBOOK-NO-SHEETS`), and one whose sheets are all hidden (`EC-ALL-SHEETS-HIDDEN`). When the first
sheet is hidden, the first visible sheet is the one Excel opens on. `properties.created` must be a valid `Date`
between the years 1 and 9999, checked when the writer is created (`EC-DOCPROPS-CREATED-RANGE`). Control characters
in sheet names become `_` and are dropped from the title and creator, where XML cannot carry them; a font name or
number format code holding one is refused (`EC-XML-CONTROL-CHARS-METADATA`). Cell text keeps them, encoded as
`_xHHHH_`.
### Shared strings
Excel can store strings in a workbook-wide table (`xl/sharedStrings.xml`) and have cells point at indexes, or
inline them in the sheet. The table shrinks files with repeated values, and grows in memory with every unique one
— which is exactly how a streaming writer runs out of heap on a column of record ids.
The default is **inline** (ADR-001, revised): no table is built, memory does not grow with unique strings, the
compressed file is within a couple of percent of the table version on typical data (the platform deflater gets
about 20% more bytes, which costs roughly a tenth of the write time on repeat-heavy data), and it is the shape
every application reads faithfully — Numbers truncates shared strings at control characters and
mis-decodes protected `_xHHHH_` escapes in them, but reads inline strings correctly. It is also what SheetJS
writes by default, so files look the same as before to anyone migrating.
`strings: 'auto'` gives a **bounded hybrid**: strings are interned while the table is under budget, then the map
is frozen — existing entries still resolve, new strings go inline — and memory stops growing. Use it for very
low-cardinality data (picklists repeated across hundreds of thousands of rows) when file size matters more than
write speed.
| Option | Values | Default | Meaning |
| --------------------- | -------------------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `strings` | `'inline' \| 'auto' \| 'shared'` | `'inline'` | `'auto'` builds a bounded table; `'shared'` interns everything (unbounded, for tests) |
| `sstBudget.maxUnique` | number | 65,536 | Stop interning once this many unique strings exist |
| `sstBudget.maxChars` | number | 16 Mi | Stop interning once the interned text totals this many UTF-16 units |
| `sstBudget.maxLength` | number | 256 | Strings longer than this are always inline |
`'shared'` switches the budget off entirely, so it will run out of memory on a large unique-heavy sheet. Use it
only when you are testing something about the table itself.
The result of `workbook.close()` reports what happened:
```ts
const result = await workbook.close();
result.sharedStrings; // { count, uniqueCount, frozen }
```
Under `'inline'` the counts are zero. Under `'auto'`, `frozen: true` means the budget was reached and the rest of the workbook was written inline. Mixed `s` and
`inlineStr` cells in one sheet are legal, and every reader in the compatibility matrix accepts them.
### Zip64
An entry larger than 4 GiB needs zip64 headers, and Excel only accepts them when they were declared **in the local
file header** — that is, before the first byte of the entry, when the writer cannot yet know how big it will be.
So the decision has to be made up front (ADR-002).
| `zip64` | Behaviour |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `'auto'` (default) | A worksheet gets zip64 only when its declared `rowCount` makes a > 4 GiB part plausible (about 40M cells, see below). Everything else stays a standard zip |
| `true` | Every streamed part gets zip64 headers |
| `false` | Never emitted |
:::caution
`'auto'` is deliberately conservative because **SheetJS 0.20.3 cannot open a zip64 archive at all** ("Unsupported
ZIP file" even for a small one) and **Google Drive fails to convert one to a Google Sheet** (`EC-ZIP64-SMALL`).
Excel, LibreOffice, openpyxl and calamine all read them. Only turn `zip64: true` on when you know your consumers
can handle it.
:::
A sheet written without a `rowCount` stays 32-bit, and if a part does pass 4 GiB the writer fails with
`ENTRY_TOO_LARGE`, whose message says to enable zip64 (`zip64: true`) or split the data across sheets. Announcing
`rowCount` lets `'auto'` switch zip64 on for a sheet with a huge number of cells:
```ts
const sheet = workbook.addSheet('Huge', { header, rowCount: records.length });
```
`'auto'` estimates the part's size from its cell count (about 100 bytes a cell), so it cannot see long text. 140,000
rows of 32,000-character cells pass 4 GiB with an accurate `rowCount`, and the write fails cleanly with
`ENTRY_TOO_LARGE` at that point. When rows carry long text (email bodies, JSON blobs), estimate the uncompressed size
yourself and pass `zip64: true` when it could exceed about 3.5 GiB, accepting that SheetJS and Google Drive cannot
read that file.
### Compression and the deflater
| Option | Values | Default | Meaning |
| ------------- | ---------------------- | ----------- | ---------------------------------------------------------- |
| `compression` | `'deflate' \| 'store'` | `'deflate'` | `'store'` writes uncompressed entries: faster, much larger |
| `deflater` | `DeflaterFactory` | platform | Swap in another compressor |
The default compressor is `CompressionStream('deflate-raw')`, which has no compression level. Where
`CompressionStream` is unavailable or rejects `deflate-raw` the writer silently switches to `'store'` rather than
claiming method 8 over uncompressed bytes. In Node, `nodeDeflater(level)` gives you zlib and a level — level 1 is roughly 2–3× faster
than the default on xlsx-shaped XML:
```ts
import { createWorkbookWriter, nodeDeflater, toFile } from '@jetstreamapp/simple-excel/node';
const workbook = createWorkbookWriter(toFile('out.xlsx'), { deflater: nodeDeflater(1) });
```
:::caution
A stored entry is streamed like any other, so its sizes follow the data in a data descriptor. Excel, LibreOffice,
openpyxl and calamine read that, but **SheetJS 0.20.3 does not** ("Bad compressed size"). Avoid
`compression: 'store'` when SheetJS reads your files. Every current browser and Node 20.12+ compress, so the automatic
fallback only happens on older platforms.
:::
`createDeflater` is the default `DeflaterFactory`: the platform `CompressionStream('deflate-raw')`, or a
pass-through when the entry is stored or the platform cannot compress. A custom `deflater` has the same shape. The
writer calls it once per zip entry as `(onChunk, { method })`, where `method` is `'deflate' | 'store'`, and it
returns a `Deflater`: `push(chunk)`, `finish()`, `abort(reason?)` and the `bytesIn` / `bytesOut` counters. It must
hand compressed bytes to `onChunk` in order, awaiting each call, and `finish()` must resolve only after the last
`onChunk` has settled.
### Deterministic output
```ts
const workbook = createWorkbookWriter(sink, { deterministic: true });
```
Fixes the zip entry timestamps and the `docProps` dates, so the same rows produce byte-identical output. The
repository's golden-bytes suite depends on this; it is also what you want for content-addressed caching or for
diffing two exports.
:::caution
Determinism holds for one deflate implementation, not across engines. The compressed stream comes from the
platform's `CompressionStream('deflate-raw')`, and each engine tunes zlib differently: the same 20,000-row
workbook is 1,654,492 bytes in Chromium, 1,712,097 in Firefox and 1,686,065 in WebKit
(`npm run smoke:browsers`). The XML inside is identical everywhere — only the compressed container differs — so
compare hashes between runs on the same engine, or write with `compression: 'store'`.
:::
### Progress, abort and cancellation
```ts
const controller = new AbortController();
const workbook = createWorkbookWriter(sink, {
signal: controller.signal,
onProgress: ({ sheet, rows, bytesOut }) => updateUi(sheet, rows, bytesOut),
onCellTruncated: count => console.warn(`${count} cells truncated`),
});
```
- `onProgress` fires every 5,000 rows and once at each sheet close.
- `signal` is checked on every `writeRow`, `addSheet`, `registerStyle` and `close`. Once it has fired, the next
call throws `ABORTED` and the sink is torn down.
- `workbook.abort(reason?)` does the same thing imperatively. Call it in a `catch` so a half-written stream is not
left open.
- Any failure while writing (a sink that rejects, a compressor that fails, a value the writer refuses mid-row)
aborts the sink once, and every later call rejects with that same, original error (`EC-WRITER-FAILURE-STICKY`).
Mistakes that write nothing, such as a refused `registerStyle`, `addSheet` or `merge`, are thrown to the caller
and leave the workbook usable.
```ts
try {
await writeEverything();
} catch (error) {
await workbook.abort(error);
throw error;
}
```
### Long cells: the 32,767-character limit
Excel refuses a cell longer than 32,767 characters. The writer applies a policy rather than producing a file that
opens with a repair prompt (`EC-CELL-32767-LIMIT`):
| Option | Values | Default |
| ------------------ | ------------------------- | ------------------ |
| `cellOverflow` | `'truncate' \| 'throw'` | `'truncate'` |
| `truncationSuffix` | string | `'...(truncated)'` |
| `onCellTruncated` | `(count: number) => void` | — |
With `'truncate'`, the text is cut so that the suffix still fits inside the limit, never between the two halves of
an emoji (`EC-TRUNCATION-SURROGATE`). `onCellTruncated` is called **once**, after `workbook.close()` has finished
the file, with the workbook's total, and only when something was truncated; the same count is in the final result
as `truncatedCells`. An exception thrown from the callback rejects `close()` even though the file is complete. With `'throw'` a long cell raises `CELL_TOO_LONG`.
### The result
`workbook.close()` resolves to a `WorkbookWriteResult`:
```ts
interface WorkbookWriteResult {
readonly bytes: number;
readonly sheets: readonly { name: string; rows: number; columns: number }[];
readonly truncatedCells: number;
readonly sharedStrings: { count: number; uniqueCount: number; frozen: boolean };
}
```
`bytes` is the size of the finished archive, `sheets` carries the sanitized name and the actual extent of each
sheet, and `truncatedCells` is the workbook-wide truncation count. `sheet.close()` returns the same per-sheet
summary earlier, if you need it before the workbook finishes.
### All writer options
| Option | Type | Default |
| ------------------ | --------------------------------------- | ---------------------- |
| `strings` | `'auto' \| 'inline' \| 'shared'` | `'inline'` |
| `sstBudget` | `{ maxUnique?, maxChars?, maxLength? }` | 65,536 / 16 Mi / 256 |
| `zip64` | `'auto' \| boolean` | `'auto'` |
| `compression` | `'deflate' \| 'store'` | `'deflate'` |
| `deflater` | `DeflaterFactory` | platform |
| `deterministic` | `boolean` | `false` |
| `dates` | `'local' \| 'utc'` | `'local'` |
| `date1904` | `boolean` | `false` |
| `cellOverflow` | `'truncate' \| 'throw'` | `'truncate'` |
| `truncationSuffix` | `string` | `'...(truncated)'` |
| `onCellTruncated` | `(count: number) => void` | — |
| `onProgress` | `(progress: WriteProgress) => void` | — |
| `signal` | `AbortSignal` | — |
| `properties` | `{ creator?, title?, created? }` | creator `simple-excel` |