Skip to content

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

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

marcin-kordas-hoc wants to merge 24 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

Context

HyperFormula has no way to read or write an .xlsx file: an application parses the file with a library of its choice and passes arrays to buildFromSheets. This pull request adds that step to the engine, backed by ExcelJS.

  • HyperFormula.buildFromFile(data, config?) is an asynchronous factory method. It takes the file's bytes as an ArrayBuffer or a Uint8Array and returns a Promise<HyperFormula> built from the file's worksheets, keyed by worksheet name, as buildFromSheets would build it.
  • toFile() returns a Promise<Uint8Array> with the engine's sheets as an .xlsx file.
  • UnsupportedFileError, with reason 'empty' or 'unparseable', is what buildFromFile rejects with when the bytes cannot be read as an .xlsx workbook.
  • The code lives in hyperformula/src/io/xlsx/.

Only cell values and formulas are read and written. Styles, number formats, merged cells and named expressions are not; a date is read as its serial number. On import, Excel's _xlfn. and _xlws. function prefixes are removed, and a text cell that starts with = stays text. On export, a cell that holds a value is written as its computed value, so a number entered as the string '1' is written as a number, and an error as an Excel error where Excel has one. Formulas in an .xlsx file always use English function names, , between arguments and . as the decimal separator: toFile() writes them that way whatever the engine's language and separators are, and buildFromFile() converts them to the configured ones, building the engine twice when they differ.

ExcelJS is an optional peer dependency (^4.4.0), so it is not installed with HyperFormula. Either method loads it with a dynamic import(), taking the API from the module's default export when Node's native import() puts it only there; without ExcelJS installed, Node rejects the call with ERR_MODULE_NOT_FOUND. The UMD bundles keep it external and expect the global ExcelJS; the Karma test bundle includes it, and the documentation's server-side bundle keeps it external. tsconfig.json moves module from es2015 to es2020, which the dynamic import() needs.

Status: a draft. The choice of ExcelJS, and how an application that never calls these methods is kept free of it, are open decisions; see "Out of scope and follow-ups".

How did you test your changes?

  • Paired tests: handsontable/hyperformula-tests#22, branch feature/hf-107-xlsx-import-export: 8 spec files in unit/io/, 49 tests, all passing.
  • The tests for importing text that starts with =, for exported values, for the formula syntax of other languages and separators, and for loading ExcelJS through a default export fail against this branch before those fixes.
  • Full Jest suite with the paired tests: 528 suites, 6791 passed, 3 skipped, 0 failed.
  • Published builds: after npm run bundle-all, a Node script that loads hyperformula/commonjs and another that imports hyperformula/es/index.mjs each export an engine with toFile() and read it back with buildFromFile().
  • ESLint on the changed files: 0 errors.
  • UMD bundle sizes against develop: hyperformula.full.min.js 994,367 bytes against 988,920, hyperformula.min.js 827,468 against 822,016.

Compatibility with Excel and Google Sheets

Not measured in Excel or Google Sheets for this pull request. Files written by toFile() were read back with ExcelJS only.

Formula Excel Google Sheets HyperFormula
=SUM(A1;B1;0,5) in an engine with ; and , separators, written by toFile() not measured not measured stored in the file as SUM(A1,B1,0.5)

Public API changes

  • Added HyperFormula.buildFromFile(data, config?).
  • Added HyperFormula#toFile().
  • Added UnsupportedFileError, a named export of the package and a static property of its default export.
  • Added the optional peer dependency exceljs.

Out of scope and follow-ups

  • Styles, number formats, merged cells, named expressions and CSV.
  • toFile() rejects with ExcelJS's own error for a sheet name Excel does not allow, such as a/b, and ExcelJS shortens a name longer than 31 characters.
  • Open decisions, needed before this can be merged:
    1. ExcelJS itself. Its last release, 4.4.0, is from 2023-10-19. NVD lists four vulnerabilities published on 2026-08-24 for "exceljs through 4.4.0", with no fixed release; CVE-2026-78206 (CVSS 4.0 score 8.7) is unbounded decompression of a supplied .xlsx file, the path buildFromFile() exposes. GitHub's advisory database, which npm audit and Snyk read, lists only CVE-2018-16459 for exceljs, so neither reports them; the green audit and security/snyk checks here also follow from exceljs being a dev and peer dependency only.
    2. Applications that never call these methods. import('exceljs') is a static specifier, so bundlers resolve it: a webpack 4 or esbuild build of an application that imports HyperFormula fails when exceljs is not installed. When it is installed, an esbuild bundle without code splitting grows from 826,362 to 1,780,998 bytes, because ExcelJS is bundled in.

