Skip to content

feat: built-in XLSX import/export via buildFromFile/toFile (HF-107)#1702

Open
marcin-kordas-hoc wants to merge 13 commits into
developfrom
feature/hf-107-xlsx-import-export
Open

feat: built-in XLSX import/export via buildFromFile/toFile (HF-107)#1702
marcin-kordas-hoc wants to merge 13 commits into
developfrom
feature/hf-107-xlsx-import-export

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Implements HF-107: built-in XLSX import/export.

  • HyperFormula.buildFromFile(data, config?) — static async factory; accepts ArrayBuffer | Uint8Array, returns Promise<HyperFormula>.
  • hf.toFile() — instance method; returns Promise<Uint8Array> (xlsx bytes).
  • UnsupportedFileError — thrown when the bytes can't be read as xlsx (reason: 'empty' | 'unparseable').
  • New src/io/xlsx/ module (loadWorkbook, importer, exporter) backed by ExcelJS.
  • exceljs becomes a runtime dependency, lazy-loaded (await import()) and externalized in the UMD bundles, so the default/CDN bundles stay lean and keep the single-license-preamble invariant (verify:umd / verify:umd:full).
  • Guide updated: docs/guide/file-import.md.

Scope (v1)

Preserves multiple sheets, values, formulas (=-prefixed, _xlfn./_xlws. stripped), best-effort dates/errors. Values + formulas only — styling, number formats, merged cells, etc. are not preserved (HyperFormula has no formatting model).

Deferred follow-ups: robust byte-signature / CFB format detection, date1904 + full error-token mapping + object-cell fidelity, named expressions, CSV, code-level Premium gating.

Tests

Specs live in the tests repo — see the paired handsontable/hyperformula-tests PR on branch feature/hf-107-xlsx-import-export (7 spec files / 36 tests: unit + round-trip, incl. an ExcelJS-authored round-trip and a formatting-loss proof).

Notes

  • The UMD full bundle now expects a global ExcelJS for xlsx (external dependency).
  • tsconfig module bumped es2015 → es2020 (required for the lazy dynamic import()).

🤖 Generated with Claude Code


Note

Medium Risk
New public async APIs and a large runtime dependency affect bundle/CDN consumers (must supply ExcelJS for UMD xlsx); round-trip fidelity is intentionally limited to values/formulas.

Overview
Adds first-class .xlsx import and export on HyperFormula: async buildFromFile(ArrayBuffer | Uint8Array) builds an engine from workbook bytes, and toFile() returns serialized sheets as .xlsx bytes. Parsing and writing go through a new src/io/xlsx/ layer (load → import / export) backed by ExcelJS, which moves from dev-only to a runtime dependency and is lazy-loaded via dynamic import() so bundles stay smaller until xlsx is used.

Invalid or empty input raises UnsupportedFileError (empty / unparseable). UMD builds externalize exceljs (global ExcelJS on full/base CDN bundles) to preserve license-preamble checks; tsconfig module is bumped to es2020 for dynamic import emit. The file guide is rewritten around the new APIs; v1 is values and formulas only (no styles, merges, etc.).

Reviewed by Cursor Bugbot for commit f376892. Bugbot is set up for automated code reviews on this repo. Configure here.

marcin-kordas-hoc and others added 12 commits July 7, 2026 12:34
…HF-107)

