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