Repository navigation
feat: built-in XLSX import/export via buildFromFile/toFile (HF-107) - #1702
marcin-kordas-hoc wants to merge 24 commits into
Conversation
❌ Deploy Preview for hyperformula-dev-docs failed. Why did it fail? →
|
|
Task linked: HF-107 Import/export files (XLSX, CSV) |
Performance comparison of head (4ca6c46) vs base (d143456) |
…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>
f376892 to
f28710e
Compare
Deploying with
|
| 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 |
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>
| return value | ||
| } | ||
|
|
||
| return cell.text ?? null |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 7ad1ff2. Configure here.
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>
There was a problem hiding this comment.
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).
❌ 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.
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>
…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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|


Context
HyperFormula has no way to read or write an
.xlsxfile: an application parses the file with a library of its choice and passes arrays tobuildFromSheets. 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 anArrayBufferor aUint8Arrayand returns aPromise<HyperFormula>built from the file's worksheets, keyed by worksheet name, asbuildFromSheetswould build it.toFile()returns aPromise<Uint8Array>with the engine's sheets as an.xlsxfile.UnsupportedFileError, withreason'empty'or'unparseable', is whatbuildFromFilerejects with when the bytes cannot be read as an.xlsxworkbook.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.xlsxfile 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, andbuildFromFile()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 dynamicimport(), taking the API from the module'sdefaultexport when Node's nativeimport()puts it only there; without ExcelJS installed, Node rejects the call withERR_MODULE_NOT_FOUND. The UMD bundles keep it external and expect the globalExcelJS; the Karma test bundle includes it, and the documentation's server-side bundle keeps it external.tsconfig.jsonmovesmodulefromes2015toes2020, which the dynamicimport()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?
feature/hf-107-xlsx-import-export: 8 spec files inunit/io/, 49 tests, all passing.=, for exported values, for the formula syntax of other languages and separators, and for loading ExcelJS through adefaultexport fail against this branch before those fixes.npm run bundle-all, a Node script that loadshyperformula/commonjsand another that importshyperformula/es/index.mjseach export an engine withtoFile()and read it back withbuildFromFile().develop:hyperformula.full.min.js994,367 bytes against 988,920,hyperformula.min.js827,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.=SUM(A1;B1;0,5)in an engine with;and,separators, written bytoFile()SUM(A1,B1,0.5)Public API changes
HyperFormula.buildFromFile(data, config?).HyperFormula#toFile().UnsupportedFileError, a named export of the package and a static property of its default export.exceljs.Out of scope and follow-ups
toFile()rejects with ExcelJS's own error for a sheet name Excel does not allow, such asa/b, and ExcelJS shortens a name longer than 31 characters..xlsxfile, the pathbuildFromFile()exposes. GitHub's advisory database, whichnpm auditand Snyk read, lists only CVE-2018-16459 forexceljs, so neither reports them; the greenauditandsecurity/snykchecks here also follow fromexceljsbeing a dev and peer dependency only.import('exceljs')is a static specifier, so bundlers resolve it: a webpack 4 or esbuild build of an application that imports HyperFormula fails whenexceljsis 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
Related issues
Checklist
dev-docs/pages this change affects – or none needed it.🤖 Generated with Claude Code