# hucre

> Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Schema validation, streaming, tree-shakeable. Pure TypeScript, works everywhere.

Latest version **1.1.0** (published 2026-08-13) · MIT license · 0 weekly downloads

## Install

```sh
npm install hucre
pnpm add hucre
yarn add hucre
bun add hucre
```

Provides the command `hucre`.

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 1.1.0 |
| Published | 2026-08-13 |
| First published | 2026-03-27 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM |
| Node | >=24 |
| Dependencies | 0 |
| Unpacked size | 2.7 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 2176 |
| Author | productdevbook |
| Maintainers | productdevbook |
| Keywords | csv, esm, excel, export, import, ods, parse, read, spreadsheet, tree-shakeable, typescript, write, xlsx, zero-dependency |

## Links

- npm: https://www.npmjs.com/package/hucre
- Repository: https://github.com/productdevbook/hucre
- Issues: https://github.com/productdevbook/hucre/issues
- Funding: https://github.com/sponsors/productdevbook
- npm.io page: https://npm.io/package/hucre

## Alternatives

- [csv-to-markdown-table](https://npm.io/package/csv-to-markdown-table.md) — 47.0K weekly downloads
- [@sapphire/ratelimits](https://npm.io/package/@sapphire/ratelimits.md) — 4.4K weekly downloads
- [js-csvparser](https://npm.io/package/js-csvparser.md) — 2.0K weekly downloads
- [@adadapted/js-sdk](https://npm.io/package/@adadapted/js-sdk.md) — 251 weekly downloads
- [@grapecity/spread-sheets-sparklines](https://npm.io/package/@grapecity/spread-sheets-sparklines.md) — 103 weekly downloads

## Recent versions

- 1.1.0 (latest) — 2026-08-13
- 1.0.0 — 2026-08-04
- 0.6.2 — 2026-07-21
- 0.6.1 — 2026-06-10
- 0.6.0 — 2026-05-28
- 0.5.1 — 2026-05-11
- 0.5.0 — 2026-05-08
- 0.4.0 — 2026-04-25
- 0.3.0 — 2026-04-06
- 0.2.0 — 2026-04-04
- 0.1.0 — 2026-03-27
- 0.0.1 — 2026-03-27

## README

<p align="center">
  <br>
  <img src=".github/assets/cover.svg" alt="hucre — Zero-dependency spreadsheet engine" width="100%">
  <br><br>
  <b style="font-size: 2em;">hucre</b>
  <br><br>
  Zero-dependency spreadsheet engine.
  <br>
  Read & write XLSX, CSV, ODS, JSON, NDJSON, XML. Schema validation, streaming, round-trip preservation. Pure TypeScript, works everywhere.
  <br><br>
  <a href="https://npmjs.com/package/hucre"><img src="https://img.shields.io/npm/v/hucre?style=flat&colorA=18181B&colorB=34d399" alt="npm version"></a>
  <a href="https://npmjs.com/package/hucre"><img src="https://img.shields.io/npm/dm/hucre?style=flat&colorA=18181B&colorB=34d399" alt="npm downloads"></a>
  <a href="https://bundlephobia.com/result?p=hucre"><img src="https://img.shields.io/bundlephobia/minzip/hucre?style=flat&colorA=18181B&colorB=34d399" alt="bundle size"></a>
  <a href="https://github.com/productdevbook/hucre/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/hucre?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
</p>

## 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<sup>†</sup> | ~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<sup>‡</sup> | No (Pro $)    | Yes       | No            |
| **Stream read + write** | Yes                  | CSV only      | Yes       | CSV only      |
| **ODS support**         | Yes<sup>§</sup>      | 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

```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 },
})
```

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 — `<filters><filter val="…"/></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

```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,
  writeXlsxStreamSheets,
  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",
    },
  },
)

// A streamed row can carry formatting per cell, not just per column —
// enough for a title, a subtotal line or a header band. A cell that brings
// its own style overrides its column's; a bare value still inherits it.
// Row heights and merges travel alongside, so a report header no longer
// forces a fall back to writeXlsx.
writeXlsxStream(
  [
    [{ value: "Q3 Report", style: { font: { size: 20, bold: true } } }, null, null],
    [{ formula: "SUM(A3:A99)" }, "Total"],
    ...dataRows,
  ],
  {
    name: "Report",
    rowDefs: new Map([[0, { height: 30 }]]),
    merges: ["A1:C1"], // or [{ startRow: 0, startCol: 0, endRow: 0, endCol: 2 }]
  },
)

// Stream write, several sheets — same guarantee across a workbook that
// holds more than one sheet. Each sheet brings its own name, columns and
// rows (and its own styles, heights and merges); the row sources are
// pulled one sheet at a time, in order.
return new Response(
  writeXlsxStreamSheets([
    { name: "Accepted", rows: accepted(), columns },
    { name: "Rejected", rows: rejected(), columns },
  ]),
  {
    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()
```

#### Across the formats

```ts
import { writeOdsStream } from "hucre/ods"
import { writeNdjsonStream } from "hucre/json"
import { streamXmlRows, writeXmlStream } from "hucre/xml"

return new Response(writeOdsStream(rowCursor, { name: "Export" }), {
  headers: { "content-type": "application/vnd.oasis.opendocument.spreadsheet" },
})

return new Response(writeNdjsonStream(rowCursor), {
  headers: { "content-type": "application/x-ndjson" },
})

return new Response(writeXmlStream(rowCursor, { rowTag: "record" }), {
  headers: { "content-type": "application/xml; charset=utf-8" },
})

// Reading one back, a row at a time.
for await (const row of streamXmlRows(bytes, { rowTag: "record" })) {
  console.log(row.index, row.values)
}
```

|        | 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 `<office:automatic-styles>` 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 — a streaming reader cannot know a key
that appears only in a later row. For the same reason it takes `rowTag`
from the first child of the root rather than from the most frequent one.

ODS now has both writers, and the rule for choosing is the same one XLSX
already has: `writeOdsStream` for constant memory and values only,
`OdsStreamWriter` when you want the styles and can afford to buffer.

That difference is the format's, not a gap: ODF puts
`<office:automatic-styles>` **before** the body, so a style first seen on
row 900,000 has nowhere to be declared once the body has gone out. A
buffering writer holds the rows until `finish()` and writes the style
block from everything it saw, which is exactly what buys it per-cell
styles and column widths.

#### Which writer to use

|             | `writeXlsxStream()`                          | `XlsxStreamWriter`                |
| ----------- | -------------------------------------------- | --------------------------------- |
| Output      | `ReadableStream<Uint8Array>`                 | `Promise<Uint8Array>`             |
| 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               |
| Sheets      | one, or several with `writeXlsxStreamSheets` | one, plus auto-split parts        |

Measured with `pnpm bench` — the scenarios are in `bench/`, so these are
reproducible rather than quoted. 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 |

#### What the streaming _reader_ costs

The write table above is the flattering half. `streamXlsxRows` is
constant-memory in **rows** and linear in **distinct strings**, because
`xl/sharedStrings.xml` is a workbook-wide part that has to be read up
front. Two fixtures of the same shape and size, 100,000 rows × 12
columns, differing only in how many distinct strings they contain:

|                              | 400,000 distinct strings | 10 distinct strings |
| ---------------------------- | -----------------------: | ------------------: |
| `readXlsx`                   |        2,721 ms / 662 MB |   1,896 ms / 392 MB |
| `readXlsx` + `maxRows: 1000` |        1,953 ms / 656 MB |   1,150 ms / 221 MB |
| `streamXlsxRows`             |        2,446 ms / 556 MB |    1,592 ms / 90 MB |

Two things worth knowing before you rely on either:

- An export of names, SKUs or free text is the high-cardinality case, and
  there the streaming reader is not much better than the buffering one.
- **`maxRows` bounds the output, not the work.** Asking for 1,000 rows of
  100,000 saved 28% of the time and 1% of the memory on the
  high-cardinality fixture.

Run `pnpm bench` to see both on your own machine.

#### One surface across the incremental writers

`XlsxStreamWriter`, `CsvStreamWriter`, `NdjsonStreamWriter` and
`OdsStreamWriter` all implement `SpreadsheetStreamWriter`, 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<Uint8Array>`                          |

```ts
import type { SpreadsheetStreamWriter } from "hucre"

async function exportAll(writer: SpreadsheetStreamWriter, rows: Array<Record<string, CellValue>>) {
  for (const row of rows) writer.addObject(row)
  return await writer.finish() // string | Uint8Array — narrow at the call site
}
```

This was a convention until v1.0.1 and nothing enforced it: there was no
shared interface, so when `XlsxStreamWriter.addRow` widened to accept
styled cells, nothing failed and the drift was left for a reader to
find. The `implements` is what makes the next divergence a compile error
(#468).

Four caveats worth stating plainly:

- **`finish()` is not one type.** The text writers return `string`, XLSX
  returns `Promise<Uint8Array>`, and the interface says so rather than
  pretending otherwise. `await` covers both; narrow before use.
- **Construction is not shared.** `XlsxStreamWriter` takes a sheet `name`
  and `ColumnDef[]`; the two text writers take a plain key list. The
  helper is written once — building the writer is still per-format.
- `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.

### Read, edit, write

`readXlsx` returns a `Workbook`; `writeXlsx` takes `WriteOptions`. They
are different types — `Chart` is not `SheetChart`, `PivotTable` is not
`WritePivotTable` — so `writeXlsx({ sheets: wb.sheets })` does not
typecheck. `toWriteOptions` converts:

```ts
import { readXlsx, toWriteOptions, writeXlsx } from "hucre"

const wb = await readXlsx(buffer)
wb.sheets[0].rows[0][0] = "edited"

const out = await writeXlsx(toWriteOptions(wb))
```

This is the **authoring** path, so it carries only what `WriteSheet` and
`WriteOptions` describe. Pass `onDrop` to see what it could not:

```ts
toWriteOptions(wb, {
  onDrop: ({ field, sheet, reason }) => console.warn(`dropped ${field}`, sheet, reason),
})
// dropped charts  Sheet1  the read model (`Chart`) and the write model …
```

Editing someone else's file rather than producing a new one? Use
`openXlsx` / `saveXlsx` below instead — that path preserves the parts
hucre does not regenerate, so nothing is dropped.

### 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 `<externalReferences>` 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("<id>", 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 `<x14:slicerList>` / `<x15:timelines>` 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 `<pivotCaches>` 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 `<c:plotArea>` 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 `<c:numLit>` 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 `<c:chartSpace>` 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. Containers by their magic bytes — XLSX, XLSB, XLS, ODS —
// and the text formats by their opening: CSV, JSON, NDJSON, XML, HTML.
const wb = await read(buffer)

// Write any of nine. Default xlsx; always returns bytes, so nothing
// downstream has to branch on the format.
const bytes = await write({ sheets, format: "ndjson" })
// "xlsx" | "ods" | "csv" | "tsv" | "json" | "ndjson" | "xml" | "html" | "markdown"

// 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" })
```

Detection **decides rather than guesses**: every text format announces
itself in its first non-whitespace character or two, which is a far
weaker claim than the delimiter auto-detection `parseCsv` already makes.
CSV is the fallback, because "text that is not any of the others" is what
CSV is. Bytes that are not text at all still get the same
`UnsupportedFormatError` as before — a NUL in the first few KB is where
that line is drawn.

The text formats are single-sheet by nature: `write()` takes the first
sheet for those, and carries values rather than formatting. The
record-shaped ones (`json`, `ndjson`, `xml`) read row 0 as field names,
which is the convention `read()` inverts on the way back in.

### 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 convert data.csv report.md     # .json .ndjson .xml .html .md too

# stdin and stdout, the usual `-`
cat data.csv | npx hucre convert - out.xlsx
npx hucre convert in.xlsx - --to csv
cat data.csv | npx hucre convert - - --to md

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`, `.json`, `.ndjson`/`.jsonl`,
`.xml`, `.html`, `.xls`, `.xlsb`. Output: the same minus `.xls` and
`.xlsb`, which are read-only formats — naming one as the output says so —
plus `.md`, which is write-only, because there is no `fromMarkdown` and
there will not be.

`-` reads stdin or writes stdout. From a pipe there is no extension to go
on, so the input format comes from the content and the output format from
`--to`; guessing a binary default would spray a ZIP into a terminal.
Progress messages go to stderr whenever stdout is the file.

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

`insertRows`, `deleteRows`, `insertColumns` and `deleteColumns` move the
references too — formula text, the formulas inside data validations and
conditional rules, sparkline ranges, page breaks and text-box anchors —
so a formula below an insertion still points at its own arguments. A
reference into a deleted row becomes `#REF!`; a range the deletion clips
shrinks; a reference qualified with another sheet is left alone.

Two limits worth knowing:

- **Workbook-level references are out of reach.** These functions take a
  `Sheet`, so `Workbook.namedRanges`, defined names, external-link caches
  and pivot caches cannot be updated from here.
- **`sortRows` does not rewrite formulas.** Sorting has no correct answer
  for a relative reference, which is why Excel warns about it too.

### 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 `<br>`. Both are one-way, and there is no `fromMarkdown` —
nor will there be. Use CSV or JSON to move data.

Cell text is escaped so it renders as itself: a cell reading
`*not emphasis*` comes out as those words rather than as emphasis. Pass
`escapeInline: false` when the cells hold Markdown you want rendered — a
column of `**bold**` labels, say. Pipes and newlines are escaped either
way, because losing those loses the table.

### 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",
  tableIndex: 0, // default: the first table in the document
  typeInference: true, // default: numbers, booleans and ISO dates from text
  preserveLeadingZeros: true, // default: "007" stays a string
})
```

Named HTML entities (`&nbsp;`, `&mdash;`, `&euro;`, …) are decoded, `<br>`
becomes a newline inside the cell, `<script>` and `<style>` contents are
skipped rather than read as text, and `<tfoot>` lands at the bottom of the
sheet wherever it was declared — which is where a browser paints it. A
document with several tables reads the first; pass `tableIndex` for another.

It reads `<table>`, `<thead>`, `<tbody>`, `<tfoot>`, `<tr>`, `<td>`, `<th>`,
`<caption>`, `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`         |
| `<thead>`, or a row of all `<th>`                  | ✅ `sheet.a11y.headerRow` |
| `<caption>`                                        | ✅ `sheet.a11y.summary`   |
| `hucre-num` / `-bool` / `-date` / `-null` classes  | ✅ the cell's type        |
| inline `style=` (fonts, fills, borders, alignment) | ❌ not read               |
| the `<style>` block                                | ❌ not read               |
| `role` / `aria-label`                              | ❌ not read               |

So values, structure and types survive a round trip through hucre's own HTML;
formatting does not. Two other asymmetries worth knowing: cell text is trimmed
on read (whitespace around a `<td>` is indentation, not data) while `toHtml`
writes values as they are, and a `Date` is written as `YYYY-MM-DD`, so the
time of day is gone before the reader ever sees it.

Re-exporting a sheet that came from HTML keeps the structure if you hand the
two a11y hints back:

```ts
const again = toHtml(sheet, {
  hasHeaderRow: sheet.a11y?.headerRow === 0,
  caption: sheet.a11y?.summary,
})
```

### Number Format Renderer

```ts
import { formatValue } from "hucre"

formatValue(1234.5, "#,##0.00") // "1,234.50"
formatValue(0.15, "0%") // "15%"
formatValue(44197, "yyyy-mm-dd") // "2021-01-01"
formatValue(1234, "$#,##0") // "$1,234"
formatValue(0.333, "# ?/?") // "1/3"
```

### Cell Utilities

```ts
import { parseCellRef, cellRef, colToLetter, rangeRef } from "hucre"

parseCellRef("AA15") // { row: 14, col: 26 }
cellRef(14, 26) // "AA15"
colToLetter(26) // "AA"
rangeRef(0, 0, 9, 3) // "A1:D10"
```

### Builder API

Fluent method-chaining interface:

```ts
import { WorkbookBuilder } from "hucre"

const xlsx = await WorkbookBuilder.create()
  .addSheet("Products")
  .columns([
    { header: "Name", key: "name", autoWidth: true },
    { header: "Price", key: "price", numFmt: "$#,##0.00" },
  ])
  .row(["Widget", 9.99])
  .row(["Gadget", 24.5])
  .freeze(1)
  .done()
  .build()
```

It reaches the whole model, not a subset of it. Beyond `columns` / `row` /
`merge` / `freeze` / `validation` / `cell` there are named methods for
conditional rules, auto-filter, split panes, row definitions, page setup,
headers and footers, sheet views, protection, tables, images and charts —
and `set()` for anything else on `WriteSheet`:

```ts
const xlsx = await WorkbookBuilder.create()
  .namedRanges([{ name: "Prices", range: "Products!$B$2:$B$3" }])
  .addSheet("Products")
  .rows(data)
  .pageSetup({ orientation: "landscape", paperSize: "a4" })
  .conditionalRule({
    type: "cellIs",
    priority: 1,
    range: "B2:B99",
    operator: "greaterThan",
    formula: ["100"],
  })
  .set({ rowBreaks: [40], outlineProperties: { summaryBelow: false } })
  .build()
```

`set()` exists so the builder cannot fall behind the type: whatever
`WriteSheet` or `WriteOptions` grows, it can already be expressed.

### Template Engine

Fill `{{placeholders}}` in existing XLSX templates:

```ts
import { openXlsx, saveXlsx, fillTemplate } from "hucre"

const workbook = await openXlsx(templateBuffer)
fillTemplate(workbook, {
  company: "Acme Inc",
  date: new Date(),
  total: 12500,
})
const output = await saveXlsx(workbook)
```

### Excel 2024 Checkboxes

Boolean cells can be flagged as native Excel 2024 checkboxes via Microsoft's
FeaturePropertyBag extension. The cell value drives the checked state; older
Excel and LibreOffice fall back to the raw `TRUE`/`FALSE` display since the
on-disk value is just a normal boolean.

```ts
import { writeXlsx, readXlsx } from "hucre/xlsx"

const buf = await writeXlsx({
  sheets: [
    {
      name: "Tasks",
      rows: [["Done?"], [true], [false], [true]],
      cells: new Map([
        ["1,0", { value: true, type: "boolean", checkbox: true }],
        ["2,0", { value: false, type: "boolean", checkbox: true }],
        ["3,0", { value: true, type: "boolean", checkbox: true }],
      ]),
    },
  ],
})

const wb = await readXlsx(buf)
wb.sheets[0].cells?.get("1,0")?.checkbox // true
```

This is the first JS/TS implementation of native checkboxes — only `XlsxWriter`
(Python) and `rust_xlsxwriter` had it before.

### Accessibility (WCAG 2.1 AA)

Generate screen-reader-friendly spreadsheets and audit them for common
WCAG 2.1 AA issues. Alt text on images and text boxes round-trips
through `xdr:cNvPr/@descr` and `@title` (the OOXML attributes Excel and
assistive tech read), and per-sheet summaries can promote the first
non-empty value into `docProps/core.xml` so screen readers announce it
on file open.

```ts
import { writeXlsx, a11y, readXlsx } from "hucre"

const xlsx = await writeXlsx({
  sheets: [
    {
      name: "Q1 Sales",
      rows: [
        ["Region", "Revenue"],
        ["EU", 12_400],
      ],
      a11y: { summary: "Quarterly sales by region", headerRow: 0 },
      images: [
        {
          data: pngBytes,
          type: "png",
          anchor: { from: { row: 0, col: 3 } },
          altText: "Bar chart showing 47% YoY growth",
        },
      ],
    },
  ],
})

// Audit a workbook for missing alt text, missing header rows,
// merged headers, low contrast, and more.
const wb = await readXlsx(xlsx)
for (const issue of a11y.audit(wb)) {
  console.log(issue.type, issue.code, issue.message, issue.location)
}

// Color contrast helpers (WCAG 2.1 sRGB)
a11y.contrastRatio("0969DA", "FFFFFF") // ≈ 5.19 (passes AA)
a11y.relativeLuminance("808080")
```

Issue codes: `no-doc-title`, `no-doc-description`, `empty-sheet`,
`no-header-row`, `merged-header-row`, `missing-alt-text` (error for
images, warning for text boxes), `low-contrast`, `blank-row-in-data`.
Tune the contrast pass with
`audit(wb, { skipContrast, minContrast, contrastSampleLimit })`.

### Object Shorthand (XLSX / ODS)

Skip the `wb.sheets[0].rows[0] as headers, slice(1) as data` boilerplate — return objects directly, mirror of `parseCsvObjects`:

```ts
import { readXlsxObjects, writeXlsxObjects } from "hucre/xlsx"
import { readOdsObjects, writeOdsObjects } from "hucre/ods"

const { data, headers } = await readXlsxObjects(buffer, {
  sheet: 0, // index or name (default: 0)
  headerRow: 0, // 0-based (default: 0)
  skipEmptyRows: true,
  transformHeader: (h) => h.toLowerCase().replace(/ /g, "_"),
  transformValue: (v, header) => (header === "price" ? Number(v) : v),
})

// Symmetric write — headers come from the first object's keys when omitted
const xlsx = await writeXlsxObjects(
  [
    { Name: "Widget", Price: 9.99 },
    { Name: "Gadget", Price: 24.5 },
  ],
  { sheetName: "Products" },
)
```

Every `*Objects` reader — `readObjects`, `readXlsxObjects`,
`readOdsObjects`, `parseCsvObjects`, `parseJson`, `readXml` — returns the
same `{ data, headers }` shape, under a named type
(`XlsxObjectsResult`, `OdsObjectsResult`, `CsvObjectsResult`,
`ReadObjectsResult`, `SheetObjectsResult`, `JsonReadResult`). `readObjects`
is the format-agnostic one: it takes the same options as the two above and
applies them to whatever `read()` detected, so `maxRows` and `skipEmptyRows`
work for ODS and XLS too.

### JSON / NDJSON

```ts
import {
  parseJson,
  parseNdjson,
  writeJson,
  writeNdjson,
  workbookToJson,
  jsonToWorkbook,
  NdjsonStreamWriter,
  streamNdjsonRows,
} from "hucre/json"

// Read — top-level array, { products: [...] } shape, or single object
const { data, headers } = parseJson(jsonString)

// Pick rows from a deeper path
parseJson(text, { rowsAt: "data.rows" })

// Flatten nested objects with dot-path keys (default: true)
parseJson('[{"sku":"P1","pricing":{"cost":100}}]')
// → data: [{ sku: "P1", "pricing.cost": 100 }]

// Flattening is lossy on the way back: writing returns the dot-path key, not
// the nesting. Pass `unflatten` to rebuild it — opt-in, because a header like
// "Q1.2024" is a column name, not a path.
writeJson(data, { unflatten: true })
// → [{ "sku": "P1", "pricing": { "cost": 100 } }]
// Two things it cannot undo: a primitive array joined into "1, 2" stays a
// string, and a key that already contained a dot comes back nested.

// JSON has no date type, so ISO strings stay strings unless you ask —
// same option name and same rule as the CSV reader
parseJson('[{"at":"2024-01-15T10:30:00Z"}]', { typeInference: true })
// → data: [{ at: Date }]

// NDJSON / JSON Lines — one object per line
const out = parseNdjson(ndjsonText, {
  onError: (line, ln) => console.warn(`bad line ${ln}`), // skip + report
})

// Round-trip a workbook, at any sheet count
import { readXlsx } from "hucre/xlsx"
const wb = await readXlsx(buffer)
const json = workbookToJson(wb, { pretty: true })
// `workbookToJson` emits a bare array for one sheet and { Sheet: [...] }
// beyond that — a shape that depends on the data. Pin it with
// `shape: "sheets"`, and read either shape back with `jsonToWorkbook`.
const restored = jsonToWorkbook(json)

// `parseJson` reads one table, so a multi-sheet document throws rather than
// collapsing into a single row of stringified sheets. Pick a sheet with
// `rowsAt`, or use `jsonToWorkbook` for all of them.
parseJson(workbookToJson(wb), { rowsAt: "Sheet2" })

// Streaming write — works in Cloudflare Workers / Deno / Node 18+
const writer = new NdjsonStreamWriter()
for await (const row of source) writer.addObject(row)
writer.finish() // `write()` / `end()` still work as deprecated aliases
return new Response(writer.toStream(), {
  headers: { "content-type": "application/x-ndjson" },
})

// Streaming read
for await (const row of streamNdjsonRows(request.body!)) {
  console.log(row)
}
```

### XML

Read and write tabular XML — product feeds (GS1 GDSN, Trendyol, marketplace exports), ERP dumps (SAP B1, Logo GO, Netsis), CRM catalogs.

`readXml` parses with the SAX parser internally, but it is not a streaming
API: it takes a complete XML `string` and returns every row at once, so both
the document and the full row set live in memory. Omitting `rowTag` costs a
second pass over the input for auto-detection. `maxRows` caps the rows you get
back, not the parse — the document is still walked in full. A streaming XML
reader is not implemented yet.

```ts
import { readXml, writeXml } from "hucre/xml"

// Auto-detects the most-frequently-repeating direct child of root as the row tag
const { data, headers, rowTag } = readXml(`
  <Catalog>
    <Product code="P1">
      <Name>Oak</Name>
      <Pricing currency="USD">
        <Cost>100</Cost>
        <Retail>180</Retail>
      </Pricing>
    </Product>
    <Product code="P2"><Name>Pine</Name></Product>
  </Catalog>
`)
// rowTag: "Product"
// data: [{ "@code": "P1", Name: "Oak", "Pricing.@currency": "USD",
//         "Pricing.Cost": "100", "Pricing.Retail": "180" }, ...]

// Override auto-detect with rowTag, strip namespace prefixes, control flatten
readXml(xml, { rowTag: "ns:Product", stripNamespaces: true, flatten: true })

// Write — @-keyed fields become XML attributes, dot-paths reconstruct elements
const xml = writeXml(
  [
    { "@code": "P1", Name: "Oak", "Pricing.Cost": 100 },
    { "@code": "P2", Name: "Pine", "Pricing.Cost": 90 },
  ],
  { rootTag: "Catalog", rowTag: "Product", pretty: true },
)
```

### JSON Export (legacy)

```ts
import { toJson } from "hucre"

toJson(sheet, { format: "objects" }) // [{Name:"Widget", Price:9.99}, ...]
toJson(sheet, { format: "columns" }) // {Name:["Widget"], Price:[9.99]}  — write-only
toJson(sheet, { format: "arrays" }) // {headers:[...], data:[[...]]}     — write-only
```

Only `"objects"` has a reader. `"columns"` and `"arrays"` are presentation
shapes for handing a sheet to a charting library or a dataframe; `parseJson`
will not reconstruct a table from either. Export in `"objects"` if the JSON has
to come back into hucre.

For new code prefer `writeJson` / `workbookToJson` from `hucre/json` — same result, consistent with `parseJson`/`parseNdjson`/`writeNdjson`.

### CSV

```ts
import { parseCsv, parseCsvObjects, writeCsv, writeCsvStream, detectDelimiter } from "hucre/csv"

// Parse — takes a string or bytes, auto-detects delimiter, RFC 4180
const rows = parseCsv(csvString, { typeInference: true })
const fromDisk = parseCsv(await readFile("data.csv"), { typeInference: true })

// Parse with headers — returns typed objects
const { data, headers } = parseCsvObjects(csvString, { header: true })

// Write
const csv = writeCsv(rows, { delimiter: ";", bom: true })

// Stream write — rows are pulled as the consumer reads, so peak memory
// doesn't grow with the row count. Takes any Iterable or AsyncIterable
// of value arrays or objects.
return new Response(writeCsvStream(dbCursor, { headers: ["id", "name"] }), {
  headers: { "content-type": "text/csv; charset=utf-8" },
})

// Detect delimiter
detectDelimiter(csvString) // "," or ";" or "\t" or "|"
```

Round-tripping through hucre: `escapeFormulae` (which prefixes `= + - @ |`
values with `'` so a spreadsheet cannot execute them) is reversed by
`unescapeFormulae` on the read side, and `comment` on the write side quotes
values that a reader configured with the same character would otherwise
delete as comment lines. `nullValue` and a custom `dateFormat` are one-way
— nothing on the read side restores a `null` or parses a non-ISO date.

```ts
const csv = writeCsv([["-5"]], { escapeFormulae: true }) // "'-5"
parseCsv(csv, { unescapeFormulae: true, typeInference: true }) // [[-5]]
```

`writeCsvStream` is to `CsvStreamWriter` what `writeXlsxStream` is to
`XlsxStreamWriter`: the class formats each row on arrival but retains
every line until `finish()` returns one string, while the function
flushes as it goes. Writing 3,000,000 rows (254 MB of CSV) under a
128 MB heap cap, the stream completes; the class runs out of memory.

#### Character encoding

`parseCsv`, `parseCsvObjects` and `streamCsvRows` take **bytes as well as a
string**. Given bytes they read the byte-order mark, which is the one thing
a file can say about its own encoding:

| leading bytes | read as                                               |
| ------------- | ----------------------------------------------------- |
| `EF BB BF`    | UTF-8                                                 |
| `FF FE`       | UTF-16LE — what Excel's "Save as Unicode Text" writes |
| `FE FF`       | UTF-16BE                                              |
| anything else | UTF-8, unless you say otherwise                       |

There is no detection past the mark. An encoding like windows-1254 leaves
no trace a parser can read — telling it from windows-1252 means guessing
from byte frequencies, which is wrong often enough to be worse than
asking. So name it:

```ts
// Excel on a Turkish Windows writes CSV as windows-1254
const rows = parseCsv(bytes, { encoding: "windows-1254" })
```

Any label `TextDecoder` accepts works — the WHATWG Encoding Standard's
full set, legacy single-byte encodings included. Passing a string skips
all of this: you have already decided.

The CLI takes `--encoding` for the same reason, and reads the mark
without being asked:

```bash
hucre convert veriler.csv out.xlsx --encoding windows-1254
```

On the write side the output is always UTF-8 — `TextEncoder` encodes
nothing else, by specification, so writing windows-1254 is not something a
Web-API-only library can do. What matters instead is the mark, because
Excel on a non-UTF-8 locale reads a UTF-8 CSV as its system code page
without one:

```ts
writeCsv(rows, { bom: true })
await write({ sheets, format: "csv", csv: { delimiter: ";", bom: true } })
```

```bash
hucre convert veriler.xlsx out.csv --bom
```

Each text format takes its own bag through `write` — `csv`, `tsv`, `json`,
`ndjson`, `xml`, `html`, `markdown` — so the format-agnostic entry can
configure the writer it dispatches to.

### Schema Validation

Validate imported data with type coercion, pattern matching, and error collection:

```ts
import { validateWithSchema } from "hucre"
import { parseCsv } from "hucre/csv"

const rows = parseCsv(csvString)

const result = validateWithSchema(
  rows,
  {
    "Product Name": { type: "string", required: true },
    Price: { type: "number", required: true, min: 0 },
    SKU: { type: "string", pattern: /^[A-Z]{3}-\d{4}$/ },
    Stock: { type: "integer", min: 0, default: 0 },
    Status: { type: "string", enum: ["active", "inactive", "draft"] },
  },
  { headerRow: 1 },
)

console.log(result.data) // Validated & coerced objects
console.log(result.errors) // [{ row: 3, field: "Price", message: "...", value: "abc" }]
```

Schema field options:

| Option        | Type                                                       | Description                             |
| ------------- | ---------------------------------------------------------- | --------------------------------------- |
| `type`        | `"string" \| "number" \| "integer" \| "boolean" \| "date"` | Target type (with coercion)             |
| `required`    | `boolean`                                                  | Reject null/empty values                |
| `pattern`     | `RegExp`                                                   | Regex validation (strings)              |
| `min`         | `number`                                                   | Min value (numbers) or length (strings) |
| `max`         | `number`                                                   | Max value (numbers) or length (strings) |
| `enum`        | `unknown[]`                                                | Allowed values                          |
| `default`     | `unknown`                                                  | Default for null/empty                  |
| `validate`    | `(v) => boolean \| string`                                 | Custom validator                        |
| `transform`   | `(v) => unknown`                                           | Post-validation transform               |
| `column`      | `string`                                                   | Column header name                      |
| `columnIndex` | `number`                                                   | Column index (0-based)                  |

### Date Utilities

Excel serial number conversion, in UTC:

```ts
import { serialToDate, dateToSerial, isDateFormat, formatDate } from "hucre"

serialToDate(44197) // 2021-01-01T00:00:00.000Z
dateToSerial(new Date("2021-01-01")) // 44197
isDateFormat("yyyy-mm-dd") // true
isDateFormat("#,##0.00") // false
formatDate(new Date(), "yyyy-mm-dd") // "2026-03-24"
```

**Every date path in hucre reads UTC components**, which is what makes
the conversions consistent between the readers, the writers and
`formatValue`. It is also the one thing worth knowing before you build a
`Date` yourself:

```ts
dateToSerial(new Date("2024-01-15")) // 45306    — parsed as UTC midnight
dateToSerial(new Date(2024, 0, 15)) //  45305.875 in UTC+3

// The second is *local* midnight, which is 21:00 the previous day in UTC —
// so Excel shows "14 Jan 2024 21:00". Build the instant you mean:
dateToSerial(new Date(Date.UTC(2024, 0, 15))) // 45306
```

hucre converts the instant, not the wall-clock date; it does not guess a
calendar day out of a timezone. Pass `Date.UTC(...)` when you mean a day.

Both date systems are handled, and so is the Lotus 1-2-3 phantom
29 February 1900 — with one caveat worth stating, since Excel's own model
is contradictory here. Serial 60 is that phantom day, and it has no real
instant to map to, so `serialToDate(60)` and `serialToDate(59)` both give
28 February 1900 and `dateToSerial` sends that back as 59. A workbook
that actually contains serial 60 therefore shifts by a day on rewrite.
Excel produces it only for files inherited from Lotus.

## Platform Support

hucre works everywhere — no Node.js APIs (`fs`, `crypto`, `Buffer`) in core.

| Runtime               | Status       |
| --------------------- | ------------ |
| Node.js 24+           | Full support |
| Deno                  | Full support |
| Bun                   | Full support |
| Modern browsers       | Full support |
| Cloudflare Workers    | Full support |
| Vercel Edge Functions | Full support |
| Web Workers           | Full support |

## Architecture

```
hucre (~114 KB gzipped for the whole barrel; 2–64 KB for the common
       subpath imports — see the bundle-size footnote above)
├── zip/            Zero-dep DEFLATE/inflate + ZIP read/write (+ streaming both ways)
├── xml/            SAX parser + XML writer (CSP-compliant, no eval)
├── xlsx/
│   ├── reader      Shared strings, styles, worksheets, relationships
│   ├── writer      Styles, shared strings, drawing, tables, comments
│   ├── roundtrip   Open → modify → save with preservation
│   ├── stream-*    Streaming reader (AsyncGenerator) + incremental/streaming writers
│   └── auto-width  Font-aware column width calculation
├── ods/            OpenDocument Spreadsheet read/write
├── csv/            RFC 4180 parser/writer + streaming
├── export/         HTML, Markdown, JSON, TSV output + HTML import
├── hucre           Unified read/write API, format auto-detect
├── builder         Fluent WorkbookBuilder / SheetBuilder API
├── template        {{placeholder}} template engine
├── sheet-ops       Insert/delete/move/sort/find/replace, clone, copy
├── cell-utils      parseCellRef, colToLetter, parseRange, isInRange
├── image           imageFromBase64 utility
├── worker          Web Worker serialization helpers
├── _date           UTC serial ↔ Date, Lotus bug, 1900/1904
├── _format         Number format renderer (locale-aware)
├── _schema         Schema validation, type coercion, error collection
└── cli             Convert, inspect, validate (citty + consola)
```

Zero dependencies. Pure TypeScript. The ZIP engine uses `CompressionStream`/`DecompressionStream` Web APIs with a pure TS fallback.

## API Reference

### High-level

| Function                       | Description                                                                                                                    |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `read(input, options?)`        | Auto-detect format, returns `Workbook`. XLSX, ODS, XLS and XLSB by container shape; CSV, JSON, NDJSON, XML and HTML by content |
| `write(options)`               | Write XLSX or ODS (via `format` option)                                                                                        |
| `readObjects(input, options?)` | File → `{ data, headers }` (format-agnostic `*Objects` reader)                                                                 |
| `writeObjects(data, options?)` | Objects → XLSX/ODS                                                                                                             |

### XLSX

| Function                           | Description                                                                             |
| ---------------------------------- | --------------------------------------------------------------------------------------- |
| `readXlsx(input, options?)`        | Parse XLSX from `Uint8Array \| ArrayBuffer \| ReadableStream<Uint8Array>`               |
| `writeXlsx(options)`               | Generate XLSX, returns `Uint8Array`                                                     |
| `readXlsxObjects(input, options?)` | Read sheet as `{ data, headers }` — mirror of CSV                                       |
| `writeXlsxObjects(data, options?)` | Write objects to XLSX (auto-derives headers from keys)                                  |
| `openXlsx(input, options?)`        | Open for round-trip (preserves unknown parts)                                           |
| `saveXlsx(workbook)`               | Save round-trip workbook back to XLSX                                                   |
| `streamXlsxRows(input, options?)`  | AsyncGenerator yielding rows one at a time                                              |
| `writeXlsxStream(rows, options)`   | Constant-memory XLSX writing — returns a `ReadableStream<Uint8Array>`                   |
| `toWriteOptions(workbook, opts?)`  | Convert a read `Workbook` into `WriteOptions`; `onDrop` reports what it could not carry |
| `XlsxStreamWriter`                 | Incremental XLSX writing (`addRow`/`addObject`); auto-splits past `maxRowsPerSheet`     |
| `XLSX_MAX_ROWS_PER_SHEET`          | Excel hard row limit (1,048,576) — exported constant                                    |
| `parseExternalLink(xml, relsXml?)` | Parse `xl/externalLinks/externalLinkN.xml` → `ExternalLink`                             |
| `parseCellImages(xml)`             | Parse `xl/cellimages.xml` → `ParsedCellImageRef[]` (WPS DISPIMG)                        |
| `assembleCellImages(refs, media)`  | Combine parsed refs with resolved media bytes → `CellImage[]`                           |
| `parseSlicers(xml)`                | Parse `xl/slicers/slicerN.xml` → `Slicer[]`                                             |
| `parseSlicerCache(xml)`            | Parse `xl/slicerCaches/slicerCacheN.xml` → `SlicerCache \| undefined`                   |
| `parseTimelines(xml)`              | Parse `xl/timelines/timelineN.xml` → `Timeline[]`                                       |
| `parseTimelineCache(xml)`          | Parse `xl/timelineCaches/timelineCacheN.xml` → `TimelineCache \| undefined`             |
| `parsePivotTable(xml)`             | Parse `xl/pivotTables/pivotTableN.xml` → `PivotTable \| undefined`                      |
| `parsePivotCacheDefinition(xml)`   | Parse `xl/pivotCache/pivotCacheDefinitionN.xml` → `PivotCache \| undefined`             |
| `attachPivotCacheFields(pt, c)`    | Overlay `PivotCache.fieldNames` onto a `PivotTable.fields[].name`                       |
| `parseChart(xml)`                  | Parse `xl/charts/chartN.xml` → `Chart \| undefined`                                     |
| `cloneChart(source, options)`      | Convert a parsed `Chart` into a writer-ready `SheetChart`                               |
| `chartKindToWriteKind(kind)`       | Map a read-side `ChartKind` onto its writable counterpart, if any                       |
| `getCharts(workbook)`              | Enumerate every chart anchored on the workbook with its sheet context                   |
| `addChart(sheet, chart)`           | Append a `SheetChart` to a `WriteSheet`, lazily creating the array                      |

### ODS

| Function                          | Description                                                           |
| --------------------------------- | --------------------------------------------------------------------- |
| `readOds(input, options?)`        | Parse ODS (`Uint8Array \| ArrayBuffer \| ReadableStream<Uint8Array>`) |
| `writeOds(options)`               | Generate ODS                                                          |
| `readOdsObjects(input, options?)` | Read sheet as `{ data, headers }`                                     |
| `writeOdsObjects(data, options?)` | Write objects to ODS                                                  |
| `streamOdsRows(input)`            | AsyncGenerator yielding ODS rows                                      |

### CSV

| Function                           | Description                                                              |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `parseCsv(input, options?)`        | Parse CSV string → `CellValue[][]`                                       |
| `parseCsvObjects(input, options?)` | Parse CSV with headers → `CsvObjectsResult` (`{ data, headers }`)        |
| `writeCsv(rows, options?)`         | Write `CellValue[][]` → CSV string                                       |
| `writeCsvObjects(data, options?)`  | Write objects → CSV string                                               |
| `detectDelimiter(input)`           | Auto-detect delimiter character                                          |
| `streamCsvRows(input, options?)`   | Generator yielding CSV rows; same options as `parseCsv`                  |
| `writeCsvStream(rows, options?)`   | Constant-memory CSV writing → `ReadableStream`                           |
| `CsvStreamWriter`                  | Incremental CSV writing (`addRow`/`addObject`); buffers until `finish()` |
| `writeTsv(rows, options?)`         | Write TSV (tab-separated)                                                |
| `fetchCsv(url, options?)`          | Fetch and parse CSV from URL                                             |

### JSON

| Function                          | Description                                                                            |
| --------------------------------- | -------------------------------------------------------------------------------------- |
| `parseJson(input, options?)`      | Parse JSON string/Uint8Array → `{ data, headers }`                                     |
| `parseValue(value, options?)`     | Same on already-parsed JSON                                                            |
| `parseNdjson(input, options?)`    | Parse NDJSON / JSON Lines (`onError` skips invalid)                                    |
| `writeJson(data, options?)`       | Serialize rows to a JSON string                                                        |
| `writeNdjson(data, options?)`     | Serialize rows to NDJSON, one object per line                                          |
| `workbookToJson(wb, options?)`    | Convert a `Workbook` to JSON (single-sheet array or per-sheet; `shape` pins it)        |
| `jsonToWorkbook(input, options?)` | Read either `workbookToJson` shape back into a `Workbook`                              |
| `flattenValue(value, options?)`   | Flatten one value into dot-path keyed cells                                            |
| `unflattenRow(row)`               | Rebuild a dot-path keyed row into nested objects — the inverse                         |
| `streamNdjsonRows(stream, opts?)` | Async generator over a `ReadableStream<Uint8Array>`                                    |
| `NdjsonStreamWriter`              | Incremental writer (`addRow`/`addObject`); `toStream()` releases rows as they are sent |

### XML

| Function                   | Description                                               |
| -------------------------- | --------------------------------------------------------- |
| `readXml(input, options?)` | XML string → rows; auto-detects the repeating row element |
| `writeXml(data, options?)` | Serialize rows to XML; `@`-keys → attributes              |

### Sheet Operations

| Function                                | Description                     |
| --------------------------------------- | ------------------------------- |
| `insertRows(sheet, index, count)`       | Insert rows, shift down         |
| `deleteRows(sheet, index, count)`       | Delete rows, shift up           |
| `insertColumns(sheet, index, count)`    | Insert columns, shift right     |
| `deleteColumns(sheet, index, count)`    | Delete columns, shift left      |
| `moveRows(sheet, from, count, to)`      | Move rows                       |
| `cloneSheet(sheet, name)`               | Deep clone a sheet              |
| `copySheetToWorkbook(sheet, wb, name?)` | Copy sheet between workbooks    |
| `copyRange(sheet, source, target)`      | Copy cell range within sheet    |
| `moveSheet(wb, from, to)`               | Reorder sheets                  |
| `removeSheet(wb, index)`                | Remove a sheet                  |
| `sortRows(sheet, col, order?)`          | Sort rows by column             |
| `findCells(sheet, predicate)`           | Find cells by value or function |
| `replaceCells(sheet, find, replace)`    | Find and replace values         |

### Export

| Function                      | Description                                                                                        |
| ----------------------------- | -------------------------------------------------------------------------------------------------- |
| `toHtml(sheet, options?)`     | HTML `<table>` with styles, a11y, dark/light CSS                                                   |
| `toMarkdown(sheet, options?)` | Markdown table, auto-alignment — output only, and truncating                                       |
| `toJson(sheet, options?)`     | JSON (objects, arrays, or columns format)                                                          |
| `fromHtml(html, options?)`    | Parse HTML table string → Sheet (values, structure and types — [not styles](#reading-html-tables)) |
| `writeTsv(rows, options?)`    | Write TSV (tab-separated)                                                                          |

### Builder

| Function                       | Description                             |
| ------------------------------ | --------------------------------------- |
| `WorkbookBuilder.create()`     | Fluent API for building workbooks       |
| `fillTemplate(workbook, data)` | Replace `{{placeholders}}` in templates |

### Formatting & Utilities

| Function                                     | Description                              |
| -------------------------------------------- | ---------------------------------------- |
| `formatValue(value, numFmt, options?)`       | Apply Excel number format (locale-aware) |
| `validateWithSchema(rows, schema, options?)` | Validate & coerce data with schema       |
| `serialToDate(serial, is1904?)`              | Excel serial → Date (UTC)                |
| `dateToSerial(date, is1904?)`                | Date → Excel serial                      |
| `isDateFormat(numFmt)`                       | Check if format string is date           |
| `formatDate(date, format)`                   | Format Date with Excel format string     |
| `parseCellRef(ref)`                          | "AA15" → `{ row: 14, col: 26 }`          |
| `cellRef(row, col)`                          | `(14, 26)` → "AA15"                      |
| `colToLetter(col)`                           | `26` → "AA"                              |
| `rangeRef(r1, c1, r2, c2)`                   | `(0,0,9,3)` → "A1:D10"                   |

### Accessibility (a11y)

| Function                      | Description                                                |
| ----------------------------- | ---------------------------------------------------------- |
| `a11y.audit(wb, options?)`    | WCAG 2.1 AA audit; returns `A11yIssue[]`                   |
| `a11y.contrastRatio(fg, bg)`  | sRGB contrast ratio (1–21) for two hex colors              |
| `a11y.relativeLuminance(hex)` | WCAG relative luminance (0–1) for a hex color              |
| `a11y.applyA11ySummary(wb)`   | Promote first sheet `a11y.summary` to workbook description |

### Web Worker Helpers

| Function                    | Description                                                          |
| --------------------------- | -------------------------------------------------------------------- |
| `serializeWorkbook(wb)`     | Convert Workbook for `postMessage` (Maps → objects, Dates → strings) |
| `deserializeWorkbook(data)` | Restore Workbook from serialized form                                |

Every export in this library is worker-safe: there are no DOM or Node-only
dependencies, so anything can be called from inside a Web Worker.

## Development

```sh
pnpm install
pnpm dev          # vitest watch
pnpm test         # lint + typecheck + test
pnpm build        # obuild: src/ transpiled file-by-file into dist/ (+ .d.mts),
                  # plus a bundled, minified dist/cli.mjs
pnpm lint:fix     # oxlint + oxfmt
pnpm typecheck    # tsc
```

### `hucre/ooxml` — low-level part parsers

`parseChart`, `parsePivotTable`, `parseSlicers`, `parseThemeColors` and
friends take a raw XML string from inside an `.xlsx` package and return
hucre's internal model of it.

```ts
import { parseChart, parsePivotTable } from "hucre/ooxml"
```

**This entry point is explicitly outside the v1 stability commitment.**
Its shapes mirror the OOXML parse pipeline and move when that pipeline
moves — freezing them would mean the internals could never change without
a major bump. Everything else (`hucre`, `hucre/xlsx`, `hucre/csv`,
`hucre/ods`, `hucre/json`, `hucre/xml`) is stable.

If you only read and write spreadsheets, you do not need anything here.
These names are still exported from the root for backward compatibility,
marked deprecated.

## Read/write parity

hucre reads more than it writes, in specific and documented places — and
XLSX has two write paths with different fidelity, which is the single
biggest source of surprise.

**[docs/PARITY.md](docs/PARITY.md)** states it per format: what round-trips,
what is read-only, what is write-only, and why. If you hit a loss that is
not on that page, it is a bug worth reporting.

The short version: use `openXlsx`/`saveXlsx` when editing someone else's
workbook — parts hucre does not model are copied byte-for-byte. Use
`readXlsx`/`writeXlsx` when producing a new one, where only what
`WriteSheet` and `WriteOptions` describe survives.

## Migrating to v1

v1 makes the public API a stability commitment, and getting there meant
fixing things that could not be fixed afterwards without a major bump —
an inverted argument order, an option that meant four different things,
and a handful of exports that never did anything.

**[MIGRATION.md](./MIGRATION.md)** lists every change that can affect
existing code, with a table at the top so you can tell in a few seconds
whether any of it applies to you. Most projects need to change nothing.

## Contributing

Contributions are welcome! Please [open an issue](https://github.com/productdevbook/hucre/issues) or submit a PR.

See the [issue tracker](https://github.com/productdevbook/hucre/issues) for the roadmap.

### Roadmap

**Not implemented yet:**

- Formula evaluation engine
- Chart authoring for the remaining kinds — bubble, radar, surface, stock, and combo (multi-kind plot areas); these read and round-trip today
- Streaming XML reader (`readXml` takes a whole string today)
- Conditional formatting: `timePeriod` rules, and the `rank` / `percent` / `bottom` / `aboveAverage` / `equalAverage` / `stdDev` knobs on `top10` and `aboveAverage`
- Auto-filter criteria beyond value lists (custom, dynamic, colour, icon filters)
- Pre-computed pivot value cells (the writer emits the structure; Excel computes on open)
- Threaded comments (Excel 365+) — synthesize from a fresh write (read + roundtrip already supported). `WriteSheet` has no `threadedComments` field; it is not silently accepted
- Slicers & timeline filters — synthesize from a fresh write (read + roundtrip already supported)
- WPS DISPIMG cell-embedded images — synthesize from a fresh write (read + roundtrip already supported)
- XLS / XLSB writing (both formats are read-only today)

## Alternatives

Looking for a different approach? These libraries may fit your use case:

- **[SheetJS (xlsx)](https://github.com/SheetJS/sheetjs)** — The most popular spreadsheet library. Feature-rich but large bundle (~300 KB), removed from npm (CDN-only), styling requires Pro license.
- **[ExcelJS](https://github.com/exceljs/exceljs)** — Read/write/stream XLSX with styling. Mature but has 9 direct dependencies (4.4.0), CJS-only, no ESM.
- **[xlsx-js-style](https://github.com/gitbrent/xlsx-js-style)** — SheetJS fork that adds cell styling. Same bundle size and limitations as SheetJS.
- **[xlsmith](https://github.com/ChronicStone/xlsmith)** — Schema-driven Excel report builder with typed column definitions, formula helpers, conditional styles, and summary rows. Great for structured report generation.
- **[xlsx-populate](https://github.com/dtjohnson/xlsx-populate)** — Template-based XLSX manipulation. Good for filling existing templates, limited write-from-scratch support.
- **[better-xlsx](https://github.com/nichenqin/better-xlsx)** — Lightweight XLSX writer with styling. Write-only, no read support.

## License

[MIT](./LICENSE) — Made by [productdevbook](https://github.com/productdevbook)

---
_Source: https://npm.io/package/hucre · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