exceljs was a devDependency; HF-107 makes xlsx import/export a built-in
feature, so it becomes a runtime dependency. @types/exceljs stays a
devDependency (build-time only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a public UnsupportedFileError class to src/errors.ts for later
HF-107 tasks (workbook loader/importer/buildFromFile) to throw when a
byte buffer can't be read as an .xlsx file. v1 covers only 'empty' and
'unparseable' reasons per spec; richer reasons are a deferred follow-up.

Re-exported from src/index.ts alongside the other public error types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v1 scope: attempt the ExcelJS load and wrap any failure in
UnsupportedFileError('empty' | 'unparseable'). Byte-signature/ZIP-magic/CFB
format detection is deliberately deferred (YAGNI). Handles both ArrayBuffer
and Uint8Array inputs, including subarray views with a non-zero byteOffset,
via Buffer.from(data) (never data.buffer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert an already-loaded ExcelJS Workbook into HyperFormula's Sheets
shape. Pure and synchronous: byte-loading stays in loadXlsxWorkbook
(Task 3). Maps simple formulas ('=' + cell.formula), primitive values,
best-effort Date pass-through, and falls back to cell.text for
object-valued cells (error/richText/hyperlink) so raw objects are
never emitted. Shared/array formulas, _xlfn. normalization, and the
full error-token table are deliberately deferred (Task 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Excel stores newer/relocated functions (e.g. XLOOKUP) with an internal
_xlfn. (sometimes _xlfn._xlws.) prefix in the raw formula string. Left
in place, HF would fail to resolve the function name even when it
natively supports it. Add stripFunctionPrefixes() and apply it in the
importer's formula branch.

Confirming tests added for shared-formula per-cell rebasing and
cross-sheet/quoted-name references, which a probe of ExcelJS's real
round-trip behavior showed were already handled correctly by the
existing '=' + cell.formula mapping - no rebasing logic needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…107)

Chains loadXlsxWorkbook -> workbookToSheets -> buildFromSheets into a single
async static factory so consumers can go straight from raw .xlsx bytes to a
live engine. No import options, named expressions, or format detection in v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sheetsToXlsx() maps HF's getAllSheetsSerialized() output (values +
'='-prefixed formula strings) onto an ExcelJS workbook and returns
Uint8Array bytes. toFile() wires this to a live engine instance.
v1 scope: values and formulas only, no styles/number-formats/merged
cells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers identity round-trip for HF-authored values+formulas (dates
excluded, by design), a second-round-trip stability check seeded from
an ExcelJS-authored workbook (breaks buildFromArray symmetry), and a
lossy-formatting proof (fill/font/numFmt do not survive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites the file-import guide to lead with the built-in buildFromFile
(import) and toFile (export) API, with Node and browser snippets, a
values+formulas-only limitations note, and the third-party approach kept
as an advanced fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep exceljs a core dependency but load it lazily so it is no longer
inlined into the eager module graph of the always-imported HyperFormula
class, nor into the default CDN (base) bundle.

- importer.ts: value import -> `import type` (Cell/Workbook/Worksheet are
  types only; it never constructs a Workbook).
- loadWorkbook.ts / exporter.ts: `import type {Workbook}` for the signature
  type plus a runtime `await import(/* webpackMode: "eager" */ 'exceljs')`
  inside the async functions. The eager magic comment keeps HyperFormula's
  own single-file `full` UMD from splitting into a separate chunk while
  downstream ESM/CJS bundlers still see a real dynamic import and code-split.
- .config/webpack/development.js: add exceljs to the base bundle externals
  (alongside chevrotain/tiny-emitter); configFull still inlines everything.
- tsconfig.json: `module` es2015 -> es2020, required for dynamic `import()`
  to typecheck/compile (TS1323). Emit is unchanged for every module that does
  not use dynamic import / import.meta (only these two files change).
- importer.ts: note stripFunctionPrefixes may rewrite a matching substring
  inside a string literal (accepted best-effort v1 limitation).
- docs/guide/file-import.md: note exported dynamic-array formulas lack Excel's
  internal `_xlfn.` prefix and may not resolve in real Excel.
- package.json: drop unused @types/exceljs (exceljs 4.4.0 ships its own types).

Verified: jest test/io + smoke (40 tests green), tsc --noEmit clean, eslint
0 errors. Base bundle no longer inlines exceljs (jszip markers 7 -> 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- loadXlsxWorkbook: convert input via a zero-offset Uint8Array copy and pass
  its ArrayBuffer to ExcelJS, instead of Buffer.from (which pulled webpack's
  ~feross/buffer polyfill into the browser bundle).
- webpack: externalize exceljs in the 'full' bundle too (not just base), so
  neither UMD bundle inlines exceljs; this keeps the single-license-preamble
  invariant (verify:umd / verify:umd:full assert exactly 2 @license comments).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the two-repo workflow, HF-107's unit/round-trip specs live in the
private hyperformula-tests repo (matching branch), not the public repo.
Source, docs, and build config remain here; the specs move out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploy Preview for hyperformula-dev-docs failed. Why did it fail? →

Name Link
🔨 Latest commit f376892
🔍 Latest deploy log https://app.netlify.com/projects/hyperformula-dev-docs/deploys/6a593af7b164f20008d363d8

@qunabu

qunabu commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Task linked: HF-107 Import/export files (XLSX, CSV)

Comment thread src/io/xlsx/exporter.ts Outdated
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Performance comparison of head (f376892) vs base (4e302ff)

                                     testName |   base |   head | change
------------------------------------------------------------------------
                                      Sheet A | 486.02 | 491.25 | +1.08%
                                      Sheet B | 154.57 | 155.55 | +0.63%
                                      Sheet T | 138.05 | 140.36 | +1.67%
                                Column ranges | 467.71 | 469.69 | +0.42%
Sheet A:  change value, add/remove row/column |  14.27 |  14.62 | +2.45%
 Sheet B: change value, add/remove row/column | 127.07 | 128.54 | +1.16%
                   Column ranges - add column | 140.87 | 141.82 | +0.67%
                Column ranges - without batch | 429.73 | 427.03 | -0.63%
                        Column ranges - batch |  108.2 |  112.6 | +4.07%

…(HF-107)

ExcelJS `writeBuffer()` returns a Node `Buffer` in Node but an `ArrayBuffer`
in the browser. `Uint8Array.from()` treats an `ArrayBuffer` as an empty
array-like and silently drops all bytes, so `toFile()` produced corrupt/empty
`.xlsx` files in browser builds. Use `new Uint8Array(...)`, which views an
ArrayBuffer and copies a Buffer — correct on both targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f376892. Configure here.

try {
const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs')
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(bytes.buffer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broken XLSX load from Node Buffers

High Severity

loadXlsxWorkbook copies input with .slice() then passes bytes.buffer to ExcelJS. On Node, Buffer (a Uint8Array) overrides .slice() to share memory, and .buffer ignores byteOffset/byteLength. Pooled or sliced Buffers—common from fs.readFile for small files—can load the wrong bytes and fail as UnsupportedFileError, despite the docs advertising that path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f376892. Configure here.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.42%. Comparing base (00e5c73) to head (f376892).
⚠️ Report is 14 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1702      +/-   ##
===========================================
- Coverage    97.16%   96.42%   -0.75%     
===========================================
  Files          176      179       +3     
  Lines        15322    15561     +239     
  Branches      3356     3448      +92     
===========================================
+ Hits         14887    15004     +117     
- Misses         427      557     +130     
+ Partials         8        0       -8     
Files with missing lines Coverage Δ
src/HyperFormula.ts 99.73% <100.00%> (+<0.01%) ⬆️
src/errors.ts 100.00% <100.00%> (ø)
src/index.ts 100.00% <100.00%> (ø)
src/io/xlsx/exporter.ts 100.00% <100.00%> (ø)
src/io/xlsx/importer.ts 100.00% <100.00%> (ø)
src/io/xlsx/loadWorkbook.ts 100.00% <100.00%> (ø)

... and 28 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants