# 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