Skip to content

Repository files navigation


hucre — Zero-dependency spreadsheet engine

hucre

Zero-dependency spreadsheet engine.
Read & write XLSX, CSV, ODS, JSON, NDJSON, XML. Schema validation, streaming, round-trip preservation. Pure TypeScript, works everywhere.

npm version npm downloads bundle size license

Quick Start

npm install hucre
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:

import { readXlsx, writeXlsx } from "hucre/xlsx" // XLSX only
import { parseCsv, writeCsv } from "hucre/csv" // CSV only (~2 KB gzipped)
import { readOds, writeOds } from "hucre/ods" // ODS only
import { parseJson, writeNdjson } from "hucre/json" // JSON / NDJSON
import { readXml, writeXml } from "hucre/xml" // Tabular XML

Why hucre?

vs JavaScript / TypeScript Libraries

hucre SheetJS CE ExcelJS xlsx-js-style
Dependencies 0 0* 9 0*
Bundle (gzip) 2–114 KB ~300 KB ~500 KB ~300 KB
ESM native Yes Partial No (CJS) Partial
TypeScript Native Bolted-on Bolted-on Bolted-on
Edge runtime Yes No No No
CSP compliant Yes Yes No (eval) Yes
npm published Yes No (CDN only) Stale Yes
Read + Write Yes Yes (Pro $) Yes Yes
Styling Yes No (Pro $) Yes Yes
Cond. formatting 13 types No (Pro $) Yes No
Stream read + write Yes CSV only Yes CSV only
ODS support Yes§ Yes No Yes
Round-trip Yes Partial Partial Partial
Sparklines Yes No No No
Tables Yes Yes Yes Yes
Images Yes No (Pro $) Yes No

* SheetJS removed itself from npm; must install from CDN tarball.

† Depends on what you import — hucre is fully tree-shakeable, so there is no single number. Minified + gzipped, bundled with rolldown against dist/: { parseCsv, writeCsv } from hucre/csv = 2.3 KB, { readXlsx } from hucre/xlsx = 32 KB, { readXlsx, writeXlsx } = 64 KB, the entire library (export * from "hucre") = 114 KB.

cellIs, expression, colorScale, dataBar, iconSet, containsText, notContainsText, beginsWith, endsWith, containsBlanks, notContainsBlanks, duplicateValues, uniqueValues — read and written. top10 and aboveAverage round-trip in their default form only (the writer emits no rank / percent / bottom or aboveAverage / equalAverage / stdDev attributes), and timePeriod rules are dropped on read.

§ Values, formulas and merges round-trip in full; cell styling covers six facets (bold, italic, size, font colour, background colour, number format). Borders, alignment, column widths, freeze panes, validation, named ranges, images and page setup are not modelled in either direction, so ODS → ODS is lossless while XLSX → ODS drops them. See What ODS carries.

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.

Features

Reading

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:

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

import { writeXlsx } from "hucre/xlsx"

const buffer = await writeXlsx({
  sheets: [
    {
      name: "Report",
      columns: [
        { header: "Date", key: "date", width: 15, numFmt: "yyyy-mm-dd" },
        { header: "Revenue", key: "revenue", width: 15, numFmt: "$#,##0.00" },
        { header: "Active", key: "active", width: 10 },
      ],
      data: [
        { date: new Date("2026-01-15"), revenue: 12500, active: true },
        { date: new Date("2026-01-16"), revenue: 8900, active: false },
      ],
      freezePane: { rows: 1 },
      autoFilter: { range: "A1:C3" },
    },
  ],
  defaultFont: { name: "Calibri", size: 11 },
})

Features: cell styles, auto column widths, merged cells, freeze/split panes, auto-filter (with per-column value filters — <filters><filter val="…"/></filters>; custom/dynamic/colour criteria are not emitted), data validation, hyperlinks, images (PNG/JPEG/GIF/SVG/WebP), comments, tables, conditional formatting (13 rule types — see the footnote above), named ranges, print settings, page breaks, sheet protection, workbook protection, rich text, shared/array/dynamic formulas, sparklines, textboxes, background images, number formats, hidden sheets, Excel 2024 native checkboxes, HTML/Markdown/JSON/TSV export, template engine.

Auto Column Width

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

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

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).

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:

import { streamXlsxRows, writeXlsxStream, XlsxStreamWriter } from "hucre/xlsx"

// Stream read — async generator yields rows one at a time
for await (const row of streamXlsxRows(buffer)) {
  console.log(row.index, row.values)
}

// True streaming from a ReadableStream — the ZIP is parsed front-to-back
// from local file headers, so the whole archive is never buffered. Only
// the small metadata parts (content types, rels, workbook, shared strings,
// styles) are read up front; the target worksheet is piped straight into
// the SAX parser. (Falls back to buffering only for archives whose layout
// rules out single-pass streaming — e.g. ZIP data descriptors, or shared
// strings stored after the worksheet.)
const res = await fetch("https://example.com/huge.xlsx")
for await (const row of streamXlsxRows(res.body!)) {
  console.log(row.index, row.values)
}