Types of changes

  • Breaking change (existing functionality no longer works as before) – agreed with the maintainers
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues

  1. HF-107

Checklist

🤖 Generated with Claude Code

@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 (4ca6c46) vs base (d143456)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  380.09 |  357.86 | -5.85%
                                      Sheet B |  114.18 |  107.59 | -5.77%
                                      Sheet T |  101.48 |   97.35 | -4.07%
                                Column ranges |   486.9 |  476.65 | -2.11%
                                Sorted lookup | 15015.1 | 14391.9 | -4.15%
Sheet A:  change value, add/remove row/column |   10.04 |    9.35 | -6.87%
 Sheet B: change value, add/remove row/column |   89.86 |   91.38 | +1.69%
                   Column ranges - add column |  125.11 |  122.16 | -2.36%
                Column ranges - without batch |  384.33 |  389.04 | +1.23%
                        Column ranges - batch |   98.44 |   95.76 | -2.72%

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

Stale Bugbot comment from a previous run.

Comment thread hyperformula/src/io/xlsx/loadWorkbook.ts
marcin-kordas-hoc and others added 13 commits August 27, 2026 08:06
…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>
…(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>
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the feature/hf-107-xlsx-import-export branch from f376892 to f28710e Compare August 27, 2026 08:15
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs 4ca6c46 Commit Preview URL

Branch Preview URL
Oct 23 2026, 07:21 AM

marcin-kordas-hoc and others added 2 commits August 28, 2026 08:17
The branch had been open since 8 July and was 9 commits behind develop, which
left the pull request unmergeable. Merged rather than rebased: the paired
hyperformula-tests branch is pinned by exact branch name, so rewriting the 14
engine commits would unpin it, and a force-push would orphan the commit anchors
of the two review threads on the pull request.

Two conflicts, both resolved keeping each side's intent:

- CHANGELOG.md was an adjacent insert, not a real disagreement: develop added
  four entries under ### Fixed while this branch added an ### Added section in
  the same place. Both are kept.
- docs/guide/file-import.md took develop's new frontmatter block verbatim and
  this branch's rewritten body, including the heading, which now covers export
  as well as import.

package-lock.json merged without conflict and exceljs survives it at ^4.4.0,
verified by reading the merged lockfile and by a clean npm ci in this worktree.

Co-Authored-By: Claude Opus 5 (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.

Stale Bugbot comment from a previous run.

Comment thread src/io/xlsx/importer.ts Outdated
return value
}

return cell.text ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Equals-prefixed strings become formulas

Medium Severity

Excel text cells that start with = are passed through as plain strings, then buildFromSheets / CellContentParser treats any =-prefixed string as a formula. The same check on export writes HyperFormula text values that start with = (entered with a leading ') as formula cells. Round-tripping or importing apostrophe-escaped Excel text silently changes those cells into formulas.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7ad1ff2. Configure here.

Comment thread src/io/xlsx/loadWorkbook.ts Outdated
Adding exceljs to the production dependency tree pulled a moderate advisory into
it (uuid, GHSA-w5hq-g745-h8pq, reachable only through exceljs), which is what the
audit and security checks were reporting. develop's own tree audits clean, and
no published exceljs avoids it: the latest release still depends on the affected
range, and npm's remedy is a downgrade it flags as breaking.

Declaring it an optional peer dependency removes it from that tree without
changing the library choice or a line of engine code. The code was already
written for it: all three call sites load exceljs with a dynamic import, and only
the type-level imports are static, so nothing is pulled in until buildFromFile or
toFile is actually called. The webpack config already assumed this too -- the
full bundle keeps exceljs external and documents that consumers provide it -- so
the manifest was the one place still claiming it was unconditional.

Measured on the regenerated lockfile: `npm audit --omit=dev --package-lock-only`
reports 0 vulnerabilities, against 2 moderate before. exceljs is also kept as a
devDependency so the test suite and the docs build still resolve it; the feature's
own 36 specs pass unchanged.

Consumer-visible: `npm install hyperformula` no longer installs exceljs, and users
of the two file methods add it themselves. The guide and the changelog say so.

Co-Authored-By: Claude Opus 5 <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.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

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

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7c4f9e0. Configure here.

Comment thread package.json Outdated
marcin-kordas-hoc and others added 3 commits September 14, 2026 04:46
Regenerating the lockfile also refreshed two transitive packages of exceljs
within their existing ranges -- dayjs 1.11.21 to 1.11.23 and jszip 3.10.1 to
3.10.2 -- which has nothing to do with reclassifying the dependency and is the
kind of drift that makes a diff hard to review. Both are pinned back to the
versions on the previous commit.

The lockfile now differs from before this change only by which packages are
marked as development ones: no package added, none removed, no version changed.
The production tree still audits clean and the feature's 36 specs still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The documentation bundles HyperFormula's full build, which keeps exceljs
external and so emits a bare require('exceljs'). Server-side rendering resolved
that to ExcelJS's Node entry, and its stream reader reaches unzipper, then
fstream, then rimraf -- which ships ESM using optional chaining that this
webpack cannot parse. The server bundle therefore failed to compile while the
client bundle compiled cleanly, which is why the docs build and the Cloudflare
docs deployment both failed and nothing else did.

Nothing in the documentation calls buildFromFile or toFile, so exceljs stays
external in the server build as well: Node would resolve it at call time, and
there is no call.

Verified by running the build: exit 0, "Server: Compiled successfully", zero
module parse errors, 216 sitemap locations written. It failed before this change
in the same worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Karma builds its bundle from the full webpack config, which keeps exceljs
external and expects a global ExcelJS. Karma loads only karma.starter.ts, so
that global never exists and every browser spec touching xlsx import/export
failed against a reference to nothing.

The reasons for externalising exceljs -- bundle size, and transitive licenses
breaking the single-preamble invariant -- apply to the published artifact, not
to a bundle that exists for the duration of a test run, so the test bundle
simply includes it.

Same diagnosis as the docs-build fix in the previous commit, where it was
confirmed by running the build. This one could not be confirmed locally: Karma
has no browser to launch in this environment -- neither ChromeHeadless nor
FirefoxHeadless has a binary -- so CI is the first run of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as draft September 14, 2026 15:32
@marcin-kordas-hoc
marcin-kordas-hoc removed the request for review from Tobiadefami September 14, 2026 15:32
marcin-kordas-hoc and others added 5 commits October 23, 2026 00:01
…o the XLSX import/export branch

The xlsx module moves to hyperformula/src/io/xlsx, the exceljs peer declaration moves to
hyperformula/package.json, and the lockfile is regenerated from develop's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xposes it only there

The published commonjs and es builds keep import('exceljs') as a native dynamic import, which in
Node exposes the CommonJS package only as the default export, so buildFromFile and toFile failed
with a TypeError. The load error is no longer reported as an unreadable file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e as text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e syntax of an xlsx file

A number entered as a string was written as text, and an engine with another language or other
separators wrote formulas Excel cannot read; buildFromFile now converts the file's formulas to the
configured syntax.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x import and export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.30%. Comparing base (d143456) to head (4ca6c46).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1702      +/-   ##
===========================================
+ Coverage    97.28%   97.30%   +0.02%     
===========================================
  Files          207      212       +5     
  Lines        16218    16357     +139     
  Branches      3556     3624      +68     
===========================================
+ Hits         15777    15916     +139     
  Misses         433      433              
  Partials         8        8              
Files with missing lines Coverage Δ
hyperformula/src/HyperFormula.ts 97.46% <100.00%> (+0.05%) ⬆️
hyperformula/src/errors.ts 100.00% <100.00%> (ø)
hyperformula/src/index.ts 100.00% <100.00%> (ø)
hyperformula/src/io/xlsx/exporter.ts 100.00% <100.00%> (ø)
hyperformula/src/io/xlsx/formulaSyntax.ts 100.00% <100.00%> (ø)
hyperformula/src/io/xlsx/importer.ts 100.00% <100.00%> (ø)
hyperformula/src/io/xlsx/loadExcelJs.ts 100.00% <100.00%> (ø)
hyperformula/src/io/xlsx/loadWorkbook.ts 100.00% <100.00%> (ø)
🚀 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.

This branch has not been deployed

No deployments
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