Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Pure TypeScript, works everywhere.
hucre
Zero-dependency spreadsheet engine.
Read & write XLSX, CSV, ODS, JSON, NDJSON, XML. Schema validation, streaming, round-trip preservation. Pure TypeScript, works everywhere.
## Quick Start
```sh
npm install hucre
```
```ts
import { readXlsx, writeXlsx } from "hucre"
// Read an XLSX file
const workbook = await readXlsx(buffer)
console.log(workbook.sheets[0].rows)
// Write an XLSX file
const xlsx = await writeXlsx({
sheets: [
{
name: "Products",
columns: [
{ header: "Name", key: "name", width: 25 },
{ header: "Price", key: "price", width: 12, numFmt: "$#,##0.00" },
{ header: "Stock", key: "stock", width: 10 },
],
data: [
{ name: "Widget", price: 9.99, stock: 142 },
{ name: "Gadget", price: 24.5, stock: 87 },
],
},
],
})
```
## Tree Shaking
Import only what you need:
```ts
import { readXlsx, writeXlsx } from "hucre/xlsx" // XLSX only
import { parseCsv, writeCsv } from "hucre/csv" // CSV only (~2 KB gzipped)
import { readOds, writeOds } from "hucre/ods" // ODS only
import { parseJson, writeNdjson } from "hucre/json" // JSON / NDJSON
import { readXml, writeXml } from "hucre/xml" // Tabular XML
```
## Why hucre?
### vs JavaScript / TypeScript Libraries
| | hucre | SheetJS CE | ExcelJS | xlsx-js-style |
| ----------------------- | -------------------- | ------------- | --------- | ------------- |
| **Dependencies** | 0 | 0\* | 9 | 0\* |
| **Bundle (gzip)** | 4–129 KB† | ~300 KB | ~500 KB | ~300 KB |
| **ESM native** | Yes | Partial | No (CJS) | Partial |
| **TypeScript** | Native | Bolted-on | Bolted-on | Bolted-on |
| **Edge runtime** | Yes | No | No | No |
| **CSP compliant** | Yes | Yes | No (eval) | Yes |
| **npm published** | Yes | No (CDN only) | Stale | Yes |
| **Read + Write** | Yes | Yes (Pro $) | Yes | Yes |
| **Styling** | Yes | No (Pro $) | Yes | Yes |
| **Cond. formatting** | 13 types‡ | No (Pro $) | Yes | No |
| **Stream read + write** | Yes | CSV only | Yes | CSV only |
| **ODS support** | Yes§ | Yes | No | Yes |
| **Round-trip** | Yes | Partial | Partial | Partial |
| **Sparklines** | Yes | No | No | No |
| **Tables** | Yes | Yes | Yes | Yes |
| **Images** | Yes | No (Pro $) | Yes | No |
\* SheetJS removed itself from npm; must install from CDN tarball.
† Depends on what you import — hucre is fully tree-shakeable, so there is no
single number. Minified + gzipped, bundled with rolldown against `dist/`:
`{ parseCsv, writeCsv }` from `hucre/csv` = **3.7 KB**, `{ readXlsx }` from
`hucre/xlsx` = **34 KB**, `{ readXlsx, writeXlsx }` = **68 KB**, the entire
library (`export * from "hucre"`) = **129 KB**.
These four are measured by `pnpm size` and pinned in
`scripts/size-budget.json`, so CI fails when one grows past its budget.
They are in the README because they had already drifted once — the
previous figures (2.3 / 32 / 64 / 114 KB) were true when written and were
enforced by nothing.
‡ `cellIs`, `expression`, `colorScale`, `dataBar`, `iconSet`, `containsText`,
`notContainsText`, `beginsWith`, `endsWith`, `containsBlanks`,
`notContainsBlanks`, `duplicateValues`, `uniqueValues` — read and written.
`top10` and `aboveAverage` round-trip in their default form only (the writer
emits no `rank` / `percent` / `bottom` or `aboveAverage` / `equalAverage` /
`stdDev` attributes), and `timePeriod` rules are dropped on read.
§ Values, formulas and merges round-trip in full; cell styling covers six
facets (bold, italic, size, font colour, background colour, number format).
Borders, alignment, column widths, freeze panes, validation, named ranges,
images and page setup are not modelled in either direction, so ODS → ODS is
lossless while XLSX → ODS drops them. See [What ODS carries](#what-ods-carries).
### vs Libraries in Other Languages
| | hucre (TS) | openpyxl (Py) | XlsxWriter (Py) | rust_xlsxwriter | Apache POI (Java) |
| --------------------- | ------------------- | ------------- | --------------- | --------------- | ----------------- |
| **Read XLSX** | Yes | Yes | No | No | Yes |
| **Write XLSX** | Yes | Yes | Yes | Yes | Yes |
| **Streaming** | Read+Write | Read+Write | const_memory | const_memory | SXSSF (write) |
| **Charts** | Round-trip | 15+ types | 9 types | 12+ types | Limited |
| **Pivot tables** | Read + Write (skel) | Read-only | No | No | Limited |
| **Cond. formatting** | 13 types (see ‡) | Yes | Yes | Yes | Yes |
| **Sparklines** | Yes | Yes | Yes | Yes | No |
| **Formula eval** | No | No | No | No | Yes |
| **Multi-format** | XLSX/ODS/CSV | XLSX only | XLSX only | XLSX only | XLS/XLSX |
| **Zero dependencies** | Yes | lxml optional | No | Yes | No |
"Read + Write (skel)" for pivot tables: hucre writes the pivot cache, layout
and relationships, but not the pre-computed value cells — Excel fills those
in on first open. See [Pivot Tables](#pivot-tables).
## Features
### Reading
```ts
import { readXlsx } from "hucre/xlsx"
const wb = await readXlsx(uint8Array, {
sheets: [0, "Products"], // Filter sheets by index or name
readStyles: true, // Parse cell styles
dateSystem: "auto", // Auto-detect 1900/1904
})
for (const sheet of wb.sheets) {
console.log(sheet.name) // "Products"
console.log(sheet.rows) // CellValue[][]
console.log(sheet.merges) // MergeRange[]
}
```
`sheets` also accepts a predicate that runs against lightweight metadata
**before** each worksheet body is parsed — useful for visibility-based
selection without paying the I/O cost of the full read:
```ts
const wb = await readXlsx(buf, {
sheets: (info) => !info.hidden && !info.veryHidden,
})
// info: { name, index, hidden?, veryHidden? }
```
Supported cell types: strings, numbers, booleans, dates, formulas, rich text, errors, inline strings.
### Writing
```
…
```
A row entry may be a value or a cell object, so styling one cell does not
mean naming its position again in a parallel map:
```ts
await writeXlsx({
sheets: [
{
name: "Report",
rows: [
[{ value: "Region", style: { font: { bold: true } } }, "Revenue"],
["EU", { value: 12500, style: { numFmt: "$#,##0.00" } }],
["Total", { formula: "SUM(B2:B2)" }],
],
},
],
})
```
Anything a cell carries works there — `style`, `formula`, `richText`,
`hyperlink`, `checkbox`. `cells` still takes a `"row,col"` map, and wins
where both describe the same position.
Features: cell styles, auto column widths, merged cells, freeze/split panes, auto-filter (with per-column value filters — ``; custom/dynamic/colour criteria are not emitted), data validation, hyperlinks, images (PNG/JPEG/GIF/SVG/WebP), comments, tables, conditional formatting (all 15 rule types, with their dxf styles), named ranges, print settings, page breaks, sheet protection, workbook protection, rich text, shared/array/dynamic formulas, sparklines, textboxes, background images, number formats, hidden sheets, Excel 2024 native checkboxes, HTML/Markdown/JSON/TSV export, template engine.
### Auto Column Width
```ts
const buffer = await writeXlsx({
sheets: [
{
name: "Products",
columns: [
{ header: "Name", key: "name", autoWidth: true },
{ header: "Price", key: "price", autoWidth: true, numFmt: "$#,##0.00" },
{ header: "SKU", key: "sku", autoWidth: true },
],
data: products,
},
],
})
```
Calculates optimal column widths from cell content — font-aware, handles CJK double-width characters, number formats, min/max constraints.
### Data Validation
```
…
```
### Hyperlinks
```ts
const buffer = await writeXlsx({
sheets: [
{
name: "Links",
rows: [["Visit Google", "Go to Sheet2"]],
cells: new Map([
[
"0,0",
{
value: "Visit Google",
type: "string",
hyperlink: { target: "https://google.com", tooltip: "Open Google" },
},
],
[
"0,1",
{
value: "Go to Sheet2",
type: "string",
hyperlink: { target: "", location: "Sheet2!A1" },
},
],
]),
},
],
})
```
For tabular reports, put links **inline in `data` rows** instead of a parallel
`cells` map — keyed by the column's `key`. Use the `link()` helper (or a plain
`{ text, hyperlink, tooltip? }` object). A `#`-prefixed target is treated as an
internal reference (`#Sheet2!A1`).
```ts
import { writeXlsx, link } from "hucre/xlsx"
await writeXlsx({
sheets: [
{
name: "Summary",
columns: [
{ header: "Link", key: "link" },
{ header: "ID", key: "id" },
],
data: [
{ link: link("Open", "https://example.com/items/abc-123"), id: "abc-123" },
{ link: { text: "Open", hyperlink: "https://example.com/items/def-456" }, id: "def-456" },
],
},
],
})
```
### Streaming
Process large files row-by-row without loading everything into memory:
```
…
```
#### Across the formats
```
…
```
| | whole read | whole write | stream read | stream write | incremental writer |
| ------ | :--------: | :---------: | :---------: | :----------: | :----------------: |
| XLSX | ✔ | ✔ | ✔ | ✔ (multi) | ✔ |
| CSV | ✔ | ✔ | ✔ | ✔ | ✔ |
| NDJSON | ✔ | ✔ | ✔ | ✔ | ✔ |
| ODS | ✔ | ✔ | ✔ | ✔ | ✔ |
| XML | ✔ | ✔ | ✔ | ✔ | — |
`writeOdsStream` carries **values, not formatting**, and that follows from
the format: ODF puts `` before the body, so a
style first seen on row 900,000 has nowhere to be declared — the same
shape as the shared-string table, which the XLSX streaming writer answers
with inline strings and ODF has no equivalent for. Column widths and a
header row are carried, because `columns` is known before the first row.
`writeOds` remains the path for a document that needs styling.
`streamXmlRows` differs from `readXml` in one way that follows from
streaming rather than from a choice: **it yields the keys each row
actually has.** `readXml` pads every row to the union of all headers,
which needs the whole document —