// Cap the number of rows yielded (preview / sampling). The underlying
// ZIP/SAX stream is cancelled once the cap is reached, so very large
// sheets stay cheap.
for await (const row of streamXlsxRows(buffer, { maxRows: 100 })) {
  console.log(row.index, row.values)
}

// Filter to an A1 range. Rows outside the row span are skipped; cells
// outside the column span are masked to `null` (column indexes stay
// stable). Parsing stops once a row past the end-row is observed.
for await (const row of streamXlsxRows(buffer, { range: "B2:D1000" })) {
  // row.values[0] === null (column A is outside)
  // row.values[1..3] carry B/C/D
}

// Stream write — the workbook is emitted as bytes while rows are pulled
// from the source, so peak memory doesn't grow with the row count.
function* rows() {
  for (let i = 0; i < 5_000_000; i++) yield [i + 1, Math.random()]
}

return new Response(
  writeXlsxStream(
    rows(), // any Iterable or AsyncIterable — a DB cursor, another stream, …
    {
      name: "BigData",
      columns: [{ header: "ID" }, { header: "Value" }],
      freezePane: { rows: 1 },
    },
  ),
  {
    headers: {
      "content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    },
  },
)

// Incremental write — serializes each row on arrival, but holds the
// serialized parts until finish() returns one buffer. Use it when you
// need a Uint8Array anyway; use writeXlsxStream for constant memory.
const writer = new XlsxStreamWriter({
  name: "BigData",
  columns: [{ header: "ID" }, { header: "Value" }],
  freezePane: { rows: 1 },
})
for (let i = 0; i < 100_000; i++) {
  writer.addRow([i + 1, Math.random()])
}
const buffer = await writer.finish()

Which writer to use

writeXlsxStream() XlsxStreamWriter
Output ReadableStream<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

Measured on 5 columns of mixed text/number/date data, writing to a sink that discards the bytes (Node 24):

Rows writeXlsxStream peak heap XlsxStreamWriter peak heap
300,000 41 MB 328 MB
1,000,000 67 MB 1,037 MB
3,000,000 70 MB not viable

One surface across the incremental writers

XlsxStreamWriter, CsvStreamWriter, and NdjsonStreamWriter share one vocabulary, so a format-agnostic export helper can be written once:

Method Behaviour
addRow(values) Append positional values
addObject(item) Append an object, projected through the writer's column order
finish() Close the writer and return its output (string or Uint8Array)
toStream() Output as a ReadableStream<Uint8Array>

Two caveats worth stating plainly:

  • toStream() on XlsxStreamWriter and CsvStreamWriter does not bound memory. Both buffer everything until finish(); the stream just hands you the finished bytes. writeXlsxStream / writeCsvStream are the constant-memory paths. NdjsonStreamWriter.toStream() is the only live drain — it releases rows as they are enqueued and stays open until finish().
  • NdjsonStreamWriter.addRow needs columns (new NdjsonStreamWriter({ columns: [...] })), because NDJSON rows are objects and positional values have no key names otherwise. It throws rather than guessing. Its old write() / end() are kept as @deprecated aliases.

Streaming trade-offs worth knowing:

  • Strings are written inline (t="inlineStr") by default. A shared string table has to live in memory until the workbook closes, which would undo the constant-memory guarantee — pass inlineStrings: false when the data is repetitive and file size matters more.
  • Part sizes aren't known when their ZIP headers go out, so entries carry trailing ZIP data descriptors and [Content_Types].xml is written last. That's the same layout archiver/zip-stream emit, which is what ExcelJS's own streaming writer produces. Verified against hucre's reader, ExcelJS, and unzip.
  • ZIP64 records are off by default, which caps any single part — and the whole archive — at 4 GiB. Crossing that throws rather than writing a broken file. Pass zip64: true to lift it (see below).
  • Compression uses the platform CompressionStream. Without it, parts are stored uncompressed rather than buffered.

ZIP64 — past 4 GiB

Reading ZIP64 archives needs no configuration: readXlsx, openXlsx, and streamXlsxRows resolve ZIP64 EOCD records and per-entry extra fields transparently, whether the producer escaped every field or only the ones that actually overflowed.

Writing is opt-in, because part sizes aren't known when their headers go out — it can't be decided per entry mid-stream:

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.

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.

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:

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:

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)

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 readXlsxwriteOds keeps values, formulas, merges and those six style facets, and drops the rest silently — there is no warning, because from ODS's side nothing was lost.

Two consequences worth knowing before you rely on it:

  • Two cell styles that differ only in an unsupported property (say two different borders) are treated as the same style and share one definition.
  • A cell styled entirely with unsupported properties gets no style reference at all, rather than an empty one.

The reader reads content.xml and meta.xml. It does not open styles.xml or settings.xml, which is where LibreOffice puts named and default cell styles and all page setup — so a LibreOffice-authored file reads back with its direct formatting only.

Widening the ODS model is open work; this table is the current contract, not a permanent one.

Round-trip Preservation

