Profile
Back to NewsBack
GitHub Trending 30 min
Reader Mode
productdevbook/hucre: Zero-dependency spreadsheet engine. Read & write XLSX, CSV, ODS. Pure TypeScript, works everywhere.

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

11 hours ago


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

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

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 — ; 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

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

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 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 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 | Promise | | Rows | pulled from an (async) iterable | pushed via addRow / addObject | | Peak memory | O(distinct styles) — flat | O(data) | | Strings | inline by default | shared string table | | Sheets | one, or several with writeXlsxStreamSheets | one, plus auto-split parts |

Measured with bun run 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 bun run 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 |

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, 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:

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 5.0–2003) — read

Read legacy .xls files — the OLE2/CFB binary format from Excel 97-2003 (BIFF8) and Excel 5.0/95 (BIFF5/7, still what some 1C installs export). read() auto-detects it, or call readXls:

import { read, readXls } from "hucre"

const wb = await readXls(bytes) const same = await read(bytes) // auto-detected

// A BIFF5 file stores text in one Windows code page. The file's own // CODEPAGE record wins; codepage covers a file that has none. const cyrillic = await readXls(bytes, { codepage: 1251 })

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

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:

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:

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 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("", 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 / extension blocks are not yet re-injected when the worksheet XML is regenerated — Excel still sees the parts as wired up via rels and content-types so they survive the roundtrip, but synthesizing slicers from a fresh write is a follow-up.

Pivot Tables

Pivot tables (xl/pivotTables/pivotTableN.xml) and their workbook-level cache definitions (xl/pivotCache/pivotCacheDefinitionN.xml plus the companion pivotCacheRecordsN.xml) are read into typed workbook.pivotCaches and per-sheet sheet.pivotTables arrays. On saveXlsx the pivot parts are re-declared in [Content_Types].xml, the workbook rels, the workbook block, and each host sheet's rels — Excel previously saw the pivot parts as orphans and dropped the tables on next open.

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 in declaration order, so combo charts surface as e.g. ["bar", "line"]. Chart.series mirrors the field shape that ChartSeries accepts on the write side — a parsed series can be fed straight back into SheetChart.series. Bubble/scatter series (literal embedded data, no formula) intentionally surface no valuesRef / categoriesRef. Chart.anchor mirrors SheetChart.anchor: twoCellAnchor charts surface both from and to, oneCellAnchor charts surface from only, absoluteAnchor charts (EMU-positioned, no cell anchor) report anchor as undefined.

Write side — writeXlsx + addChart

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 w

... (README truncated for length)

Chat with me