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)** | 2–114 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` = **2.3 KB**, `{ readXlsx }` from
`hucre/xlsx` = **32 KB**, `{ readXlsx, writeXlsx }` = **64 KB**, the entire
library (`export * from "hucre"`) = **114 KB**.
‡ `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
```ts
import { writeXlsx } from "hucre/xlsx"
const buffer = await writeXlsx({
sheets: [
{
name: "Report",
columns: [
{ header: "Date", key: "date", width: 15, numFmt: "yyyy-mm-dd" },
{ header: "Revenue", key: "revenue", width: 15, numFmt: "$#,##0.00" },
{ header: "Active", key: "active", width: 10 },
],
data: [
{ date: new Date("2026-01-15"), revenue: 12500, active: true },
{ date: new Date("2026-01-16"), revenue: 8900, active: false },
],
freezePane: { rows: 1 },
autoFilter: { range: "A1:C3" },
},
],
defaultFont: { name: "Calibri", size: 11 },
})
```
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 (13 rule types — see the footnote above), 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
```ts
const buffer = await writeXlsx({
sheets: [
{
name: "Sheet1",
rows: [
["Status", "Quantity"],
["active", 10],
],
dataValidations: [
{
type: "list",
values: ["active", "inactive", "draft"],
range: "A2:A100",
showErrorMessage: true,
errorTitle: "Invalid",
errorMessage: "Pick from the list",
},
{
type: "whole",
operator: "between",
formula1: "0",
formula2: "1000",
range: "B2:B100",
},
],
},
],
})
```
### 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:
```ts
import { streamXlsxRows, writeXlsxStream, XlsxStreamWriter } from "hucre/xlsx"
// Stream read — async generator yields rows one at a time
for await (const row of streamXlsxRows(buffer)) {
console.log(row.index, row.values)
}
// True streaming from a ReadableStream — the ZIP is parsed front-to-back
// from local file headers, so the whole archive is never buffered. Only
// the small metadata parts (content types, rels, workbook, shared strings,
// styles) are read up front; the target worksheet is piped straight into
// the SAX parser. (Falls back to buffering only for archives whose layout
// rules out single-pass streaming — e.g. ZIP data descriptors, or shared
// strings stored after the worksheet.)
const res = await fetch("https://example.com/huge.xlsx")
for await (const row of streamXlsxRows(res.body!)) {
console.log(row.index, row.values)
}
// Cap the number of rows yielded (preview / sampling). The underlying
// ZIP/SAX stream is cancelled once the cap is reached, so very large
// sheets stay cheap.
for await (const row of streamXlsxRows(buffer, { maxRows: 100 })) {
console.log(row.index, row.values)
}
// Filter to an A1 range. Rows outside the row span are skipped; cells
// outside the column span are masked to `null` (column indexes stay
// stable). Parsing stops once a row past the end-row is observed.
for await (const row of streamXlsxRows(buffer, { range: "B2:D1000" })) {
// row.values[0] === null (column A is outside)
// row.values[1..3] carry B/C/D
}
// Stream write — the workbook is emitted as bytes while rows are pulled
// from the source, so peak memory doesn't grow with the row count.
function* rows() {
for (let i = 0; i < 5_000_000; i++) yield [i + 1, Math.random()]
}
return new Response(
writeXlsxStream(
rows(), // any Iterable or AsyncIterable — a DB cursor, another stream, …
{
name: "BigData",
columns: [{ header: "ID" }, { header: "Value" }],
freezePane: { rows: 1 },
},
),
{
headers: {
"content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
},
)
// Incremental write — serializes each row on arrival, but holds the
// serialized parts until finish() returns one buffer. Use it when you
// need a Uint8Array anyway; use writeXlsxStream for constant memory.
const writer = new XlsxStreamWriter({
name: "BigData",
columns: [{ header: "ID" }, { header: "Value" }],
freezePane: { rows: 1 },
})
for (let i = 0; i < 100_000; i++) {
writer.addRow([i + 1, Math.random()])
}
const buffer = await writer.finish()
```
#### Which writer to use
| | `writeXlsxStream()` | `XlsxStreamWriter` |
| ----------- | ------------------------------- | --------------------------------- |
| Output | `ReadableStream` | `Promise` |
| Rows | pulled from an (async) iterable | pushed via `addRow` / `addObject` |
| Peak memory | O(distinct styles) — flat | O(data) |
| Strings | inline by default | shared string table |
Measured on 5 columns of mixed text/number/date data, writing to a sink
that discards the bytes (Node 24):
| Rows | `writeXlsxStream` peak heap | `XlsxStreamWriter` peak heap |
| --------: | --------------------------: | ---------------------------: |
| 300,000 | 41 MB | 328 MB |
| 1,000,000 | 67 MB | 1,037 MB |
| 3,000,000 | 70 MB | not viable |
#### One surface across the incremental writers
`XlsxStreamWriter`, `CsvStreamWriter`, and `NdjsonStreamWriter` share one
vocabulary, so a format-agnostic export helper can be written once:
| Method | Behaviour |
| ----------------- | ----------------------------------------------------------------- |
| `addRow(values)` | Append positional values |
| `addObject(item)` | Append an object, projected through the writer's column order |
| `finish()` | Close the writer and return its output (`string` or `Uint8Array`) |
| `toStream()` | Output as a `ReadableStream` |
Two caveats worth stating plainly:
- `toStream()` on `XlsxStreamWriter` and `CsvStreamWriter` **does not bound
memory**. Both buffer everything until `finish()`; the stream just hands
you the finished bytes. `writeXlsxStream` / `writeCsvStream` are the
constant-memory paths. `NdjsonStreamWriter.toStream()` is the only live
drain — it releases rows as they are enqueued and stays open until
`finish()`.
- `NdjsonStreamWriter.addRow` needs `columns` (`new NdjsonStreamWriter({
columns: [...] })`), because NDJSON rows are objects and positional
values have no key names otherwise. It throws rather than guessing.
Its old `write()` / `end()` are kept as `@deprecated` aliases.
Streaming trade-offs worth knowing:
- Strings are written inline (`t="inlineStr"`) by default. A shared
string table has to live in memory until the workbook closes, which
would undo the constant-memory guarantee — pass `inlineStrings: false`
when the data is repetitive and file size matters more.
- Part sizes aren't known when their ZIP headers go out, so entries carry
trailing ZIP data descriptors and `[Content_Types].xml` is written last.
That's the same layout `archiver`/`zip-stream` emit, which is what
ExcelJS's own streaming writer produces. Verified against `hucre`'s
reader, ExcelJS, and `unzip`.
- ZIP64 records are off by default, which caps any single part — and the
whole archive — at 4 GiB. Crossing that throws rather than writing a
broken file. Pass `zip64: true` to lift it (see below).
- Compression uses the platform `CompressionStream`. Without it, parts are
stored uncompressed rather than buffered.
#### ZIP64 — past 4 GiB
Reading ZIP64 archives needs no configuration: `readXlsx`, `openXlsx`, and
`streamXlsxRows` resolve ZIP64 EOCD records and per-entry extra fields
transparently, whether the producer escaped every field or only the ones
that actually overflowed.
Writing is opt-in, because part sizes aren't known when their headers go
out — it can't be decided per entry mid-stream:
```ts
const stream = writeXlsxStream(rows, { name: "Huge", columns, zip64: true })
```
Turn it on when one worksheet's XML may cross 4 GiB — very wide sheets
near the per-sheet row cap can. ZIP64 archives need a ZIP64-aware
consumer; the output here is verified against Info-ZIP's `unzip`,
ExcelJS, and `hucre`'s own reader.
#### Auto-split past Excel's row limit
Pass `maxRowsPerSheet` to spill into `{name}_2`, `{name}_3`, … when the
data crosses Excel's 1,048,576-row hard limit (default). The captured
header row is repeated on every rolled sheet.
Both writers do this.
```ts
import { XlsxStreamWriter, XLSX_MAX_ROWS_PER_SHEET } from "hucre/xlsx"
const writer = new XlsxStreamWriter({
name: "BigData",
columns: [
{ key: "id", header: "ID" },
{ key: "v", header: "Value" },
],
maxRowsPerSheet: 1_000_000, // optional override; default = 1_048_576
repeatHeaders: true, // default
})
for (let i = 0; i < 3_000_000; i++) writer.addRow([i + 1, Math.random()])
// → BigData, BigData_2, BigData_3
const buf = await writer.finish()
```
### Password Protection
Read and write password-protected XLSX workbooks (ECMA-376 **Agile**
encryption — the Excel 2010+ scheme). Encryption is built on the
platform's WebCrypto, so it stays zero-dependency and runs in Node, Deno,
Bun, Cloudflare Workers, and browsers.
```ts
import { writeXlsx, readXlsx, readObjects, EncryptedFileError, DecryptionError } from "hucre"
// Write an encrypted workbook
const encrypted = await writeXlsx({
sheets: [{ name: "Secret", rows: [["pin", 1234]] }],
encryption: { password: "hunter2" },
})
// Read it back with the password
const wb = await readXlsx(encrypted, { password: "hunter2" })
// Works through every read entry point: read(), readObjects(), streamXlsxRows()
const { data: rows } = await readObjects(encrypted, { password: "hunter2" })
// Without a password an encrypted file throws EncryptedFileError;
// with the wrong password it throws DecryptionError.
try {
await readXlsx(encrypted)
} catch (e) {
if (e instanceof EncryptedFileError) console.log("needs a password")
}
```
The roundtrip path takes the same option: `await saveXlsx(wb, { encryption: { password } })`.
The key-derivation iteration count defaults to Excel's 100000; lower it via
`encryption: { password, spinCount }` when the speed/security trade-off calls for it.
### XLSB (Binary Excel) — read
Read `.xlsb` (Excel Binary Workbook) files — the binary package format
that's smaller and faster to open than `.xlsx`. `read()` auto-detects it,
or call `readXlsb` directly:
```ts
import { read, readXlsb } from "hucre"
const wb = await readXlsb(bytes) // sheet names + typed cell values
const same = await read(bytes) // auto-detected (XLSX vs XLSB vs ODS)
```
Decodes shared strings, RK / floating-point numbers, inline strings,
booleans, error codes, cached formula values, merged cells, and dates (via
the binary style table, honouring the workbook's own 1900/1904 date
system). Read-only; password-protected `.xlsb` also decrypts with
`{ password }`.
### XLS (Legacy Excel 97-2003) — read
Read legacy `.xls` (BIFF8) files — the OLE2/CFB binary format from Excel
97-2003. `read()` auto-detects it, or call `readXls`:
```ts
import { read, readXls } from "hucre"
const wb = await readXls(bytes)
const same = await read(bytes) // auto-detected
```
Decodes the shared-string table (with CONTINUE spanning), RK / MULRK /
number / boolean / error cells, labels, cached formula values, dates, and
merged cells. Read-only.
Both legacy readers surface sheet names, cell values, and merges — and
nothing else. Formulas arrive as their **cached value**, never as formula
text; styles, column widths, row heights, sheet visibility, named ranges
and workbook properties are not read. Converting `.xls` / `.xlsb` to
`.xlsx` through hucre is therefore a values-and-sheet-names conversion,
not a faithful copy.
### ODS (OpenDocument)
```ts
import { readOds, writeOds } from "hucre/ods"
const wb = await readOds(buffer)
const ods = await writeOds({ sheets: [{ name: "Sheet1", rows: [["Hello", 42]] }] })
```
#### What ODS carries
The ODS reader and writer model the same narrow set, so **ODS → ODS is
lossless**. The loss shows up when converting _into_ ODS from a format
that models more, which in practice means XLSX.
| | ODS |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Cell values — string, number, boolean, date | yes |
| Formulas, including the `of:=` translation | yes |
| Merged cells | yes |
| Cell styles | **six facets only**: bold, italic, font size, font colour, background colour, number format |
| Document properties | six fields: title, subject, creator, description, keywords, created |
| Borders, alignment, font name, underline, strikethrough | no |
| Column widths, row heights, hidden rows and columns | no |
| Freeze and split panes, sheet views, tab colour | no |
| Data validation, conditional formatting, auto-filter | no |
| Named ranges, tables, images, page setup, sheet protection | no |
| Hidden sheets | no |
So `readXlsx` → `writeOds` keeps values, formulas, merges and those six
style facets, and drops the rest **silently** — there is no warning,
because from ODS's side nothing was lost.
Two consequences worth knowing before you rely on it:
- Two cell styles that differ _only_ in an unsupported property (say two
different borders) are treated as the same style and share one
definition.
- A cell styled entirely with unsupported properties gets no style
reference at all, rather than an empty one.
The reader reads `content.xml` and `meta.xml`. It does not open
`styles.xml` or `settings.xml`, which is where LibreOffice puts named and
default cell styles and all page setup — so a LibreOffice-authored file
reads back with its direct formatting only.
Widening the ODS model is open work; this table is the current contract,
not a permanent one.
### Round-trip Preservation
Open, modify, save — without losing charts, macros, or features hucre doesn't natively handle:
```ts
import { openXlsx, saveXlsx } from "hucre/xlsx"
const workbook = await openXlsx(buffer)
workbook.sheets[0].rows[0][0] = "Updated!"
const output = await saveXlsx(workbook) // Charts, VBA, themes preserved
```
Preservation is a property of **this** path. `openXlsx`/`saveXlsx` copies
every part it does not regenerate byte-for-byte, so anything hucre does
not model survives. `readXlsx`/`writeXlsx` is the authoring path: it
rebuilds the workbook from the model, and only what `WriteSheet` and
`WriteOptions` describe comes out the other side. Reading an `.xlsm` with
`readXlsx` and writing it back leaves no `xl/vbaProject.bin` — use
`openXlsx`/`saveXlsx` to edit a macro-enabled workbook.
To attach a macro project to a workbook you are authoring, pass the
binary to `writeXlsx` — the output becomes macro-enabled:
```ts
await writeXlsx({
sheets: [{ name: "Sheet1", rows }],
vbaProject: await readFile("vbaProject.bin"), // output is .xlsm
})
```
### External Workbook References
`[N]Sheet!Ref` references to other workbooks are read into a typed
`workbook.externalLinks` model and re-declared on roundtrip — without
this the `` block and the matching relationship
disappear from `xl/workbook.xml.rels`, leaving Excel with orphan
`externalLinkN.xml` parts that it ignores.
```ts
import { readXlsx, parseExternalLink } from "hucre"
const wb = await readXlsx(buf)
for (const link of wb.externalLinks ?? []) {
console.log(link.target, link.targetMode, link.sheetNames)
for (const sheet of link.sheetData) {
for (const cell of sheet.cells) {
// cell.type ∈ "n" | "s" | "b" | "e" | "str"
console.log(cell.ref, cell.type, cell.value)
}
}
}
// Standalone parser when you already have the XML strings
const link = parseExternalLink(externalLinkXml, externalLinkRelsXml)
```
The 1-based index in `workbook.externalLinks` matches the `[N]` prefix
used by formulas like `[1]Sheet1!A1`. Cached `t="s"` values stay as
shared-string indices into the _external_ workbook (which hucre cannot
dereference); resolved strings live in the linked file.
### Cell-Embedded Images (WPS DISPIMG)
WPS Office (and recent Excel versions) embed images inside cells via a
workbook-level `xl/cellimages.xml` registry referenced from
`=_xlfn.DISPIMG("", 1)` formulas. hucre reads the registry into a
typed `workbook.cellImages` array and re-declares the part on
`saveXlsx` so the DISPIMG link survives round-trips — without this the
relationship and content-type override are dropped and the formula
loses its target.
```ts
import { readXlsx } from "hucre"
const wb = await readXlsx(buf)
for (const img of wb.cellImages ?? []) {
console.log(img.id, img.type, img.description, img.data.byteLength)
}
// Standalone parsers when you already have the XML strings.
import { parseCellImages, assembleCellImages, REL_CELL_IMAGES } from "hucre"
const refs = parseCellImages(cellImagesXml)
const images = assembleCellImages(refs, mediaMap)
```
Synthesizing a `cellimages.xml` from a model on a fresh `writeXlsx`
call (without an existing source file) is a follow-up — for now the
read + roundtrip-preserve side is in place.
### Slicers & Timeline Filters
Slicers (Excel 2010+) and timeline slicers (Excel 2013+) are read into
typed `workbook.slicerCaches` / `workbook.timelineCaches` plus per-sheet
`sheet.slicers` / `sheet.timelines` arrays. On `saveXlsx` the slicer /
timeline parts are re-declared in `[Content_Types].xml`, the workbook
rels, the workbook `extLst`, and each sheet's rels — without this
roundtrip Excel saw the cache parts as orphans and dropped the
slicers / timelines on next open.
```ts
import { readXlsx } from "hucre"
const wb = await readXlsx(buf)
// Workbook-level cache definitions.
console.log(wb.slicerCaches) // SlicerCache[] (pivot-table or table source)
console.log(wb.timelineCaches) // TimelineCache[]
// Per-sheet slicer / timeline instances.
for (const sheet of wb.sheets) {
for (const s of sheet.slicers ?? []) console.log(s.name, s.cache, s.caption)
for (const t of sheet.timelines ?? []) console.log(t.name, t.cache, t.level)
}
// Standalone parsers when you already have the XML strings.
import { parseSlicers, parseSlicerCache, parseTimelines, parseTimelineCache } from "hucre"
```
The worksheet body's `` / `` extension
blocks are not yet re-injected when the worksheet XML is regenerated —
Excel still sees the parts as wired up via rels and content-types so
they survive the roundtrip, but synthesizing slicers from a fresh
write is a follow-up.
### Pivot Tables
Pivot tables (`xl/pivotTables/pivotTableN.xml`) and their workbook-level
cache definitions (`xl/pivotCache/pivotCacheDefinitionN.xml` plus the
companion `pivotCacheRecordsN.xml`) are read into typed
`workbook.pivotCaches` and per-sheet `sheet.pivotTables` arrays. On
`saveXlsx` the pivot parts are re-declared in `[Content_Types].xml`,
the workbook rels, the workbook `` block, and each host
sheet's rels — Excel previously saw the pivot parts as orphans and
dropped the tables on next open.
```ts
import { readXlsx } from "hucre"
const wb = await readXlsx(buf)
// Workbook-level cache definitions.
for (const cache of wb.pivotCaches ?? []) {
console.log(cache.cacheId, cache.sourceSheet, cache.sourceRef, cache.fieldNames)
}
// Per-sheet pivot table instances.
for (const sheet of wb.sheets) {
for (const pt of sheet.pivotTables ?? []) {
console.log(pt.name, pt.location, pt.cacheId)
for (const f of pt.fields) {
console.log(" ", f.name, f.axis, f.function)
}
}
}
// Standalone parsers when you already have the XML strings.
import { parsePivotTable, parsePivotCacheDefinition, attachPivotCacheFields } from "hucre"
```
`PivotTable.cacheId` matches the workbook-level `cacheId` rather than a
per-table relationship, so reordering `Workbook.pivotCaches` keeps the
links sound.
`writeXlsx` can also author pivot tables from scratch via the per-sheet
`pivotTables` field. Hucre emits the pivot cache (definition + cached
records), the pivot layout, and every required relationship and content
type. The numeric layout (row totals, grand totals, value cells) is left
for Excel to compute on first open via the existing `fullCalcOnLoad`
recompute — Phase 1 ships the structural skeleton, not pre-computed
value cells.
```ts
import { writeXlsx } from "hucre"
const xlsx = await writeXlsx({
sheets: [
{
name: "Data",
rows: [
["Region", "Product", "Revenue"],
["EU", "A", 100],
["EU", "B", 50],
["US", "A", 200],
["US", "B", 75],
],
},
{
name: "Pivot",
pivotTables: [
{
name: "SalesPivot",
sourceSheet: "Data",
rows: ["Region"],
columns: ["Product"],
values: [{ field: "Revenue", function: "sum" }],
},
],
},
],
})
```
Supported aggregation functions: `sum` (default), `count`, `average`,
`max`, `min`, `product`, `countNums`, `stdDev`, `stdDevp`, `var`,
`varp`. Pivots can source from their own sheet (omit `sourceSheet`)
or any sibling sheet, and accept either `rows` (raw 2-D arrays) or
`columns` + `data` (object-style) source shapes.
### Charts
Charts (`xl/charts/chartN.xml` plus the optional `styleN.xml` /
`colorsN.xml` companions) round-trip through three layers:
- `parseChart(xml)` / `readXlsx().sheets[].charts[]` — surfaces a
read-side `Chart` record (kinds, title, series, axes, legend, data
labels, fills / borders, manual layout, view3D, etc.).
- `writeXlsx({ sheets: [{ charts: [SheetChart] }] })` — emits the
chart parts and re-anchors the drawing in the regenerated worksheet
body so Excel never sees the chart as an orphan.
- `cloneChart(source, options)` — bridges the two: it converts a
parsed `Chart` into a writable `SheetChart`, applies a typed override
bag (`undefined` = inherit, `null` = drop, value = replace), and
returns the result ready for `writeXlsx`.
`getCharts(workbook)` flattens every chart anchored on the workbook's
sheets into a single array; `addChart(sheet, chart)` is the symmetric
writer-side helper that appends a `SheetChart` to a `WriteSheet`.
#### Capability matrix
The table summarises which knobs flow through each layer. "Read"
means `parseChart` surfaces the field on `Chart` (or
`ChartAxisInfo` / `ChartDataLabelsInfo` / `ChartDataTable` for nested
slots); "Write" means `writeXlsx` emits it from the matching
`SheetChart` field; "Clone" means `cloneChart` carries it through
with the standard `undefined` / `null` / value override grammar. `—`
means the layer does not cover it.
The write side is narrower than the read side on chart kinds:
`WriteChartKind` is `bar | column | line | pie | doughnut | scatter |
area`, and `SheetChart.type` holds a single kind — so combo charts
(several chart-type elements in one plot area) parse fine but cannot be
authored. `writeXlsx` throws on any other kind; `cloneChart` throws on a
bubble / radar / surface / stock source unless `options.type` coerces it
to a writable kind.
| Family | Read | Write | Clone |
| ---------------------------------------------------------------------------------------------------------------------------- | :--: | :---: | :---: |
| Chart kinds — bar / column / line / pie / doughnut / area / scatter | x | x | x |
| Chart kinds — bubble / radar / surface / stock / 3-D variants, and combo (multi-kind plot areas) | x | — | — |
| Title text + visibility (`title`, `showTitle`, `autoTitleDeleted`) | x | x | x |
| Title typography (size / bold / italic / strike / underline / color / family) | x | x | x |
| Title rotation, manual layout | x | x | x |
| Title fill / border color / border width / border dash | x | x | x |
| Legend position, overlay, font knobs, manual layout | x | x | x |
| Legend fill / border color / border width / border dash, per-entry hide | x | x | x |
| Plot-area manual layout, fill / border color / border width / border dash | x | x | x |
| Chart-space fill / border color / border width / border dash, rounded corners, style preset | x | x | x |
| Axis title text + typography (per-axis), rotation, manual layout, fill / border knobs | x | x | x |
| Axis label rotation, font size, bold / italic / underline / strike, color, font family | x | x | x |
| Axis scale (min / max / logBase / majorUnit / minorUnit), reverse, hidden, crosses, dispUnits | x | x | x |
| Axis number format, tick marks, tick-label position / skip, label offset / alignment | x | x | x |
| Axis gridlines (major / minor) | x | x | x |
| Series name, value/category refs, fill color, line stroke (width / dash) | x | x | x |
| Series markers (symbol / size / fill / outline) | x | x | x |
| Data labels — chart-level + per-series (show\*, position, separator, number format, leader lines, typography, fill / border) | x | x | x |
| Data table (showHorzBorder / showVertBorder / showOutline / showKeys, typography, fill / border) | x | x | x |
| Bar / column gap-width and overlap | x | x | x |
| Pie / doughnut hole size, vary-colors, first-slice angle | x | x | x |
| Display blanks-as, plot-vis-only, show-data-labels-over-max, lang, date1904 | x | x | x |
| 3D walls / floor / view3D | x | x | x |
| Chart-space protection block | x | x | x |
| Anchor (twoCellAnchor / oneCellAnchor) + relative offsets | x | x | x |
#### Read side — `parseChart` / `getCharts`
```ts
import { getCharts, openXlsx, parseChart } from "hucre"
const wb = await openXlsx(buf)
for (const { sheetName, chart } of getCharts(wb)) {
console.log(sheetName, chart.kinds, chart.title)
// e.g. "Sales" ["bar"] "Quarterly Sales"
console.log(chart.anchor) // { from: { row: 1, col: 3 }, to: { row: 16, col: 10 } }
console.log(chart.axes?.x?.title, chart.axes?.y?.scale)
for (const s of chart.series ?? []) {
console.log(s.kind, s.name, s.valuesRef, s.color, s.dataLabels)
}
}
// Standalone parser when you already have the chart XML.
const chart = parseChart(xml)
```
`Chart.kinds` lists every chart-type element under `` in
declaration order, so combo charts surface as e.g. `["bar", "line"]`.
`Chart.series` mirrors the field shape that `ChartSeries` accepts on
the write side — a parsed series can be fed straight back into
`SheetChart.series`. Bubble/scatter `` series (literal
embedded data, no formula) intentionally surface no
`valuesRef` / `categoriesRef`. `Chart.anchor` mirrors
`SheetChart.anchor`: `twoCellAnchor` charts surface both `from` and
`to`, `oneCellAnchor` charts surface `from` only,
`absoluteAnchor` charts (EMU-positioned, no cell anchor) report
`anchor` as `undefined`.
#### Write side — `writeXlsx` + `addChart`
```ts
import { addChart, writeXlsx } from "hucre"
const dashboard = {
name: "Dashboard",
rows: [
["Quarter", "Revenue", "Forecast"],
["Q1", 12000, 11500],
["Q2", 15500, 15000],
["Q3", 14000, 14500],
["Q4", 17800, 17200],
],
}
addChart(dashboard, {
type: "column",
title: "Quarterly Revenue",
titleFontSize: 14,
titleBold: true,
titleColor: "1F77B4",
titleBorderColor: "1F77B4",
titleBorderWidth: 1.5,
titleBorderDash: "dash",
series: [
{ name: "Revenue", values: "B2:B5", categories: "A2:A5", color: "1F77B4" },
{ name: "Forecast", values: "C2:C5", categories: "A2:A5", color: "FF7F0E" },
],
axes: {
x: { title: "Quarter" },
y: { title: "Revenue (USD)", numberFormat: { formatCode: "$#,##0" } },
},
legend: "bottom",
legendFillColor: "F2F2F2",
legendBorderDash: "dot",
plotAreaBorderColor: "DDDDDD",
plotAreaBorderWidth: 0.75,
dataLabels: { showValue: true, position: "outEnd", fontSize: 9 },
anchor: { from: { row: 6, col: 0 }, to: { row: 22, col: 7 } },
})
const xlsx = await writeXlsx({ sheets: [dashboard] })
```
Every knob the writer accepts lives on the `SheetChart` type — see
[`SheetChart` in `src/xlsx/chart/types.ts`](https://github.com/productdevbook/hucre/blob/main/src/xlsx/chart/types.ts) for the full
field list with per-field semantics, OOXML mapping, and clamp /
default behavior. The capability table above lists the families
covered.
#### Clone — `cloneChart(source, options)`
`cloneChart` takes a parsed `Chart` and a `CloneChartOptions` override
bag and returns a `SheetChart` ready for `writeXlsx`. Every override
field uses the same grammar:
- `undefined` (or omitted) — inherit the source value.
- `null` — drop the source value (writer falls back to the OOXML
default for that slot).
- a typed value — replace the source value (after running through the
same clamp / normalize as the writer).
Per-series overrides are supplied as a positional `series` array;
each entry merges with the source series at the matching index.
```ts
import { cloneChart, openXlsx, parseChart, writeXlsx } from "hucre"
const wb = await openXlsx(templateBytes)
const sourceChart = wb.sheets[0].charts?.[0]
if (!sourceChart) throw new Error("template missing chart")
// Re-bind the template chart to a new data range and recolour series.
const cloned = cloneChart(sourceChart, {
anchor: { from: { row: 0, col: 0 }, to: { row: 18, col: 8 } },
title: "FY 2026 Revenue by Region",
titleColor: "1F77B4",
legend: "right",
series: [
{ values: "B2:B13", categories: "A2:A13", color: "1F77B4" },
{ values: "C2:C13", categories: "A2:A13", color: null /* drop template tint */ },
],
})
const out = await writeXlsx({
sheets: [{ name: "Sheet1", rows: dashboardRows, charts: [cloned] }],
})
```
A common pattern is "template -> override -> write" — keep one
chart-of-each-flavour template and use `cloneChart` to spawn many
variants without re-encoding the whole `` tree by
hand. See [`CloneChartOptions` in
`src/xlsx/chart-clone.ts`](https://github.com/productdevbook/hucre/blob/main/src/xlsx/chart-clone.ts) for the full
override surface (every knob on `SheetChart` has a matching
`undefined | null | value` override field).
#### Walking and adding charts
```ts
import { addChart, getCharts, openXlsx, writeXlsx } from "hucre"
const wb = await openXlsx(templateBytes)
// Read side — find every chart in a template workbook.
for (const { sheetName, chart } of getCharts(wb)) {
console.log(sheetName, chart.kinds, chart.title)
}
// Write side — declarative chart attachment.
const dashboard = { name: "Dashboard", rows: dashboardRows }
addChart(dashboard, {
type: "column",
title: "Q1 Revenue",
series: [{ name: "Revenue", values: "B2:B13", categories: "A2:A13" }],
anchor: { from: { row: 14, col: 0 } },
})
await writeXlsx({ sheets: [dashboard] })
```
Charts also survive the **roundtrip** (`openXlsx` → modify → `saveXlsx`),
not just the fresh `writeXlsx` path. A chart attached to a newly added
sheet — or carried across workbooks by `copySheetToWorkbook` — is
serialized into proper `xl/charts/chartN.xml` parts with their drawing
relationships on save:
```ts
import { copySheetToWorkbook, getCharts, openXlsx, saveXlsx } from "hucre"
const template = await openXlsx(templateBytes)
const report = await openXlsx(reportBytes)
// Copy a chart-bearing sheet from the template into the report workbook…
copySheetToWorkbook(template.sheets[0], report, "Dashboard")
// …and saveXlsx re-emits the chart parts (not just the cell data).
const out = await saveXlsx(report)
console.log(getCharts(await openXlsx(out)).length) // includes the copied chart
```
The roundtrip path serializes model charts for sheets that don't already
own a drawing (new or copied sheets); to add a chart to a sheet that
already carries one, compose it through the fresh `writeXlsx` path
instead.
### Unified API
Auto-detect format and work with simple helpers:
```ts
import { read, write, readObjects, writeObjects } from "hucre"
// Auto-detect XLSX vs ODS
const wb = await read(buffer)
// Quick: file → objects, plus the headers they were keyed by. Same
// { data, headers } shape — and the same options — as readXlsxObjects /
// readOdsObjects / parseCsvObjects.
const { data: products, headers } = await readObjects<{ name: string; price: number }>(buffer, {
sheet: 0, // index or name (default: 0)
headerRow: 0, // 0-based (default: 0)
skipEmptyRows: true, // default
maxRows: 1000, // optional cap on the returned rows
})
// Quick: objects → XLSX
const xlsx = await writeObjects(products, { sheetName: "Products" })
```
### CLI
```bash
npx hucre convert input.xlsx output.csv
npx hucre convert input.csv output.xlsx
npx hucre convert legacy.xls output.xlsx # .xls / .xlsb read as input
npx hucre inspect file.xlsx
npx hucre inspect file.xlsx --sheet 0
npx hucre validate data.xlsx --schema schema.json
```
Input: `.xlsx`, `.ods`, `.csv`, `.tsv`, `.xls`, `.xlsb`. Output: the first
four — `.xls` and `.xlsb` are read-only formats, and naming one as the
output says so. `convert` carries cell values and sheet names only; from a
legacy binary input that is all there is (see the notes on each format
above).
### Sheet Operations
Manipulate sheet data in memory:
```ts
import { insertRows, deleteRows, cloneSheet, moveSheet } from "hucre"
insertRows(sheet, 5, 3) // Insert 3 rows at position 5
deleteRows(sheet, 0, 1) // Delete first row
const copy = cloneSheet(sheet, "Copy") // Deep clone
moveSheet(workbook, 0, 2) // Reorder sheets
```
### HTML & Markdown Export
```ts
import { toHtml, toMarkdown } from "hucre"
const html = toHtml(workbook.sheets[0], {
hasHeaderRow: true,
styles: true,
classes: true,
})
const md = toMarkdown(workbook.sheets[0])
// | Name | Price | Stock |
// |--------|-------:|------:|
// | Widget | 9.99 | 142 |
```
**Markdown is terminal output, not interchange.** `toMarkdown` truncates any
cell over `maxWidth` characters (default 50) with a `...` suffix and turns
newlines into `
`. Both are one-way, and there is no `fromMarkdown` —
nor will there be. Use CSV or JSON to move data.
### Reading HTML tables
`fromHtml` parses a table — anyone's table, not just hucre's — into a sheet:
```ts
import { fromHtml } from "hucre"
const sheet = fromHtml(scrapedHtml, {
sheetName: "Scraped",
typeInference: true, // default: numbers, booleans and ISO dates from text
preserveLeadingZeros: true, // default: "007" stays a string
})
```
It reads ``, ``, ``, ``, ``, `| `, ` | `,
``, `colspan` and `rowspan`. Nested tables read as the text of the
cell holding them, and markup the scanner cannot finish (an unterminated
comment, a truncated tag) ends the parse there and returns the rows read so
far rather than throwing.
**`fromHtml` is not the inverse of `toHtml`.** HTML export is presentation
output; the table it writes is meant for a browser, and most of what makes it
readable there has no way back into a sheet:
| `toHtml` writes | `fromHtml` reads |
| -------------------------------------------------- | ------------------------- |
| `colspan` / `rowspan` | ✅ `sheet.merges` |
| ``, or a row of all `| ` | ✅ `sheet.a11y.headerRow` |
| `` | ✅ `sheet.a11y.summary` |
| `hucre-num` / `-bool` / `-date` / `-null` classes | ✅ the cell's type |
| inline `style=` (fonts, fills, borders, alignment) | ❌ not read |
| the ` | |