Open, modify, save — without losing charts, macros, or features hucre doesn't natively handle:

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:

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.

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.

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.

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.

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.

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

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

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

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 for the full override surface (every knob on SheetChart has a matching undefined | null | value override field).

Walking and adding charts

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:

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:

import { read, write, readObjects, writeObjects } from "hucre"

// Auto-detect XLSX vs ODS
const wb = await read(buffer)

// Quick: file → objects, plus the headers they were keyed by. Same
// { data, headers } shape — and the same options — as readXlsxObjects /
// readOdsObjects / parseCsvObjects.
const { data: products, headers } = await readObjects<{ name: string; price: number }>(buffer, {
  sheet: 0, // index or name (default: 0)
  headerRow: 0, // 0-based (default: 0)
  skipEmptyRows: true, // default
  maxRows: 1000, // optional cap on the returned rows
})

// Quick: objects → XLSX
const xlsx = await writeObjects(products, { sheetName: "Products" })

CLI

npx hucre convert input.xlsx output.csv
npx hucre convert input.csv output.xlsx
npx hucre convert legacy.xls output.xlsx # .xls / .xlsb read as input
npx hucre inspect file.xlsx
npx hucre inspect file.xlsx --sheet 0
npx hucre validate data.xlsx --schema schema.json

Input: .xlsx, .ods, .csv, .tsv, .xls, .xlsb. Output: the first four — .xls and .xlsb are read-only formats, and naming one as the output says so. convert carries cell values and sheet names only; from a legacy binary input that is all there is (see the notes on each format above).

Sheet Operations

Manipulate sheet data in memory:

import { insertRows, deleteRows, cloneSheet, moveSheet } from "hucre"

insertRows(sheet, 5, 3) // Insert 3 rows at position 5
deleteRows(sheet, 0, 1) // Delete first row
const copy = cloneSheet(sheet, "Copy") // Deep clone
moveSheet(workbook, 0, 2) // Reorder sheets

HTML & Markdown Export

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.

Reading HTML tables

fromHtml parses a table — anyone's table, not just hucre's — into a sheet:

import { fromHtml } from "hucre"

const sheet = fromHtml(scrapedHtml, {
  sheetName: "Scraped",
  typeInference: true, // default: numbers, booleans and ISO dates from text
  preserveLeadingZeros: true, // default: "007" stays a string
})

It reads <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:

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

Number Format Renderer

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

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:

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()

Template Engine

Fill {{placeholders}} in existing XLSX templates:

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.

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.

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:

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

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.

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)

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

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

// Parse — auto-detects delimiter, handles RFC 4180 edge cases
const rows = parseCsv(csvString, { 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.

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.

Schema Validation

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

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

Timezone-safe Excel date serial number conversion:

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"

Handles the Lotus 1-2-3 bug (serial 60), 1900/1904 date systems, and time fractions correctly.

Platform Support

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

Runtime Status
Node.js 18+ 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           Timezone-safe 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 (XLSX/ODS), returns Workbook
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>
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.xmlExternalLink
parseCellImages(xml) Parse xl/cellimages.xmlParsedCellImageRef[] (WPS DISPIMG)
assembleCellImages(refs, media) Combine parsed refs with resolved media bytes → CellImage[]
parseSlicers(xml) Parse xl/slicers/slicerN.xmlSlicer[]
parseSlicerCache(xml) Parse xl/slicerCaches/slicerCacheN.xmlSlicerCache | undefined
parseTimelines(xml) Parse xl/timelines/timelineN.xmlTimeline[]
parseTimelineCache(xml) Parse xl/timelineCaches/timelineCacheN.xmlTimelineCache | undefined
parsePivotTable(xml) Parse xl/pivotTables/pivotTableN.xmlPivotTable | undefined
parsePivotCacheDefinition(xml) Parse xl/pivotCache/pivotCacheDefinitionN.xmlPivotCache | undefined
attachPivotCacheFields(pt, c) Overlay PivotCache.fieldNames onto a PivotTable.fields[].name
parseChart(xml) Parse xl/charts/chartN.xmlChart | 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)
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

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.

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 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 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 or submit a PR.

See the issue tracker 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) — The most popular spreadsheet library. Feature-rich but large bundle (~300 KB), removed from npm (CDN-only), styling requires Pro license.
  • ExcelJS — Read/write/stream XLSX with styling. Mature but has 9 direct dependencies (4.4.0), CJS-only, no ESM.
  • xlsx-js-style — SheetJS fork that adds cell styling. Same bundle size and limitations as SheetJS.
  • xlsmith — Schema-driven Excel report builder with typed column definitions, formula helpers, conditional styles, and summary rows. Great for structured report generation.
  • xlsx-populate — Template-based XLSX manipulation. Good for filling existing templates, limited write-from-scratch support.
  • better-xlsx — Lightweight XLSX writer with styling. Write-only, no read support.

License

MIT — Made by productdevbook

About

Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Pure TypeScript, works everywhere.

Topics

Resources

Stars

1.8k stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages