Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Pure TypeScript, works everywhere.
Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Pure TypeScript, works everywhere.
hucre
Zero-dependency spreadsheet engine.
Read & write XLSX, CSV, ODS, JSON, NDJSON, XML. Schema validation, streaming, round-trip preservation. Pure TypeScript, works everywhere.
npm install hucreimport { 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 },
],
},
],
})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| hucre | SheetJS CE | ExcelJS | xlsx-js-style | |
|---|---|---|---|---|
| Dependencies | 0 | 0* | 9 | 0* |
| Bundle (gzip) | 4–129 KB† | ~300 KB | ~500 KB | ~300 KB |
| ESM native | Yes | Partial | No (CJS) | Partial |
| TypeScript | Native | Bolted-on | Bolted-on | Bolted-on |
| Edge runtime | Yes | No | No | No |
| CSP compliant | Yes | Yes | No (eval) | Yes |
| npm published | Yes | No (CDN only) | Stale | Yes |
| Read + Write | Yes | Yes (Pro $) | Yes | Yes |
| Styling | Yes | No (Pro $) | Yes | Yes |
| Cond. formatting | 13 types‡ | No (Pro $) | Yes | No |
| Stream read + write | Yes | CSV only | Yes | CSV only |
| ODS support | Yes§ | Yes | No | Yes |
| Round-trip | Yes | Partial | Partial | Partial |
| Sparklines | Yes | No | No | No |
| Tables | Yes | Yes | Yes | Yes |
| Images | Yes | No (Pro $) | Yes | No |
* SheetJS removed itself from npm; must install from CDN tarball.
† Depends on what you import — hucre is fully tree-shakeable, so there is no
single number. Minified + gzipped, bundled with rolldown against dist/:
{ parseCsv, writeCsv } from hucre/csv = 3.7 KB, { readXlsx } from
hucre/xlsx = 34 KB, { readXlsx, writeXlsx } = 68 KB, the entire
library (export * from "hucre") = 129 KB.
These four are measured by pnpm size and pinned in
scripts/size-budget.json, so CI fails when one grows past its budget.
They are in the README because they had already drifted once — the
previous figures (2.3 / 32 / 64 / 114 KB) were true when written and were
enforced by nothing.
‡ cellIs, expression, colorScale, dataBar, iconSet, containsText,
notContainsText, beginsWith, endsWith, containsBlanks,
notContainsBlanks, duplicateValues, uniqueValues — read and written.
top10 and aboveAverage round-trip in their default form only (the writer
emits no rank / percent / bottom or aboveAverage / equalAverage /
stdDev attributes), and timePeriod rules are dropped on read.
§ Values, formulas and merges round-trip in full; cell styling covers six facets (bold, italic, size, font colour, background colour, number format). Borders, alignment, column widths, freeze panes, validation, named ranges, images and page setup are not modelled in either direction, so ODS → ODS is lossless while XLSX → ODS drops them. See What ODS carries.
| 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.
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.
…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:
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.
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.
…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" },
],
},
],
})Process large files row-by-row without loading everything into memory:
……| 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 —
No open issues yet, or sync has not completed.