diff --git a/CHANGELOG.md b/CHANGELOG.md
index 323c746b2d..3ffa22857d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added
- Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829)
+- Added `.xlsx` import and export of cell values and formulas: the `buildFromFile()` and `toFile()` methods. Both need ExcelJS, an optional peer dependency: install it with `npm install exceljs`. [#1702](https://github.com/handsontable/hyperformula/pull/1702)
### Fixed
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index a05381cf74..b9a781bdd7 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -106,6 +106,17 @@ const DOCS_DEST = process.env.DOCS_DEST || buildConfigOverrides.dest || '.vuepre
const DOCS_HOSTNAME = process.env.DOCS_HOSTNAME || buildConfigOverrides.hostname || 'https://handsontable.com';
module.exports = {
+ // The docs bundle HyperFormula's 'full' build, which keeps exceljs external and therefore
+ // emits a bare require('exceljs'). In the server-side render that resolves to ExcelJS's Node
+ // entry, whose stream reader reaches unzipper -> fstream -> rimraf; rimraf ships ESM using
+ // optional chaining, which this webpack cannot parse, so the server bundle failed to compile.
+ // Nothing in the documentation calls buildFromFile or toFile, so exceljs is left external here
+ // too: Node would resolve it at call time, and there is no call.
+ configureWebpack: (config, isServer) => {
+ if (isServer) {
+ config.externals = [].concat(config.externals || [], 'exceljs');
+ }
+ },
// Default page globs, minus the built-in-functions template: it is the INPUT of docs:generate-function-docs,
// not a page, and without this exclusion VuePress would publish it as a duplicate, table-less
// /guide/built-in-functions.tmpl.html (also polluting the sitemap, search index, and llms.txt corpus).
diff --git a/docs/README.md b/docs/README.md
index 07c85dedce..0f7ff35913 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -122,7 +122,7 @@ docs # All documentation files
│ ├── subtheme # Subtheme files
│ ├── templates # HTML templates
│ ├── theme # Local theme extending the default theme (see below)
-│ ├── config.js # VuePress configuration
+│ ├── config.js # VuePress configuration; keeps exceljs external in the server-side bundle
│ ├── enhanceApp.js # VuePress app-level enhancements
│ └── highlight.js # Code highlight configuration
├── api # The API reference files, generated automatically from JsDoc. Do not edit!
diff --git a/docs/guide/building.md b/docs/guide/building.md
index 5ab9edadfa..af32324a61 100644
--- a/docs/guide/building.md
+++ b/docs/guide/building.md
@@ -55,6 +55,9 @@ have dependencies, they need to be added manually
* `./dist/hyperformula.full.min.js` - a minified version with
dependencies
+None of them includes ExcelJS, which the `buildFromFile` and `toFile` methods
+need: load it separately, as the global `ExcelJS`.
+
## Build the project
To build the project you can use the following commands:
diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md
index a937071807..0d421a4dcd 100644
--- a/docs/guide/file-import.md
+++ b/docs/guide/file-import.md
@@ -7,72 +7,141 @@ tags:
- read Excel file
---
-# File import
+# File import and export
-Import XLSX and CSV files into HyperFormula.
+Import and export XLSX files, and import CSV files, with HyperFormula.
## Overview
-HyperFormula has no built-in file import functionality. But its [factory methods](../api/classes/hyperformula.md#factories) use standard JavaScript data types, for easy integration with any way of importing data.
+HyperFormula imports and exports `.xlsx` files through the
+[ExcelJS](https://www.npmjs.com/package/exceljs) library. Import with the
+[`buildFromFile`](../api/classes/hyperformula.md#buildfromfile) factory method
+and export with the [`toFile`](../api/classes/hyperformula.md#tofile) method.
-## Import CSV files
-
-To import CSV files, use a third-party [CSV parser](https://www.npmjs.com/search?q=csv) (e.g., [PapaParse](https://www.npmjs.com/package/papaparse) or [csv-parse](https://www.npmjs.com/package/csv-parse)). Then pass the result to HyperFormula as a JavaScript array.
+ExcelJS is an **optional peer dependency**, so it is not installed with
+HyperFormula. Add it only if you use these two methods:
-## Import XLSX files
+```bash
+npm install exceljs
+```
-To import XLSX files, use a third-party [XLSX parser](https://www.npmjs.com/search?q=xlsx) (e.g., [ExcelJS](https://www.npmjs.com/package/exceljs) or [xlsx](https://www.npmjs.com/package/xlsx)). Then pass the result to HyperFormula as a JavaScript array.
+In Node.js, everything else in HyperFormula works without it: the library is
+loaded on demand, so calling `buildFromFile` or `toFile` without ExcelJS
+installed fails at the point of the call. A bundler such as webpack or esbuild,
+however, resolves the library when it builds your application, so the build
+fails if ExcelJS is not installed, even if your code never calls these two
+methods. In the UMD `full` build, ExcelJS is kept out of the bundle and has to
+be provided as the global `ExcelJS`.
+
+For any other format (for example CSV), HyperFormula's
+[factory methods](../api/classes/hyperformula.md#factories) accept standard
+JavaScript arrays, so you can plug in any third-party parser.
+
+::: tip
+Only **cell values and formulas** are imported and exported. Cell styling,
+number formats, merged cells, charts, images, data validation, and other
+Excel-specific features are **not** preserved — HyperFormula stores only
+values and formulas. A cell that holds a value is exported as its value, so a
+number entered as a string, such as `'1'`, is exported as a number. Formulas are
+converted between the syntax of the file (English function names, `,` between
+arguments, `.` as the decimal separator) and the language and separators your
+configuration uses. On export, formulas that use newer or dynamic-array Excel functions are written
+without Excel's internal `_xlfn.` prefix, so they may not resolve when the
+exported file is opened in real Excel.
+:::
-### Example: Import XLSX files in Node
+## Import XLSX files
-This example uses [ExcelJS](https://www.npmjs.com/package/exceljs) to import XLSX files into HyperFormula.
+Pass the file's bytes (an `ArrayBuffer` or `Uint8Array`) to the asynchronous
+[`buildFromFile`](../api/classes/hyperformula.md#buildfromfile) factory method.
+Reading the file itself is left to your environment, so the same API works in
+both Node.js and the browser.
-See full example on [GitHub](https://github.com/handsontable/hyperformula-demos/tree/3.5.x/read-excel-file).
+### In Node.js
```js
-const ExcelJS = require('exceljs');
+const fs = require('fs');
const { HyperFormula } = require('hyperformula');
-async function run(filename) {
- const xlsxWorkbook = await readXlsxWorkbookFromFile(filename);
- const sheetsAsJavascriptArrays = convertXlsxWorkbookToJavascriptArrays(xlsxWorkbook)
- const hf = HyperFormula.buildFromSheets(sheetsAsJavascriptArrays, { licenseKey: 'gpl-v3' });
+async function importXlsx(filePath) {
+ const data = fs.readFileSync(filePath); // a Buffer (a Uint8Array)
+ const hf = await HyperFormula.buildFromFile(data, { licenseKey: 'gpl-v3' });
console.log('Formulas:', hf.getSheetSerialized(0));
console.log('Values: ', hf.getSheetValues(0));
-}
-async function readXlsxWorkbookFromFile(filename) {
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.readFile(filename);
- return workbook;
+ return hf;
}
+```
+
+### In the browser
+
+```js
+import { HyperFormula } from 'hyperformula';
-function convertXlsxWorkbookToJavascriptArrays(workbook) {
- const workbookData = {};
+// `file` is a File obtained from an element.
+async function importXlsx(file) {
+ const data = await file.arrayBuffer(); // an ArrayBuffer
+ const hf = await HyperFormula.buildFromFile(data, { licenseKey: 'gpl-v3' });
- workbook.eachSheet((worksheet) => {
- const sheetDimensions = worksheet.dimensions
- const sheetData = [];
+ return hf;
+}
+```
- for (let rowNum = sheetDimensions.top; rowNum <= sheetDimensions.bottom; rowNum++) {
- const rowData = [];
+If the bytes cannot be read as an `.xlsx` file, `buildFromFile` rejects with an
+[`UnsupportedFileError`](../api/classes/unsupportedfileerror.md).
- for (let colNum = sheetDimensions.left; colNum <= sheetDimensions.right; colNum++) {
- const cell = worksheet.getCell(rowNum, colNum)
+## Export XLSX files
- const cellData = cell.formula ? `=${cell.formula}` : cell.value;
- rowData.push(cellData);
- }
+The asynchronous [`toFile`](../api/classes/hyperformula.md#tofile) method returns
+the workbook's bytes as a `Uint8Array`. Writing those bytes is left to your
+environment.
- sheetData.push(rowData);
- }
+### In Node.js
- workbookData[worksheet.name] = sheetData;
- })
+```js
+const fs = require('fs');
- return workbookData;
+async function exportXlsx(hf, filePath) {
+ const bytes = await hf.toFile();
+ fs.writeFileSync(filePath, bytes);
}
+```
+
+### In the browser
-run('sample_file.xlsx');
+```js
+async function downloadXlsx(hf, fileName) {
+ const bytes = await hf.toFile();
+ const blob = new Blob([bytes], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ URL.revokeObjectURL(url);
+}
```
+
+## Import CSV files
+
+HyperFormula has no built-in CSV parser. Use a third-party
+[CSV parser](https://www.npmjs.com/search?q=csv) (e.g.,
+[PapaParse](https://www.npmjs.com/package/papaparse) or
+[csv-parse](https://www.npmjs.com/package/csv-parse)), then pass the resulting
+array to [`buildFromArray`](../api/classes/hyperformula.md#buildfromarray).
+
+## Advanced: custom XLSX handling
+
+If you need control over how the workbook is read or written (for example, to
+read cached values instead of formulas, or to handle Excel features outside
+HyperFormula's value-and-formula model), parse the file yourself with a
+third-party [XLSX parser](https://www.npmjs.com/search?q=xlsx) such as
+[ExcelJS](https://www.npmjs.com/package/exceljs) or
+[xlsx](https://www.npmjs.com/package/xlsx), convert the result to JavaScript
+arrays, and pass them to
+[`buildFromSheets`](../api/classes/hyperformula.md#buildfromsheets). See a full
+example on [GitHub](https://github.com/handsontable/hyperformula-demos/tree/3.5.x/read-excel-file).
diff --git a/hyperformula/.config/karma/base.js b/hyperformula/.config/karma/base.js
index 6115e5bbe9..dfff21acc1 100644
--- a/hyperformula/.config/karma/base.js
+++ b/hyperformula/.config/karma/base.js
@@ -73,6 +73,14 @@ module.exports.create = function(config) {
// Take the second config from an array - full HF build.
const config = webpackConfigFactory('development')[1];
+ // That build keeps exceljs external so consumers can supply the global themselves. A test
+ // bundle has no consumer to supply it, so the reference resolved to nothing in the browser
+ // and every spec touching xlsx import/export failed. Bundle it in instead: the size and
+ // license reasons for externalising it apply to what is published, not to what Karma runs.
+ if (config.externals && config.externals.exceljs) {
+ delete config.externals.exceljs;
+ }
+
// Loaders are executed from bottom to top. Push ts-loader as a first loader.
config.module.rules[0].use.push({
loader: 'ts-loader',
diff --git a/hyperformula/.config/webpack/development.js b/hyperformula/.config/webpack/development.js
index 029b8246ce..5602efd5a7 100644
--- a/hyperformula/.config/webpack/development.js
+++ b/hyperformula/.config/webpack/development.js
@@ -29,12 +29,30 @@ module.exports.create = function create() {
commonjs: 'tiny-emitter',
amd: 'tiny-emitter',
},
+ exceljs: {
+ root: 'ExcelJS',
+ commonjs2: 'exceljs',
+ commonjs: 'exceljs',
+ amd: 'exceljs',
+ },
};
c.plugins.push(new WebpackBar({ name: ` ${PACKAGE_FILENAME}.js` }));
});
configFull.forEach(function(c) {
c.output.filename = `${PACKAGE_FILENAME}.full.js`;
+ // The 'full' bundle inlines HyperFormula's own dependencies, but exceljs is
+ // kept external: it is large and its transitive licenses would break the
+ // single-preamble bundle invariant. xlsx import/export in the UMD 'full'
+ // build therefore requires providing exceljs (global `ExcelJS`) separately.
+ c.externals = {
+ exceljs: {
+ root: 'ExcelJS',
+ commonjs2: 'exceljs',
+ commonjs: 'exceljs',
+ amd: 'exceljs',
+ },
+ };
c.plugins.push(new WebpackBar({ name: ` ${PACKAGE_FILENAME}.full.js` }));
});
diff --git a/hyperformula/package.json b/hyperformula/package.json
index ceed274797..53730b86ad 100644
--- a/hyperformula/package.json
+++ b/hyperformula/package.json
@@ -118,7 +118,6 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.25.9",
"@babel/runtime": "^7.26.0",
- "@types/exceljs": "^0.5.3",
"@types/jasmine": "^5.1.4",
"@types/jest": "^26.0.24",
"@types/jsdom": "^21.1.7",
@@ -614,5 +613,13 @@
"import": "./es/i18n/languages/trTR.mjs",
"require": "./commonjs/i18n/languages/trTR.js"
}
+ },
+ "peerDependencies": {
+ "exceljs": "^4.4.0"
+ },
+ "peerDependenciesMeta": {
+ "exceljs": {
+ "optional": true
+ }
}
}
diff --git a/hyperformula/src/HyperFormula.ts b/hyperformula/src/HyperFormula.ts
index ac5313eeaa..80f672308e 100644
--- a/hyperformula/src/HyperFormula.ts
+++ b/hyperformula/src/HyperFormula.ts
@@ -56,6 +56,10 @@ import {FunctionRegistry, FunctionTranslationsPackage} from './interpreter/Funct
import {FormatInfo} from './interpreter/InterpreterValue'
import {LazilyTransformingAstService} from './LazilyTransformingAstService'
import {ColumnSearchStrategy} from './Lookup/SearchStrategy'
+import {sheetsToXlsx} from './io/xlsx/exporter'
+import {formulaSyntaxOf, hasXlsxFormulaSyntax, XLSX_FORMULA_SYNTAX} from './io/xlsx/formulaSyntax'
+import {loadXlsxWorkbook} from './io/xlsx/loadWorkbook'
+import {workbookToSheets} from './io/xlsx/importer'
import {NamedExpression, NamedExpressionOptions, NamedExpressions} from './NamedExpressions'
import {normalizeAddedIndexes, normalizeRemovedIndexes} from './Operations'
import {
@@ -332,6 +336,63 @@ export class HyperFormula implements TypedEmitter {
return this.buildFromEngineState(BuildEngineFactory.buildFromSheets(sheets, configInput, namedExpressions))
}
+ /**
+ * Builds the engine from the raw bytes of an .xlsx file. The file is parsed and its
+ * worksheets are converted into sheets, keyed by worksheet name, exactly as if they had
+ * been passed to [[buildFromSheets]].
+ * Can be configured with the optional second parameter that represents a [[ConfigParams]].
+ * If not specified the engine will be built with the default configuration.
+ *
+ * Only cell values and formulas are read: no named expressions, styles or number formats.
+ * A text cell stays text even when it starts with `=`.
+ *
+ * Formulas in an .xlsx file always use English function names, `,` between arguments and
+ * `.` as the decimal separator. When the configuration sets another `language`,
+ * `functionArgSeparator`, `decimalSeparator`, `thousandSeparator`, `arrayColumnSeparator` or
+ * `arrayRowSeparator`, the formulas are converted to it, which builds the engine twice.
+ *
+ * @param {ArrayBuffer | Uint8Array} data - the raw bytes of an .xlsx file
+ * @param {Partial} configInput - engine configuration
+ *
+ * @returns {Promise} a promise that resolves to the built engine
+ *
+ * @throws [[UnsupportedFileError]] when `data` cannot be read as a valid .xlsx workbook
+ * (e.g. it is empty or not a well-formed .xlsx file)
+ * @throws [[SheetSizeLimitExceededError]] when a worksheet size exceeds the limits
+ *
+ * @example
+ * ```js
+ * // Node.js: read the file from disk first
+ * const fs = require('fs/promises');
+ * const buffer = await fs.readFile('example.xlsx');
+ * const hfInstance = await HyperFormula.buildFromFile(buffer, { licenseKey: 'gpl-v3' });
+ * ```
+ *
+ * @example
+ * ```js
+ * // Browser: read bytes from a File (e.g. an element)
+ * const arrayBuffer = await file.arrayBuffer();
+ * const hfInstance = await HyperFormula.buildFromFile(arrayBuffer, { licenseKey: 'gpl-v3' });
+ * ```
+ *
+ * @category Factories
+ */
+ public static async buildFromFile(data: ArrayBuffer | Uint8Array, configInput: Partial = {}): Promise {
+ const workbook = await loadXlsxWorkbook(data)
+ const sheets = workbookToSheets(workbook)
+
+ if (hasXlsxFormulaSyntax(configInput)) {
+ return HyperFormula.buildFromSheets(sheets, configInput)
+ }
+
+ const engineInFileSyntax = HyperFormula.buildFromSheets(sheets, {...configInput, ...XLSX_FORMULA_SYNTAX})
+ const configuredSyntax = engineInFileSyntax._config.mergeConfig(formulaSyntaxOf(configInput), false)
+ const sheetsInConfiguredSyntax = engineInFileSyntax._serialization.withNewConfig(configuredSyntax, engineInFileSyntax._namedExpressions).getAllSheetsSerialized()
+ engineInFileSyntax.destroy()
+
+ return HyperFormula.buildFromSheets(sheetsInConfiguredSyntax, configInput)
+ }
+
/**
* Builds an empty engine instance.
* Can be configured with the optional parameter that represents a [[ConfigParams]].
@@ -1185,6 +1246,38 @@ export class HyperFormula implements TypedEmitter {
return this._serialization.getAllSheetsSerialized()
}
+ /**
+ * Exports the current engine state to `.xlsx` file bytes.
+ *
+ * Only cell values and formulas are written: no named expressions, styles or number formats.
+ * A cell that holds a value is written as its value, so a number entered as a string, such as
+ * `'1'`, is written as a number. Formulas are written in the syntax of an .xlsx file (English
+ * function names, `,` between arguments, `.` as the decimal separator), whatever the engine's
+ * language and separators are.
+ *
+ * @throws [[EvaluationSuspendedError]] when the evaluation is suspended
+ *
+ * @returns {Promise} a promise that resolves to the raw bytes of an .xlsx file
+ *
+ * @example
+ * ```js
+ * const hfInstance = HyperFormula.buildFromArray([
+ * ['1', '2', '=A1+10'],
+ * ]);
+ *
+ * const bytes = await hfInstance.toFile();
+ * ```
+ *
+ * @category Sheets
+ */
+ public async toFile(): Promise {
+ const values = this.getAllSheetsValues()
+ const fileSyntax = this._config.mergeConfig(XLSX_FORMULA_SYNTAX, false)
+ const formulas = this._serialization.withNewConfig(fileSyntax, this._namedExpressions).getAllSheetsSerialized()
+
+ return sheetsToXlsx(formulas, values)
+ }
+
/**
* Updates the config with given new metadata. It is an expensive operation, as it might trigger rebuilding the engine and recalculation of all formulas.
*
diff --git a/hyperformula/src/errors.ts b/hyperformula/src/errors.ts
index 16baf2b889..f9b4e91a3d 100644
--- a/hyperformula/src/errors.ts
+++ b/hyperformula/src/errors.ts
@@ -395,6 +395,18 @@ export class AliasAlreadyExisting extends Error {
}
}
+/**
+ * Error thrown when a file cannot be read as a supported spreadsheet format,
+ * e.g. when its byte buffer is empty or cannot be parsed as an .xlsx workbook.
+ */
+export class UnsupportedFileError extends Error {
+ constructor(public readonly reason: 'empty' | 'unparseable', detail?: string) {
+ super(detail === undefined
+ ? `Unsupported or unreadable file (${reason})`
+ : `Unsupported or unreadable file (${reason}): ${detail}`)
+ }
+}
+
/**
* Error thrown when a public API method is called for a {@link FeatureId} that the current
* license entitlement does not grant, or when the license key itself blocks every gated feature
diff --git a/hyperformula/src/index.ts b/hyperformula/src/index.ts
index f48715c9f4..48ae338fe5 100644
--- a/hyperformula/src/index.ts
+++ b/hyperformula/src/index.ts
@@ -43,7 +43,8 @@ import {
SheetSizeLimitExceededError,
SourceLocationHasArrayError,
TargetLocationHasArrayError,
- UnableToParseError
+ UnableToParseError,
+ UnsupportedFileError
} from './errors'
import {ExportedCellChange, ExportedChange, ExportedNamedExpressionChange} from './Exporter'
import {HyperFormula} from './HyperFormula'
@@ -107,6 +108,7 @@ class HyperFormulaNS extends HyperFormula {
public static SourceLocationHasArrayError = SourceLocationHasArrayError
public static TargetLocationHasArrayError = TargetLocationHasArrayError
public static UnableToParseError = UnableToParseError
+ public static UnsupportedFileError = UnsupportedFileError
}
const defaultLanguage = Config.defaultConfig.language
@@ -194,5 +196,6 @@ export {
SourceLocationHasArrayError,
TargetLocationHasArrayError,
UnableToParseError,
+ UnsupportedFileError,
SerializedNamedExpression,
}
diff --git a/hyperformula/src/io/xlsx/exporter.ts b/hyperformula/src/io/xlsx/exporter.ts
new file mode 100644
index 0000000000..fb82e9364f
--- /dev/null
+++ b/hyperformula/src/io/xlsx/exporter.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (c) 2025 Handsoncode. All rights reserved.
+ */
+
+import type {CellErrorValue} from 'exceljs'
+import {ErrorType} from '../../Cell'
+import {RawCellContent} from '../../CellContentParser'
+import {CellValue, DetailedCellError} from '../../CellValue'
+import {loadExcelJs} from './loadExcelJs'
+
+/**
+ * Writes HyperFormula's sheets as `.xlsx` file bytes.
+ *
+ * Pure and async (ExcelJS's writer is async); does not touch a live engine.
+ *
+ * Both arguments are keyed by sheet name. `formulas` holds the serialized cells, as returned by
+ * {@link HyperFormula#getAllSheetsSerialized}, with the formulas already in Excel's syntax, and
+ * `values` the computed values, as returned by {@link HyperFormula#getAllSheetsValues}:
+ * - a cell whose serialized content is a formula (starts with `=`) becomes an Excel formula cell.
+ * - a cell whose serialized content is empty is left unset, so it reloads blank (not `0`/`''`).
+ * This also covers the cells a formula's array result spills into.
+ * - any other cell is written as its computed value, so a number or a boolean entered as text
+ * (for example `'1'`) is written as a number or a boolean, and a text value is written without
+ * the `'` that marks it as text in HyperFormula.
+ *
+ * Values and formulas only — no styles, number formats, or merged cells.
+ */
+export async function sheetsToXlsx(formulas: Record, values: Record): Promise {
+ const ExcelJS = await loadExcelJs()
+ const workbook = new ExcelJS.Workbook()
+
+ for (const sheetName of Object.keys(formulas)) {
+ const worksheet = workbook.addWorksheet(sheetName)
+ const rows = formulas[sheetName]
+ const valueRows = values[sheetName] ?? []
+
+ for (let r = 0; r < rows.length; r++) {
+ const row = rows[r]
+
+ for (let c = 0; c < row.length; c++) {
+ const cell = row[c]
+
+ if (cell == null) {
+ continue
+ }
+
+ const xc = worksheet.getCell(r + 1, c + 1)
+
+ if (typeof cell === 'string' && cell[0] === '=') {
+ xc.value = {formula: cell.slice(1)}
+ } else {
+ xc.value = toExcelValue(valueRows[r]?.[c] ?? null)
+ }
+ }
+ }
+ }
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ // ExcelJS returns a Node `Buffer` in Node and an `ArrayBuffer` in the browser.
+ // `Uint8Array.from()` treats an `ArrayBuffer` as an empty array-like and silently
+ // drops the bytes, corrupting browser exports; `new Uint8Array(...)` copies a
+ // Buffer and views an ArrayBuffer, so it is correct on both targets.
+ return buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer as Uint8Array)
+}
+
+/** Maps a computed HyperFormula value to the value of an ExcelJS cell. */
+function toExcelValue(value: CellValue): number | string | boolean | CellErrorValue | null {
+ if (value instanceof DetailedCellError) {
+ const excelError = toExcelError(value.type)
+ return excelError === undefined ? value.value : {error: excelError}
+ }
+
+ return value
+}
+
+/**
+ * Returns the Excel error value a HyperFormula error type is written as, or `undefined` for the
+ * error types Excel does not have, which are written as the text of the error instead.
+ */
+function toExcelError(type: ErrorType): CellErrorValue['error'] | undefined {
+ switch (type) {
+ case ErrorType.DIV_BY_ZERO:
+ return '#DIV/0!'
+ case ErrorType.NA:
+ return '#N/A'
+ case ErrorType.NAME:
+ return '#NAME?'
+ case ErrorType.NUM:
+ return '#NUM!'
+ case ErrorType.REF:
+ return '#REF!'
+ case ErrorType.VALUE:
+ return '#VALUE!'
+ default:
+ return undefined
+ }
+}
diff --git a/hyperformula/src/io/xlsx/formulaSyntax.ts b/hyperformula/src/io/xlsx/formulaSyntax.ts
new file mode 100644
index 0000000000..7d82cd1c10
--- /dev/null
+++ b/hyperformula/src/io/xlsx/formulaSyntax.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (c) 2025 Handsoncode. All rights reserved.
+ */
+
+import {Config} from '../../Config'
+import {ConfigParams} from '../../ConfigParams'
+
+type FormulaSyntaxParams = Pick
+
+/**
+ * The formula syntax of an `.xlsx` file: English function names, `,` between arguments and between
+ * the columns of an array, `;` between its rows, and `.` as the decimal separator. The file format
+ * fixes it, whatever language and separators an engine is configured with.
+ */
+export const XLSX_FORMULA_SYNTAX: FormulaSyntaxParams = {
+ language: 'enGB',
+ functionArgSeparator: ',',
+ decimalSeparator: '.',
+ thousandSeparator: '',
+ arrayColumnSeparator: ',',
+ arrayRowSeparator: ';',
+}
+
+/**
+ * Returns the options of `configInput` that decide how formulas are written, with the defaults
+ * filled in for the ones it leaves out.
+ */
+export function formulaSyntaxOf(configInput: Partial): FormulaSyntaxParams {
+ const syntax = {...XLSX_FORMULA_SYNTAX}
+
+ for (const key of Object.keys(XLSX_FORMULA_SYNTAX) as (keyof FormulaSyntaxParams)[]) {
+ const value = configInput[key] ?? Config.defaultConfig[key]
+ Object.assign(syntax, {[key]: value})
+ }
+
+ return syntax
+}
+
+/** Returns `true` when an engine built with `configInput` reads and writes formulas in the syntax of an `.xlsx` file. */
+export function hasXlsxFormulaSyntax(configInput: Partial): boolean {
+ const syntax = formulaSyntaxOf(configInput)
+ return (Object.keys(XLSX_FORMULA_SYNTAX) as (keyof FormulaSyntaxParams)[]).every(key => syntax[key] === XLSX_FORMULA_SYNTAX[key])
+}
diff --git a/hyperformula/src/io/xlsx/importer.ts b/hyperformula/src/io/xlsx/importer.ts
new file mode 100644
index 0000000000..d422f58b6c
--- /dev/null
+++ b/hyperformula/src/io/xlsx/importer.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright (c) 2025 Handsoncode. All rights reserved.
+ */
+
+import type {Cell, Workbook, Worksheet} from 'exceljs'
+import {RawCellContent} from '../../CellContentParser'
+import {Sheet, Sheets} from '../../Sheet'
+
+/**
+ * Converts an already-loaded ExcelJS {@link Workbook} into HyperFormula's
+ * {@link Sheets} shape.
+ *
+ * Pure and synchronous: byte-loading happens upstream in `loadXlsxWorkbook`.
+ *
+ * Each cell maps as follows:
+ * - a formula cell (`cell.formula != null`) becomes the string `'=' + formula`.
+ * - `null`/`undefined` values become `null`.
+ * - a `Date` value passes through as-is.
+ * - `number` and `boolean` values pass through as-is.
+ * - a text value stays text: one that starts with `=` or `'` gets a leading `'`,
+ * which HyperFormula reads as "the rest is text", so it is not parsed as a formula.
+ * - an error value becomes its error literal, such as `#DIV/0!`.
+ * - any other object value (rich text, hyperlink) becomes its displayed text.
+ *
+ * Excel's `_xlfn.` / `_xlws.` function-name prefixes are stripped from
+ * formula strings (see {@link stripFunctionPrefixes}); shared formulas and
+ * cross-sheet references already come back correctly rebased/verbatim from
+ * ExcelJS, so no further formula rewriting is needed.
+ */
+export function workbookToSheets(workbook: Workbook): Sheets {
+ const sheets: Sheets = {}
+
+ workbook.eachSheet((worksheet: Worksheet) => {
+ sheets[worksheet.name] = worksheetToSheet(worksheet)
+ })
+
+ return sheets
+}
+
+/**
+ * Builds a rectangular {@link Sheet} from a worksheet's used range
+ * (1..rowCount x 1..columnCount), mapping ExcelJS's 1-based coordinates to
+ * the 0-based array.
+ */
+function worksheetToSheet(worksheet: Worksheet): Sheet {
+ const rowCount = worksheet.rowCount ?? 0
+ const columnCount = worksheet.columnCount ?? 0
+
+ if (rowCount === 0) {
+ return []
+ }
+
+ const sheet: Sheet = []
+
+ for (let r = 1; r <= rowCount; r++) {
+ const row = worksheet.getRow(r)
+ const rowContent: RawCellContent[] = []
+
+ for (let c = 1; c <= columnCount; c++) {
+ rowContent.push(cellToRawContent(row.getCell(c)))
+ }
+
+ sheet.push(rowContent)
+ }
+
+ return sheet
+}
+
+/**
+ * Strips Excel's internal `_xlfn.` / `_xlws.` function-name prefixes.
+ *
+ * Excel stores newer/relocated functions (e.g. `XLOOKUP`, dynamic-array
+ * functions) with a `_xlfn.` prefix, and some with a combined
+ * `_xlfn._xlws.` prefix, in the raw formula string. Left in place, HF would
+ * fail to recognize the function name (`_xlfn.XLOOKUP` → `#NAME?`) even when
+ * it natively supports the function.
+ *
+ * This operates on the whole formula string, so a matching substring inside a
+ * string literal (e.g. `="_xlfn."`) is also rewritten.
+ */
+function stripFunctionPrefixes(formula: string): string {
+ return formula.replace(/_xlfn\.|_xlws\./g, '')
+}
+
+/** Maps a single ExcelJS cell to a HyperFormula {@link RawCellContent}. */
+function cellToRawContent(cell: Cell): RawCellContent {
+ if (cell.formula != null) {
+ return '=' + stripFunctionPrefixes(cell.formula)
+ }
+
+ const value = cell.value
+
+ if (value == null) {
+ return null
+ }
+
+ if (value instanceof Date || typeof value === 'number' || typeof value === 'boolean') {
+ return value
+ }
+
+ if (typeof value === 'string') {
+ return asText(value)
+ }
+
+ if (typeof value === 'object' && 'error' in value) {
+ return value.error
+ }
+
+ return asText(cell.text ?? '')
+}
+
+/**
+ * Returns raw cell content that HyperFormula reads as the given text.
+ *
+ * HyperFormula parses a string that starts with `=` as a formula and drops a leading `'`, so a text
+ * that starts with either character is prefixed with `'`.
+ */
+function asText(text: string): RawCellContent {
+ return text.startsWith('=') || text.startsWith("'") ? `'${text}` : text
+}
diff --git a/hyperformula/src/io/xlsx/loadExcelJs.ts b/hyperformula/src/io/xlsx/loadExcelJs.ts
new file mode 100644
index 0000000000..4ce796a485
--- /dev/null
+++ b/hyperformula/src/io/xlsx/loadExcelJs.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (c) 2025 Handsoncode. All rights reserved.
+ */
+
+import type * as ExcelJS from 'exceljs'
+
+export type ExcelJsApi = typeof ExcelJS
+
+/**
+ * Picks the ExcelJS API out of whatever a dynamic `import('exceljs')` resolved to.
+ *
+ * ExcelJS is a CommonJS package that assigns its whole API to `module.exports`. Node's native
+ * dynamic `import()`, which the published `commonjs` and `es` builds use, exposes that object only
+ * as the `default` export, while bundlers and CommonJS transpilers also copy its members onto the
+ * module namespace. Both shapes are accepted.
+ */
+export function pickExcelJsApi(module: ExcelJsApi | { default: ExcelJsApi }): ExcelJsApi {
+ return 'Workbook' in module ? module : module.default
+}
+
+/**
+ * Loads ExcelJS on demand, so that it is needed only by the methods that read or write `.xlsx` files.
+ */
+export async function loadExcelJs(): Promise {
+ return pickExcelJsApi(await import(/* webpackMode: "eager" */ 'exceljs'))
+}
diff --git a/hyperformula/src/io/xlsx/loadWorkbook.ts b/hyperformula/src/io/xlsx/loadWorkbook.ts
new file mode 100644
index 0000000000..2bea168b58
--- /dev/null
+++ b/hyperformula/src/io/xlsx/loadWorkbook.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (c) 2025 Handsoncode. All rights reserved.
+ */
+
+import type {Workbook} from 'exceljs'
+import {UnsupportedFileError} from '../../errors'
+import {loadExcelJs} from './loadExcelJs'
+
+/**
+ * Loads raw xlsx bytes into an ExcelJS {@link Workbook}.
+ *
+ * The bytes are not inspected before they are handed to ExcelJS: any failure to read them as an
+ * `.xlsx` workbook is reported as an {@link UnsupportedFileError}.
+ */
+export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise {
+ if (data.byteLength === 0) {
+ throw new UnsupportedFileError('empty')
+ }
+
+ const bytes = toStandaloneBytes(data)
+ const ExcelJS = await loadExcelJs()
+ const workbook = new ExcelJS.Workbook()
+
+ try {
+ await workbook.xlsx.load(bytes.buffer)
+ } catch (e) {
+ const detail = e instanceof Error ? e.message : String(e)
+ throw new UnsupportedFileError('unparseable', detail)
+ }
+
+ return workbook
+}
+
+/**
+ * Returns a zero-offset `Uint8Array` whose `ArrayBuffer` holds exactly the
+ * caller's logical bytes, so that `.buffer` can be handed to ExcelJS as-is.
+ *
+ * A `Uint8Array` argument may be a *view* into a larger buffer whose remaining
+ * bytes belong to someone else — in Node every small `Buffer` is carved out of a
+ * shared allocation pool — and passing that buffer on would let ExcelJS parse
+ * the neighbouring bytes instead. A `.xlsx` file is a ZIP, located by scanning
+ * backwards for its end-of-central-directory record, so a trailing foreign
+ * workbook wins silently: one import then resolves to another import's content.
+ *
+ * The view therefore has to be copied out. Neither `Buffer.prototype.slice` (an
+ * alias of `subarray`) nor `subarray` does that, and the Node `Buffer` API is
+ * avoided altogether because it pulls webpack's `Buffer` polyfill into the
+ * browser bundles; `set()` on a freshly allocated array works on both targets.
+ *
+ * An `ArrayBuffer` argument needs no copy: it is standalone by definition, and
+ * all of it is the file.
+ */
+function toStandaloneBytes(data: ArrayBuffer | Uint8Array): Uint8Array {
+ if (!(data instanceof Uint8Array)) {
+ return new Uint8Array(data)
+ }
+
+ const bytes = new Uint8Array(data.byteLength)
+ bytes.set(data)
+
+ return bytes
+}
diff --git a/hyperformula/tsconfig.json b/hyperformula/tsconfig.json
index 812d955676..d8ef6d31bd 100644
--- a/hyperformula/tsconfig.json
+++ b/hyperformula/tsconfig.json
@@ -8,8 +8,8 @@
/* Basic Options */
"target": "es6",
/* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
- "module": "es2015",
- /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+ "module": "es2020",
+ /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. es2020 (was es2015) is required for the lazy `import('exceljs')` dynamic import; emit is unchanged for every module that does not use dynamic import / import.meta. */
"lib": [
"es6",
"dom",
diff --git a/package-lock.json b/package-lock.json
index 7f5d0453ac..82b9e9c25a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,7 +41,6 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.25.9",
"@babel/runtime": "^7.26.0",
- "@types/exceljs": "^0.5.3",
"@types/jasmine": "^5.1.4",
"@types/jest": "^26.0.24",
"@types/jsdom": "^21.1.7",
@@ -90,6 +89,14 @@
"webpack": "^4.47.0",
"webpack-cli": "^4.10.0",
"webpackbar": "^6.0.1"
+ },
+ "peerDependencies": {
+ "exceljs": "^4.4.0"
+ },
+ "peerDependenciesMeta": {
+ "exceljs": {
+ "optional": true
+ }
}
},
"hyperformula-ui/demo/js": {
@@ -6408,16 +6415,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/exceljs": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/@types/exceljs/-/exceljs-0.5.3.tgz",
- "integrity": "sha512-a0PLZEJGbA4kHHoSS8cQ20ynIv17vCBV1reqsrX5ksQQb077YYvhEKC82lEq6/+mMufhk68KJ5jwugL3DKG8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",