From 7be379fc5635e78cda1a9f401e060944874aedd4 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:34:10 +0000 Subject: [PATCH 01/22] build(io): move exceljs to runtime dependency for built-in xlsx I/O (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) --- package-lock.json | 104 +--------------------------------------------- package.json | 2 +- 2 files changed, 2 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42283925eb..61c88c0e2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0-only", "dependencies": { "chevrotain": "^6.5.0", + "exceljs": "^4.4.0", "tiny-emitter": "^2.1.0" }, "devDependencies": { @@ -50,7 +51,6 @@ "eslint-plugin-license-header": "^0.6.1", "eslint-plugin-prettier": "^5.2.1", "esm": "^3.2.25", - "exceljs": "^4.4.0", "full-icu": "^1.5.0", "jasmine": "^5.4.0", "jest": "^26.6.3", @@ -2760,7 +2760,6 @@ }, "node_modules/@fast-csv/format": { "version": "4.3.5", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "^14.0.1", @@ -2773,12 +2772,10 @@ }, "node_modules/@fast-csv/format/node_modules/@types/node": { "version": "14.18.63", - "dev": true, "license": "MIT" }, "node_modules/@fast-csv/parse": { "version": "4.3.6", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "^14.0.1", @@ -2792,7 +2789,6 @@ }, "node_modules/@fast-csv/parse/node_modules/@types/node": { "version": "14.18.63", - "dev": true, "license": "MIT" }, "node_modules/@gar/promisify": { @@ -6878,7 +6874,6 @@ }, "node_modules/archiver": { "version": "5.3.2", - "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^2.1.0", @@ -6895,7 +6890,6 @@ }, "node_modules/archiver-utils": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "glob": "^7.1.4", @@ -6915,12 +6909,10 @@ }, "node_modules/archiver-utils/node_modules/isarray": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -6934,12 +6926,10 @@ }, "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -7156,7 +7146,6 @@ }, "node_modules/async": { "version": "3.2.6", - "dev": true, "license": "MIT" }, "node_modules/async-each": { @@ -7624,7 +7613,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/base": { @@ -7669,7 +7657,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -7720,7 +7707,6 @@ }, "node_modules/big-integer": { "version": "1.6.52", - "dev": true, "license": "Unlicense", "engines": { "node": ">=0.6" @@ -7736,7 +7722,6 @@ }, "node_modules/binary": { "version": "0.3.0", - "dev": true, "license": "MIT", "dependencies": { "buffers": "~0.1.1", @@ -7765,7 +7750,6 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -7782,7 +7766,6 @@ }, "node_modules/bluebird": { "version": "3.4.7", - "dev": true, "license": "MIT" }, "node_modules/bn.js": { @@ -7936,7 +7919,6 @@ }, "node_modules/brace-expansion": { "version": "1.1.18", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8128,7 +8110,6 @@ }, "node_modules/buffer": { "version": "5.7.1", - "dev": true, "funding": [ { "type": "github", @@ -8151,7 +8132,6 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -8169,7 +8149,6 @@ }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10" @@ -8187,7 +8166,6 @@ }, "node_modules/buffers": { "version": "0.1.1", - "dev": true, "engines": { "node": ">=0.2.0" } @@ -8618,7 +8596,6 @@ }, "node_modules/chainsaw": { "version": "0.1.0", - "dev": true, "license": "MIT/X11", "dependencies": { "traverse": ">=0.3.0 <0.4" @@ -8949,7 +8926,6 @@ }, "node_modules/compress-commons": { "version": "4.1.2", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "^0.2.13", @@ -9012,7 +8988,6 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9476,7 +9451,6 @@ }, "node_modules/core-util-is": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -9551,7 +9525,6 @@ }, "node_modules/crc-32": { "version": "1.2.2", - "dev": true, "license": "Apache-2.0", "bin": { "crc32": "bin/crc32.njs" @@ -9562,7 +9535,6 @@ }, "node_modules/crc32-stream": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "crc-32": "^1.2.0", @@ -10174,7 +10146,6 @@ }, "node_modules/dayjs": { "version": "1.11.21", - "dev": true, "license": "MIT" }, "node_modules/de-indent": { @@ -10841,7 +10812,6 @@ }, "node_modules/duplexer2": { "version": "0.1.4", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" @@ -10849,12 +10819,10 @@ }, "node_modules/duplexer2/node_modules/isarray": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/duplexer2/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -10868,12 +10836,10 @@ }, "node_modules/duplexer2/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/duplexer2/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -11004,7 +10970,6 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -12099,7 +12064,6 @@ }, "node_modules/exceljs": { "version": "4.4.0", - "dev": true, "license": "MIT", "dependencies": { "archiver": "^5.0.0", @@ -12397,7 +12361,6 @@ }, "node_modules/fast-csv": { "version": "4.3.6", - "dev": true, "license": "MIT", "dependencies": { "@fast-csv/format": "4.3.5", @@ -12899,7 +12862,6 @@ }, "node_modules/fs-constants": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -12992,7 +12954,6 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -13009,7 +12970,6 @@ }, "node_modules/fstream": { "version": "1.0.12", - "dev": true, "license": "ISC", "dependencies": { "graceful-fs": "^4.1.2", @@ -13023,7 +12983,6 @@ }, "node_modules/fstream/node_modules/rimraf": { "version": "2.7.1", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -13198,7 +13157,6 @@ }, "node_modules/glob": { "version": "7.2.3", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -13355,7 +13313,6 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -14055,7 +14012,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -14087,7 +14043,6 @@ }, "node_modules/immediate": { "version": "3.0.6", - "dev": true, "license": "MIT" }, "node_modules/import-cwd": { @@ -14248,7 +14203,6 @@ }, "node_modules/inflight": { "version": "1.0.6", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -14257,7 +14211,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -17076,7 +17029,6 @@ }, "node_modules/jszip": { "version": "3.10.1", - "dev": true, "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { "lie": "~3.3.0", @@ -17087,12 +17039,10 @@ }, "node_modules/jszip/node_modules/isarray": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/jszip/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -17106,12 +17056,10 @@ }, "node_modules/jszip/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/jszip/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -17397,7 +17345,6 @@ }, "node_modules/lazystream": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "readable-stream": "^2.0.5" @@ -17408,12 +17355,10 @@ }, "node_modules/lazystream/node_modules/isarray": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -17427,12 +17372,10 @@ }, "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -17505,7 +17448,6 @@ }, "node_modules/lie": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "immediate": "~3.0.5" @@ -17526,7 +17468,6 @@ }, "node_modules/listenercount": { "version": "1.0.1", - "dev": true, "license": "ISC" }, "node_modules/load-json-file": { @@ -17626,57 +17567,46 @@ }, "node_modules/lodash.defaults": { "version": "4.2.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", - "dev": true, "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.groupby": { "version": "4.6.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", - "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.isfunction": { "version": "3.0.9", - "dev": true, "license": "MIT" }, "node_modules/lodash.isnil": { "version": "4.0.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "dev": true, "license": "MIT" }, "node_modules/lodash.isundefined": { "version": "3.0.1", - "dev": true, "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -17723,12 +17653,10 @@ }, "node_modules/lodash.union": { "version": "4.6.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", - "dev": true, "license": "MIT" }, "node_modules/log4js": { @@ -18280,7 +18208,6 @@ }, "node_modules/minimatch": { "version": "3.1.5", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -18291,7 +18218,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18442,7 +18368,6 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -18792,7 +18717,6 @@ }, "node_modules/normalize-path": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19126,7 +19050,6 @@ }, "node_modules/once": { "version": "1.4.0", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -19357,7 +19280,6 @@ }, "node_modules/pako": { "version": "1.0.11", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parallel-transform": { @@ -19508,7 +19430,6 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21216,7 +21137,6 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -21673,7 +21593,6 @@ }, "node_modules/readable-stream": { "version": "3.6.2", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -21686,7 +21605,6 @@ }, "node_modules/readdir-glob": { "version": "1.1.3", - "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.1.0" @@ -21694,7 +21612,6 @@ }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.4", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -21702,7 +21619,6 @@ }, "node_modules/readdir-glob/node_modules/minimatch": { "version": "5.1.9", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -22466,7 +22382,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "dev": true, "funding": [ { "type": "github", @@ -22822,7 +22737,6 @@ }, "node_modules/saxes": { "version": "5.0.1", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -23114,7 +23028,6 @@ }, "node_modules/setimmediate": { "version": "1.0.5", - "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -24051,7 +23964,6 @@ }, "node_modules/string_decoder": { "version": "1.3.0", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -24553,7 +24465,6 @@ }, "node_modules/tar-stream": { "version": "2.2.0", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -24861,7 +24772,6 @@ }, "node_modules/tmp": { "version": "0.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -25047,7 +24957,6 @@ }, "node_modules/traverse": { "version": "0.3.9", - "dev": true, "license": "MIT/X11" }, "node_modules/treeify": { @@ -25785,7 +25694,6 @@ }, "node_modules/unzipper": { "version": "0.10.14", - "dev": true, "license": "MIT", "dependencies": { "big-integer": "^1.6.17", @@ -25802,12 +25710,10 @@ }, "node_modules/unzipper/node_modules/isarray": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/unzipper/node_modules/readable-stream": { "version": "2.3.8", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -25821,12 +25727,10 @@ }, "node_modules/unzipper/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/unzipper/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -26086,7 +25990,6 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/util-extend": { @@ -26128,7 +26031,6 @@ }, "node_modules/uuid": { "version": "8.3.2", - "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -28668,7 +28570,6 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -28728,7 +28629,6 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "dev": true, "license": "MIT" }, "node_modules/xtend": { @@ -28912,7 +28812,6 @@ }, "node_modules/zip-stream": { "version": "4.1.1", - "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^3.0.4", @@ -28925,7 +28824,6 @@ }, "node_modules/zip-stream/node_modules/archiver-utils": { "version": "3.0.4", - "dev": true, "license": "MIT", "dependencies": { "glob": "^7.2.3", diff --git a/package.json b/package.json index ca901db20b..9f4b584608 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ }, "dependencies": { "chevrotain": "^6.5.0", + "exceljs": "^4.4.0", "tiny-emitter": "^2.1.0" }, "devDependencies": { @@ -147,7 +148,6 @@ "eslint-plugin-license-header": "^0.6.1", "eslint-plugin-prettier": "^5.2.1", "esm": "^3.2.25", - "exceljs": "^4.4.0", "full-icu": "^1.5.0", "jasmine": "^5.4.0", "jest": "^26.6.3", From ac57c945a1eb117a0805774142ab96764b2ff5f1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:45:42 +0000 Subject: [PATCH 02/22] feat(errors): add UnsupportedFileError for xlsx import (HF-107) 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) --- src/errors.ts | 12 ++++++++++++ src/index.ts | 5 ++++- test/io/errors.spec.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 test/io/errors.spec.ts diff --git a/src/errors.ts b/src/errors.ts index 66a73a2add..316987cc3c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -392,3 +392,15 @@ export class AliasAlreadyExisting extends Error { super(`Alias id ${name} in plugin ${pluginName} already defined as a function or alias.`) } } + +/** + * 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}`) + } +} diff --git a/src/index.ts b/src/index.ts index 23b060cf49..1c01308651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,8 @@ import { SheetSizeLimitExceededError, SourceLocationHasArrayError, TargetLocationHasArrayError, - UnableToParseError + UnableToParseError, + UnsupportedFileError } from './errors' import {ExportedCellChange, ExportedChange, ExportedNamedExpressionChange} from './Exporter' import {HyperFormula} from './HyperFormula' @@ -103,6 +104,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 @@ -188,5 +190,6 @@ export { SourceLocationHasArrayError, TargetLocationHasArrayError, UnableToParseError, + UnsupportedFileError, SerializedNamedExpression, } diff --git a/test/io/errors.spec.ts b/test/io/errors.spec.ts new file mode 100644 index 0000000000..d685d77e60 --- /dev/null +++ b/test/io/errors.spec.ts @@ -0,0 +1,28 @@ +import {UnsupportedFileError} from '../../src/errors' + +describe('UnsupportedFileError', () => { + it('is an instance of Error and UnsupportedFileError', () => { + const error = new UnsupportedFileError('empty') + + expect(error instanceof Error).toBe(true) + expect(error instanceof UnsupportedFileError).toBe(true) + }) + + it('exposes the reason it was constructed with', () => { + expect(new UnsupportedFileError('empty').reason).toBe('empty') + expect(new UnsupportedFileError('unparseable').reason).toBe('unparseable') + }) + + it('includes the reason in the message', () => { + const error = new UnsupportedFileError('empty') + + expect(error.message).toContain('empty') + }) + + it('includes the detail in the message when provided', () => { + const error = new UnsupportedFileError('unparseable', 'unexpected end of file') + + expect(error.message).toContain('unparseable') + expect(error.message).toContain('unexpected end of file') + }) +}) From a014139b3bda727ddfc7c8ce5c1c869ffc3a4d8a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:51:58 +0000 Subject: [PATCH 03/22] feat(io): add loadXlsxWorkbook to load and validate xlsx bytes (HF-107) 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) --- src/io/xlsx/loadWorkbook.ts | 30 +++++++++++++++ test/io/loadWorkbook.spec.ts | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/io/xlsx/loadWorkbook.ts create mode 100644 test/io/loadWorkbook.spec.ts diff --git a/src/io/xlsx/loadWorkbook.ts b/src/io/xlsx/loadWorkbook.ts new file mode 100644 index 0000000000..68af5951cf --- /dev/null +++ b/src/io/xlsx/loadWorkbook.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {Workbook} from 'exceljs' +import {UnsupportedFileError} from '../../errors' + +/** + * Loads raw xlsx bytes into an ExcelJS {@link Workbook}. + * + * v1 scope: attempt the ExcelJS load and wrap any failure. Byte-signature / + * ZIP-magic / CFB format detection is deliberately out of scope (YAGNI). + */ +export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise { + if (data.byteLength === 0) { + throw new UnsupportedFileError('empty') + } + + const buffer = Buffer.from(data) + + try { + const workbook = new Workbook() + await workbook.xlsx.load(buffer) + return workbook + } catch (e) { + const detail = e instanceof Error ? e.message : String(e) + throw new UnsupportedFileError('unparseable', detail) + } +} diff --git a/test/io/loadWorkbook.spec.ts b/test/io/loadWorkbook.spec.ts new file mode 100644 index 0000000000..2b821fe35d --- /dev/null +++ b/test/io/loadWorkbook.spec.ts @@ -0,0 +1,74 @@ +import {Workbook} from 'exceljs' +import {UnsupportedFileError} from '../../src/errors' +import {loadXlsxWorkbook} from '../../src/io/xlsx/loadWorkbook' + +/** Builds a minimal valid xlsx file in memory (Sheet1!A1 = 42). */ +async function xlsxBuffer(): Promise { + const wb = new Workbook() + const ws = wb.addWorksheet('Sheet1') + ws.getCell('A1').value = 42 + const buf = await wb.xlsx.writeBuffer() + return buf as unknown as Uint8Array +} + +describe('loadXlsxWorkbook', () => { + it('loads a valid xlsx buffer into a Workbook', async () => { + const data = await xlsxBuffer() + + const wb = await loadXlsxWorkbook(data) + + const ws = wb.getWorksheet('Sheet1') + + expect(ws?.getCell('A1').value).toBe(42) + }) + + it('rejects an empty Uint8Array with UnsupportedFileError(empty)', async () => { + let err: unknown + try { + await loadXlsxWorkbook(new Uint8Array(0)) + } catch (e) { + err = e + } + + expect(err instanceof UnsupportedFileError).toBe(true) + expect((err as UnsupportedFileError).reason).toBe('empty') + }) + + it('rejects an empty ArrayBuffer with UnsupportedFileError(empty)', async () => { + let err: unknown + try { + await loadXlsxWorkbook(new ArrayBuffer(0)) + } catch (e) { + err = e + } + + expect(err instanceof UnsupportedFileError).toBe(true) + expect((err as UnsupportedFileError).reason).toBe('empty') + }) + + it('rejects garbage bytes with UnsupportedFileError(unparseable)', async () => { + let err: unknown + try { + await loadXlsxWorkbook(new Uint8Array([1, 2, 3, 4, 5])) + } catch (e) { + err = e + } + + expect(err instanceof UnsupportedFileError).toBe(true) + expect((err as UnsupportedFileError).reason).toBe('unparseable') + }) + + it('loads correctly from a subarray view with a non-zero byteOffset', async () => { + const xlsx = await xlsxBuffer() + const offset = 7 + const big = new Uint8Array(offset + xlsx.length + 3) + big.set(xlsx, offset) + const view = big.subarray(offset, offset + xlsx.length) + + const wb = await loadXlsxWorkbook(view) + + const ws = wb.getWorksheet('Sheet1') + + expect(ws?.getCell('A1').value).toBe(42) + }) +}) From fc557e55b8dc2c6e4e6c3d2adf94ef35215433e6 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:04:08 +0000 Subject: [PATCH 04/22] feat(io): add workbookToSheets xlsx importer core (HF-107) 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) --- src/io/xlsx/importer.ts | 87 ++++++++++++++++++++++++++++++++++++++++ test/io/importer.spec.ts | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/io/xlsx/importer.ts create mode 100644 test/io/importer.spec.ts diff --git a/src/io/xlsx/importer.ts b/src/io/xlsx/importer.ts new file mode 100644 index 0000000000..943cc05ce9 --- /dev/null +++ b/src/io/xlsx/importer.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {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`. + * + * v1 mapping (deliberately minimal, see HF-107 task brief): + * - a formula cell (`cell.formula != null`) becomes the string `'=' + formula`. + * - `null`/`undefined` values become `null`. + * - a `Date` value passes through as-is (best-effort). + * - `number` | `string` | `boolean` pass through as-is. + * - any other (object-valued: error / rich text / hyperlink) cell falls back + * to `cell.text` so we never emit the raw object. + * + * Shared/array formulas, `_xlfn.` prefix normalization, and a full Excel/HF + * error-token table are out of scope here (deferred). + */ +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 +} + +/** Maps a single ExcelJS cell to a HyperFormula {@link RawCellContent} per the v1 mapping rule. */ +function cellToRawContent(cell: Cell): RawCellContent { + if (cell.formula != null) { + return '=' + cell.formula + } + + const value = cell.value + + if (value == null) { + return null + } + + if (value instanceof Date) { + return value + } + + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { + return value + } + + return cell.text ?? null +} diff --git a/test/io/importer.spec.ts b/test/io/importer.spec.ts new file mode 100644 index 0000000000..a74d405fd8 --- /dev/null +++ b/test/io/importer.spec.ts @@ -0,0 +1,73 @@ +import {Workbook} from 'exceljs' +import {workbookToSheets} from '../../src/io/xlsx/importer' + +describe('workbookToSheets', () => { + it('keys the result by worksheet name, preserving multiple sheets', () => { + const wb = new Workbook() + wb.addWorksheet('Data') + wb.addWorksheet('Summary') + + const sheets = workbookToSheets(wb) + + expect(Object.keys(sheets).sort()).toEqual(['Data', 'Summary']) + }) + + it('maps number, string, boolean and blank cells to the right primitives at the right coordinates', () => { + const wb = new Workbook() + const ws = wb.addWorksheet('Data') + ws.getCell('A1').value = 1 + ws.getCell('B1').value = 'hi' + ws.getCell('A2').value = true + // B2 left blank on purpose + + const sheets = workbookToSheets(wb) + + expect(sheets.Data[0][0]).toBe(1) + expect(sheets.Data[0][1]).toBe('hi') + expect(sheets.Data[1][0]).toBe(true) + expect(sheets.Data[1][1]).toBe(null) + }) + + it('maps a formula cell to a string formula prefixed with =', () => { + const wb = new Workbook() + const ws = wb.addWorksheet('Data') + ws.getCell('A1').value = 1 + ws.getCell('B2').value = {formula: 'A1+1', result: 2} as unknown as number + + const sheets = workbookToSheets(wb) + + expect(sheets.Data[1][1]).toBe('=A1+1') + }) + + it('passes a Date cell through as a Date (best-effort)', () => { + const wb = new Workbook() + const ws = wb.addWorksheet('Data') + const date = new Date(2024, 0, 1) + ws.getCell('A1').value = date + + const sheets = workbookToSheets(wb) + + expect(sheets.Data[0][0] instanceof Date).toBe(true) + expect((sheets.Data[0][0] as Date).getTime()).toBe(date.getTime()) + }) + + it('renders an object-valued error cell via cell.text, never the raw object', () => { + const wb = new Workbook() + const ws = wb.addWorksheet('Data') + ws.getCell('A1').value = {error: '#DIV/0!'} as unknown as string + + const sheets = workbookToSheets(wb) + + expect(sheets.Data[0][0]).toBe('#DIV/0!') + expect(typeof sheets.Data[0][0]).toBe('string') + }) + + it('maps an empty worksheet to an empty array', () => { + const wb = new Workbook() + wb.addWorksheet('Empty') + + const sheets = workbookToSheets(wb) + + expect(sheets.Empty).toEqual([]) + }) +}) From 282a9632ac5007b46879b3f2da818eb5cc533140 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:13:00 +0000 Subject: [PATCH 05/22] fix(io): strip _xlfn./_xlws. prefixes in xlsx importer formulas (HF-107) 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) --- src/io/xlsx/importer.ts | 23 ++++++++++++-- test/io/importer.spec.ts | 68 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/io/xlsx/importer.ts b/src/io/xlsx/importer.ts index 943cc05ce9..2fd3998e91 100644 --- a/src/io/xlsx/importer.ts +++ b/src/io/xlsx/importer.ts @@ -21,8 +21,12 @@ import {Sheet, Sheets} from '../../Sheet' * - any other (object-valued: error / rich text / hyperlink) cell falls back * to `cell.text` so we never emit the raw object. * - * Shared/array formulas, `_xlfn.` prefix normalization, and a full Excel/HF - * error-token table are out of scope here (deferred). + * 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. + * + * A full Excel/HF error-token table is out of scope here (deferred). */ export function workbookToSheets(workbook: Workbook): Sheets { const sheets: Sheets = {} @@ -63,10 +67,23 @@ function worksheetToSheet(worksheet: Worksheet): Sheet { 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. + */ +function stripFunctionPrefixes(formula: string): string { + return formula.replace(/_xlfn\.|_xlws\./g, '') +} + /** Maps a single ExcelJS cell to a HyperFormula {@link RawCellContent} per the v1 mapping rule. */ function cellToRawContent(cell: Cell): RawCellContent { if (cell.formula != null) { - return '=' + cell.formula + return '=' + stripFunctionPrefixes(cell.formula) } const value = cell.value diff --git a/test/io/importer.spec.ts b/test/io/importer.spec.ts index a74d405fd8..678bce2bb9 100644 --- a/test/io/importer.spec.ts +++ b/test/io/importer.spec.ts @@ -1,5 +1,21 @@ -import {Workbook} from 'exceljs' +import ExcelJS, {Workbook, Worksheet} from 'exceljs' import {workbookToSheets} from '../../src/io/xlsx/importer' +import {Sheets} from '../../src/Sheet' + +/** + * Builds a worksheet via `build`, then writes and reloads the workbook through + * ExcelJS so formulas come back in their real stored form (e.g. `_xlfn.` + * prefixes, per-cell-rebased shared formulas) rather than the in-memory form + * that a directly-constructed cell may not exhibit. + */ +async function roundTripSheet(build: (ws: Worksheet) => void): Promise { + const wb = new ExcelJS.Workbook() + build(wb.addWorksheet('S1')) + const buf = await wb.xlsx.writeBuffer() + const wb2 = new ExcelJS.Workbook() + await wb2.xlsx.load(buf as never) + return workbookToSheets(wb2) +} describe('workbookToSheets', () => { it('keys the result by worksheet name, preserving multiple sheets', () => { @@ -71,3 +87,53 @@ describe('workbookToSheets', () => { expect(sheets.Empty).toEqual([]) }) }) + +describe('workbookToSheets - formula dialect normalization (HF-107)', () => { + it('strips the _xlfn. prefix Excel adds for newer functions', async () => { + const sheets = await roundTripSheet(ws => { + ws.getCell('A1').value = 1 + ws.getCell('A2').value = 2 + ws.getCell('A3').value = 3 + ws.getCell('C1').value = {formula: '_xlfn.XLOOKUP(1,A1:A3,A1:A3)', result: 1} as unknown as number + }) + + expect(sheets.S1[0][2]).toBe('=XLOOKUP(1,A1:A3,A1:A3)') + }) + + it('strips both _xlfn. and _xlws. when a formula carries the combined prefix', async () => { + const sheets = await roundTripSheet(ws => { + ws.getCell('A1').value = 1 + ws.getCell('C1').value = {formula: '_xlfn._xlws.ANCHORARRAY(A1)', result: 1} as unknown as number + }) + + expect(sheets.S1[0][2]).toBe('=ANCHORARRAY(A1)') + }) + + it('imports a shared formula group with each cell already rebased to its own row (no rebasing logic needed)', async () => { + const sheets = await roundTripSheet(ws => { + ws.getCell('A1').value = 1 + ws.getCell('A2').value = 2 + ws.getCell('A3').value = 3 + ws.fillFormula('B1:B3', 'A1*2', [2, 4, 6]) + }) + + expect(sheets.S1[0][1]).toBe('=A1*2') + expect(sheets.S1[1][1]).toBe('=A2*2') + expect(sheets.S1[2][1]).toBe('=A3*2') + }) + + it('passes a cross-sheet, quoted-name reference through verbatim', async () => { + const wb = new ExcelJS.Workbook() + const other = wb.addWorksheet('My Sheet') + other.getCell('A1').value = 42 + const s1 = wb.addWorksheet('S1') + s1.getCell('D1').value = {formula: "'My Sheet'!A1", result: 42} as unknown as number + + const buf = await wb.xlsx.writeBuffer() + const wb2 = new ExcelJS.Workbook() + await wb2.xlsx.load(buf as never) + const sheets = workbookToSheets(wb2) + + expect(sheets.S1[0][3]).toBe("='My Sheet'!A1") + }) +}) From 61b5eaab4329a6a794d2699d80e825b513accaff Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:26:25 +0000 Subject: [PATCH 06/22] feat(xlsx): add HyperFormula.buildFromFile public import factory (HF-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) --- src/HyperFormula.ts | 43 ++++++++++++++ test/io/buildFromFile.spec.ts | 102 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 test/io/buildFromFile.spec.ts diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 522d7b1509..bdcfe28fa5 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -53,6 +53,8 @@ import {FunctionRegistry, FunctionTranslationsPackage} from './interpreter/Funct import {FormatInfo} from './interpreter/InterpreterValue' import {LazilyTransformingAstService} from './LazilyTransformingAstService' import {ColumnSearchStrategy} from './Lookup/SearchStrategy' +import {loadXlsxWorkbook} from './io/xlsx/loadWorkbook' +import {workbookToSheets} from './io/xlsx/importer' import {NamedExpression, NamedExpressionOptions, NamedExpressions} from './NamedExpressions' import {normalizeAddedIndexes, normalizeRemovedIndexes} from './Operations' import { @@ -327,6 +329,47 @@ 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. + * + * v1 scope: no import options, no named expressions, no cell-format detection (see HF-107). + * + * @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) + return HyperFormula.buildFromSheets(sheets, configInput) + } + /** * Builds an empty engine instance. * Can be configured with the optional parameter that represents a [[ConfigParams]]. diff --git a/test/io/buildFromFile.spec.ts b/test/io/buildFromFile.spec.ts new file mode 100644 index 0000000000..28d2432905 --- /dev/null +++ b/test/io/buildFromFile.spec.ts @@ -0,0 +1,102 @@ +import {Workbook} from 'exceljs' +import {HyperFormula} from '../../src' +import {UnsupportedFileError} from '../../src/errors' + +/** Builds an xlsx buffer in memory (Sheet1!A1 = 1, Sheet1!B1 = '=A1+1'). */ +async function singleSheetBuffer(): Promise { + const wb = new Workbook() + const ws = wb.addWorksheet('Sheet1') + ws.getCell('A1').value = 1 + ws.getCell('B1').value = {formula: 'A1+1', result: 2} as unknown as number + const buf = await wb.xlsx.writeBuffer() + return buf as unknown as Uint8Array +} + +/** Builds an xlsx buffer with two named worksheets. */ +async function twoSheetBuffer(): Promise { + const wb = new Workbook() + wb.addWorksheet('Data') + wb.addWorksheet('Summary') + const buf = await wb.xlsx.writeBuffer() + return buf as unknown as Uint8Array +} + +/** Copies a Uint8Array's bytes into a fresh, standalone ArrayBuffer. */ +function toArrayBuffer(data: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(data.length) + copy.set(data) + return copy.buffer +} + +describe('HyperFormula.buildFromFile', () => { + it('imports a sheet and evaluates formulas end-to-end', async () => { + const data = await singleSheetBuffer() + + const hf = await HyperFormula.buildFromFile(data, {licenseKey: 'gpl-v3'}) + + expect(hf.getSheetSerialized(0)[0][1]).toBe('=A1+1') + expect(hf.getSheetValues(0)[0]).toEqual([1, 2]) + + hf.destroy() + }) + + it('imports every worksheet name from a multi-sheet workbook', async () => { + const data = await twoSheetBuffer() + + const hf = await HyperFormula.buildFromFile(data, {licenseKey: 'gpl-v3'}) + + const names = hf.getSheetNames() + + expect(names).toContain('Data') + expect(names).toContain('Summary') + + hf.destroy() + }) + + it('builds equivalent engines from the same bytes as Uint8Array and as ArrayBuffer', async () => { + const bytes = await singleSheetBuffer() + const arrayBuffer = toArrayBuffer(bytes) + + const hfFromBytes = await HyperFormula.buildFromFile(bytes, {licenseKey: 'gpl-v3'}) + const hfFromArrayBuffer = await HyperFormula.buildFromFile(arrayBuffer, {licenseKey: 'gpl-v3'}) + + expect(hfFromArrayBuffer.getSheetSerialized(0)).toEqual(hfFromBytes.getSheetSerialized(0)) + expect(hfFromArrayBuffer.getSheetValues(0)).toEqual(hfFromBytes.getSheetValues(0)) + + hfFromBytes.destroy() + hfFromArrayBuffer.destroy() + }) + + it('rejects garbage bytes with UnsupportedFileError', async () => { + let err: unknown + try { + await HyperFormula.buildFromFile(new Uint8Array([1, 2, 3, 4, 5]), {licenseKey: 'gpl-v3'}) + } catch (e) { + err = e + } + + expect(err instanceof UnsupportedFileError).toBe(true) + expect((err as UnsupportedFileError).reason).toBe('unparseable') + }) + + it('builds independent, non-shared engines when run concurrently', async () => { + const wbA = new Workbook() + wbA.addWorksheet('Sheet1').getCell('A1').value = 'A' + const bufA = await wbA.xlsx.writeBuffer() as unknown as Uint8Array + + const wbB = new Workbook() + wbB.addWorksheet('Sheet1').getCell('A1').value = 'B' + const bufB = await wbB.xlsx.writeBuffer() as unknown as Uint8Array + + const [hfA, hfB] = await Promise.all([ + HyperFormula.buildFromFile(bufA, {licenseKey: 'gpl-v3'}), + HyperFormula.buildFromFile(bufB, {licenseKey: 'gpl-v3'}), + ]) + + expect(hfA.getSheetValues(0)).toEqual([['A']]) + expect(hfB.getSheetValues(0)).toEqual([['B']]) + + hfA.destroy() + hfB.destroy() + }) +}) From 8839b8e9929e6805092bc15b1f25268a5207d82a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:47:52 +0000 Subject: [PATCH 07/22] feat(io): add xlsx exporter and HyperFormula.toFile (HF-107) 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) --- src/HyperFormula.ts | 27 ++++++++++++++ src/io/xlsx/exporter.ts | 52 +++++++++++++++++++++++++++ test/io/exporter.spec.ts | 78 ++++++++++++++++++++++++++++++++++++++++ test/io/toFile.spec.ts | 37 +++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/io/xlsx/exporter.ts create mode 100644 test/io/exporter.spec.ts create mode 100644 test/io/toFile.spec.ts diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index bdcfe28fa5..2d9cd37dac 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -53,6 +53,7 @@ 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 {loadXlsxWorkbook} from './io/xlsx/loadWorkbook' import {workbookToSheets} from './io/xlsx/importer' import {NamedExpression, NamedExpressionOptions, NamedExpressions} from './NamedExpressions' @@ -1174,6 +1175,32 @@ export class HyperFormula implements TypedEmitter { return this._serialization.getAllSheetsSerialized() } + /** + * Exports the current engine state to `.xlsx` file bytes. + * + * v1 scope: values and formulas only, no styles/number-formats/merged cells + * (see HF-107). Formulas are serialized exactly as {@link getAllSheetsSerialized} + * would return them. + * + * @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 { + return sheetsToXlsx(this.getAllSheetsSerialized()) + } + /** * 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/src/io/xlsx/exporter.ts b/src/io/xlsx/exporter.ts new file mode 100644 index 0000000000..4adc63afaf --- /dev/null +++ b/src/io/xlsx/exporter.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {Workbook} from 'exceljs' +import {RawCellContent} from '../../CellContentParser' + +/** + * Converts HF's serialized sheets (as returned by + * {@link HyperFormula#getAllSheetsSerialized}) into `.xlsx` file bytes. + * + * Pure and async (ExcelJS's writer is async); does not touch a live engine. + * + * v1 mapping (deliberately minimal, see HF-107 task brief): + * - a `null`/`undefined` cell is left unset, so it reloads blank (not `0`/`''`). + * - a formula string (`cell[0] === '='`) becomes an ExcelJS formula cell. + * - `number` | `string` | `boolean` | `Date` pass through as-is. + * + * Values and formulas only — no styles, number formats, or merged cells. + */ +export async function sheetsToXlsx(sheets: Record): Promise { + const workbook = new Workbook() + + for (const sheetName of Object.keys(sheets)) { + const worksheet = workbook.addWorksheet(sheetName) + const rows = sheets[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 = cell + } + } + } + } + + const buffer = await workbook.xlsx.writeBuffer() + return Uint8Array.from(buffer as Buffer) +} diff --git a/test/io/exporter.spec.ts b/test/io/exporter.spec.ts new file mode 100644 index 0000000000..141149c6e6 --- /dev/null +++ b/test/io/exporter.spec.ts @@ -0,0 +1,78 @@ +import ExcelJS, {Workbook} from 'exceljs' +import {sheetsToXlsx} from '../../src/io/xlsx/exporter' + +/** Loads exported bytes back into a fresh ExcelJS {@link Workbook} for assertions. */ +async function reload(bytes: Uint8Array): Promise { + const wb = new ExcelJS.Workbook() + await wb.xlsx.load(bytes as never) + return wb +} + +describe('sheetsToXlsx', () => { + it('returns a non-empty Uint8Array', async () => { + const bytes = await sheetsToXlsx({Sheet1: [[1]]}) + + expect(bytes instanceof Uint8Array).toBe(true) + expect(bytes.length).toBeGreaterThan(0) + }) + + it('writes a formula cell as a formula and a plain value at the right coordinates', async () => { + const bytes = await sheetsToXlsx({Sheet1: [['=A1+1', 5]]}) + + const wb = await reload(bytes) + const ws = wb.getWorksheet('Sheet1')! + + expect(ws.getCell('A1').formula).toBe('A1+1') + expect(ws.getCell('B1').value).toBe(5) + }) + + it('leaves a null cell blank on reload, not 0 or empty string', async () => { + const bytes = await sheetsToXlsx({Sheet1: [[1, null, 'x']]}) + + const wb = await reload(bytes) + const ws = wb.getWorksheet('Sheet1')! + + const blank = ws.getCell('B1').value + + expect(blank === null || blank === undefined).toBe(true) + }) + + it('creates one worksheet per input sheet, preserving names', async () => { + const bytes = await sheetsToXlsx({Data: [[1]], Summary: [[2]]}) + + const wb = await reload(bytes) + + expect(wb.getWorksheet('Data')).toBeTruthy() + expect(wb.getWorksheet('Summary')).toBeTruthy() + }) + + it('writes a Date value as a Date on reload (best-effort)', async () => { + const date = new Date(2024, 0, 1) + const bytes = await sheetsToXlsx({Sheet1: [[date]]}) + + const wb = await reload(bytes) + const ws = wb.getWorksheet('Sheet1')! + const value = ws.getCell('A1').value + + expect(value instanceof Date).toBe(true) + expect((value as Date).getTime()).toBe(date.getTime()) + }) + + it('writes a boolean value through as a boolean', async () => { + const bytes = await sheetsToXlsx({Sheet1: [[true, false]]}) + + const wb = await reload(bytes) + const ws = wb.getWorksheet('Sheet1')! + + expect(ws.getCell('A1').value).toBe(true) + expect(ws.getCell('B1').value).toBe(false) + }) + + it('produces a loadable file for an empty sheets map (an engine with zero sheets)', async () => { + const bytes = await sheetsToXlsx({}) + + const wb = await reload(bytes) + + expect(wb.worksheets.length).toBe(0) + }) +}) diff --git a/test/io/toFile.spec.ts b/test/io/toFile.spec.ts new file mode 100644 index 0000000000..1992d9f344 --- /dev/null +++ b/test/io/toFile.spec.ts @@ -0,0 +1,37 @@ +import ExcelJS from 'exceljs' +import {HyperFormula} from '../../src' + +describe('HyperFormula#toFile', () => { + it('exports a live engine to loadable xlsx bytes, preserving a formula', async () => { + const hf = HyperFormula.buildFromSheets({Sheet1: [[1, '=A1+1']]}, {licenseKey: 'gpl-v3'}) + + const bytes = await hf.toFile() + + expect(bytes instanceof Uint8Array).toBe(true) + expect(bytes.length).toBeGreaterThan(0) + + const wb = new ExcelJS.Workbook() + await wb.xlsx.load(bytes as never) + const ws = wb.getWorksheet('Sheet1')! + + expect(ws.getCell('A1').value).toBe(1) + expect(ws.getCell('B1').formula).toBe('A1+1') + + hf.destroy() + }) + + it('exports an empty engine to a valid, loadable xlsx file', async () => { + const hf = HyperFormula.buildEmpty({licenseKey: 'gpl-v3'}) + + const bytes = await hf.toFile() + + expect(bytes instanceof Uint8Array).toBe(true) + + const wb = new ExcelJS.Workbook() + await wb.xlsx.load(bytes as never) + + expect(wb.worksheets.length).toBe(0) + + hf.destroy() + }) +}) From 6abbcbd5db24ebde4266720432ccfd983e043f5d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:04:07 +0000 Subject: [PATCH 08/22] test(io): add xlsx round-trip specs for import<->export (HF-107) 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) --- test/io/roundtrip.spec.ts | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 test/io/roundtrip.spec.ts diff --git a/test/io/roundtrip.spec.ts b/test/io/roundtrip.spec.ts new file mode 100644 index 0000000000..174fcfee84 --- /dev/null +++ b/test/io/roundtrip.spec.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import ExcelJS, {Workbook} from 'exceljs' +import {HyperFormula} from '../../src' + +/** + * Round-trip tests (import ⇄ export) for the built-in xlsx I/O (HF-107). + * + * These complement the unit specs for `workbookToSheets` / `sheetsToXlsx` / + * `buildFromFile` / `toFile` by exercising the full loop end-to-end and + * asserting on the shape that actually matters to a consumer: serialized + * sheets and computed values. + */ +describe('xlsx round-trip (HF-107)', () => { + it('preserves values and formulas through buildFromSheets -> toFile -> buildFromFile (dates excluded: they degrade to serial by design)', async () => { + const sheets = { + Sheet1: [[1, 2, '=A1+B1'], ['x', true, null]], + Sheet2: [['=Sheet1!A1']], + } + + const hf1 = HyperFormula.buildFromSheets(sheets, {licenseKey: 'gpl-v3'}) + const hf2 = await HyperFormula.buildFromFile(await hf1.toFile(), {licenseKey: 'gpl-v3'}) + + expect(hf2.getAllSheetsSerialized()).toEqual(hf1.getAllSheetsSerialized()) + + // the formula still computes after the round trip, not just round-trips as text + expect(hf2.getSheetValues(0)[0]).toEqual([1, 2, 3]) + + hf1.destroy() + hf2.destroy() + }) + + it('is stable across a second round trip when the source is authored by ExcelJS, not buildFromArray/buildFromSheets', async () => { + const wb = new Workbook() + const data = wb.addWorksheet('Data') + const other = wb.addWorksheet('Other') + + other.getCell('A1').value = 5 + + data.getCell('A1').value = 10 + data.getCell('B1').value = {formula: 'A1+1', result: 11} as unknown as number + data.getCell('C1').value = {formula: 'Other!A1', result: 5} as unknown as number + // best-effort: an error cell falls back to its text on import (see workbookToSheets) + data.getCell('D1').value = {error: '#DIV/0!'} as unknown as string + + const bytes = await wb.xlsx.writeBuffer() as unknown as Uint8Array + + const hf = await HyperFormula.buildFromFile(bytes, {licenseKey: 'gpl-v3'}) + const hf2 = await HyperFormula.buildFromFile(await hf.toFile(), {licenseKey: 'gpl-v3'}) + + // sanity: the ExcelJS-authored content actually landed where expected + expect(hf.getAllSheetsSerialized()).toEqual({ + Data: [[10, '=A1+1', '=Other!A1', '#DIV/0!']], + Other: [[5]], + }) + + // import -> export -> import is stable for that content + expect(hf2.getAllSheetsSerialized()).toEqual(hf.getAllSheetsSerialized()) + expect(hf2.getSheetValues(0)).toEqual(hf.getSheetValues(0)) + + hf.destroy() + hf2.destroy() + }) + + it('does not round-trip cell formatting (fill/font/numFmt), proving the lossy contract is intentional', async () => { + const wb = new Workbook() + const ws = wb.addWorksheet('Data') + const cell = ws.getCell('A1') + + cell.value = 1234.5 + cell.numFmt = '#,##0.00' + cell.font = {bold: true, color: {argb: 'FFFF0000'}} + cell.fill = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FFFFFF00'}} + + const sourceBytes = await wb.xlsx.writeBuffer() as unknown as Uint8Array + + const hf = await HyperFormula.buildFromFile(sourceBytes, {licenseKey: 'gpl-v3'}) + const roundTripBytes = await hf.toFile() + + const reloaded = new ExcelJS.Workbook() + await reloaded.xlsx.load(roundTripBytes as never) + const reloadedCell = reloaded.getWorksheet('Data')!.getCell('A1') + + // the value survived... + expect(reloadedCell.value).toBe(1234.5) + + // ...but none of the formatting did (best-effort: tolerant of either + // "unset" representation ExcelJS may use for a never-styled cell) + expect(reloadedCell.numFmt === undefined || reloadedCell.numFmt === 'General').toBe(true) + expect(reloadedCell.fill).toBeUndefined() + expect(reloadedCell.font?.bold).not.toBe(true) + + hf.destroy() + }) +}) From 20805321ae6e146e46e0a5795c115c3139a95999 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:06:59 +0000 Subject: [PATCH 09/22] docs(guide): document built-in buildFromFile/toFile xlsx I/O (HF-107) 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) --- docs/guide/file-import.md | 127 ++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 5e7c1d1c05..3194a3fca3 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -1,69 +1,118 @@ -# 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 can import and export `.xlsx` files out of the box, using the +[ExcelJS](https://www.npmjs.com/package/exceljs) library under the hood. 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 +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. -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. +::: 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. Formula import assumes the English (`en`) function dialect. +::: ## Import XLSX files -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. - -### Example: Import XLSX files in Node - -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.4.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; } +``` -function convertXlsxWorkbookToJavascriptArrays(workbook) { - const workbookData = {}; +### In the browser - workbook.eachSheet((worksheet) => { - const sheetDimensions = worksheet.dimensions - const sheetData = []; +```js +import { HyperFormula } from 'hyperformula'; + +// `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' }); + + 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); } +``` -run('sample_file.xlsx'); +### In the browser + +```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.4.x/read-excel-file). From 2c433bf83d0019260d71c68bc7d945cbbd578b21 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:39:20 +0000 Subject: [PATCH 10/22] refactor(io): lazy-load exceljs + externalize in base bundle (HF-107) 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) --- .config/webpack/development.js | 6 ++++++ docs/guide/file-import.md | 3 +++ package-lock.json | 9 --------- package.json | 1 - src/io/xlsx/exporter.ts | 4 ++-- src/io/xlsx/importer.ts | 6 +++++- src/io/xlsx/loadWorkbook.ts | 5 +++-- tsconfig.json | 4 ++-- 8 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.config/webpack/development.js b/.config/webpack/development.js index 029b8246ce..fcb32c6974 100644 --- a/.config/webpack/development.js +++ b/.config/webpack/development.js @@ -29,6 +29,12 @@ 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` })); }); diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 3194a3fca3..3320634042 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -19,6 +19,9 @@ 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. Formula import assumes the English (`en`) function dialect. +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. ::: ## Import XLSX files diff --git a/package-lock.json b/package-lock.json index 61c88c0e2c..ef14724527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,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", @@ -4584,14 +4583,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/exceljs": { - "version": "0.5.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/express": { "version": "5.0.6", "dev": true, diff --git a/package.json b/package.json index 9f4b584608..349ade5d08 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,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", diff --git a/src/io/xlsx/exporter.ts b/src/io/xlsx/exporter.ts index 4adc63afaf..8089738894 100644 --- a/src/io/xlsx/exporter.ts +++ b/src/io/xlsx/exporter.ts @@ -3,7 +3,6 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {Workbook} from 'exceljs' import {RawCellContent} from '../../CellContentParser' /** @@ -20,7 +19,8 @@ import {RawCellContent} from '../../CellContentParser' * Values and formulas only — no styles, number formats, or merged cells. */ export async function sheetsToXlsx(sheets: Record): Promise { - const workbook = new Workbook() + const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') + const workbook = new ExcelJS.Workbook() for (const sheetName of Object.keys(sheets)) { const worksheet = workbook.addWorksheet(sheetName) diff --git a/src/io/xlsx/importer.ts b/src/io/xlsx/importer.ts index 2fd3998e91..d7ae54ff1f 100644 --- a/src/io/xlsx/importer.ts +++ b/src/io/xlsx/importer.ts @@ -3,7 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {Cell, Workbook, Worksheet} from 'exceljs' +import type {Cell, Workbook, Worksheet} from 'exceljs' import {RawCellContent} from '../../CellContentParser' import {Sheet, Sheets} from '../../Sheet' @@ -75,6 +75,10 @@ function worksheetToSheet(worksheet: Worksheet): Sheet { * `_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. + * + * NOTE: this operates on the whole formula string, so a matching substring + * inside a string literal (e.g. `="_xlfn."`) would also be rewritten. That + * edge case is not handled here — accepted best-effort limitation for v1. */ function stripFunctionPrefixes(formula: string): string { return formula.replace(/_xlfn\.|_xlws\./g, '') diff --git a/src/io/xlsx/loadWorkbook.ts b/src/io/xlsx/loadWorkbook.ts index 68af5951cf..5e80cf51d1 100644 --- a/src/io/xlsx/loadWorkbook.ts +++ b/src/io/xlsx/loadWorkbook.ts @@ -3,7 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {Workbook} from 'exceljs' +import type {Workbook} from 'exceljs' import {UnsupportedFileError} from '../../errors' /** @@ -20,7 +20,8 @@ export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise< const buffer = Buffer.from(data) try { - const workbook = new Workbook() + const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') + const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(buffer) return workbook } catch (e) { diff --git a/tsconfig.json b/tsconfig.json index 812d955676..d8ef6d31bd 100644 --- a/tsconfig.json +++ b/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", From ce85904931198863cdba84443a50e0de14c09d59 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:48:12 +0000 Subject: [PATCH 11/22] fix(io): keep exceljs and buffer polyfill out of UMD bundles (HF-107) - 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) --- .config/webpack/development.js | 12 ++++++++++++ src/io/xlsx/loadWorkbook.ts | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.config/webpack/development.js b/.config/webpack/development.js index fcb32c6974..5602efd5a7 100644 --- a/.config/webpack/development.js +++ b/.config/webpack/development.js @@ -41,6 +41,18 @@ module.exports.create = function create() { 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/src/io/xlsx/loadWorkbook.ts b/src/io/xlsx/loadWorkbook.ts index 5e80cf51d1..c36dce2669 100644 --- a/src/io/xlsx/loadWorkbook.ts +++ b/src/io/xlsx/loadWorkbook.ts @@ -17,12 +17,15 @@ export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise< throw new UnsupportedFileError('empty') } - const buffer = Buffer.from(data) + // Copy to a fresh, zero-offset Uint8Array (works in Node and the browser + // without pulling in the Node `Buffer` polyfill). `.slice()` on a subarray + // view copies exactly the view's logical bytes, so byteOffset is honored. + const bytes = data instanceof Uint8Array ? data.slice() : new Uint8Array(data) try { const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load(buffer) + await workbook.xlsx.load(bytes.buffer) return workbook } catch (e) { const detail = e instanceof Error ? e.message : String(e) From 76ee6cad948892eaabb906c661555676144f5fe2 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:52:57 +0000 Subject: [PATCH 12/22] test(io): relocate xlsx specs to hyperformula-tests repo (HF-107) 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) --- test/io/buildFromFile.spec.ts | 102 ------------------------- test/io/errors.spec.ts | 28 ------- test/io/exporter.spec.ts | 78 ------------------- test/io/importer.spec.ts | 139 ---------------------------------- test/io/loadWorkbook.spec.ts | 74 ------------------ test/io/roundtrip.spec.ts | 98 ------------------------ test/io/toFile.spec.ts | 37 --------- 7 files changed, 556 deletions(-) delete mode 100644 test/io/buildFromFile.spec.ts delete mode 100644 test/io/errors.spec.ts delete mode 100644 test/io/exporter.spec.ts delete mode 100644 test/io/importer.spec.ts delete mode 100644 test/io/loadWorkbook.spec.ts delete mode 100644 test/io/roundtrip.spec.ts delete mode 100644 test/io/toFile.spec.ts diff --git a/test/io/buildFromFile.spec.ts b/test/io/buildFromFile.spec.ts deleted file mode 100644 index 28d2432905..0000000000 --- a/test/io/buildFromFile.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import {Workbook} from 'exceljs' -import {HyperFormula} from '../../src' -import {UnsupportedFileError} from '../../src/errors' - -/** Builds an xlsx buffer in memory (Sheet1!A1 = 1, Sheet1!B1 = '=A1+1'). */ -async function singleSheetBuffer(): Promise { - const wb = new Workbook() - const ws = wb.addWorksheet('Sheet1') - ws.getCell('A1').value = 1 - ws.getCell('B1').value = {formula: 'A1+1', result: 2} as unknown as number - const buf = await wb.xlsx.writeBuffer() - return buf as unknown as Uint8Array -} - -/** Builds an xlsx buffer with two named worksheets. */ -async function twoSheetBuffer(): Promise { - const wb = new Workbook() - wb.addWorksheet('Data') - wb.addWorksheet('Summary') - const buf = await wb.xlsx.writeBuffer() - return buf as unknown as Uint8Array -} - -/** Copies a Uint8Array's bytes into a fresh, standalone ArrayBuffer. */ -function toArrayBuffer(data: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(data.length) - copy.set(data) - return copy.buffer -} - -describe('HyperFormula.buildFromFile', () => { - it('imports a sheet and evaluates formulas end-to-end', async () => { - const data = await singleSheetBuffer() - - const hf = await HyperFormula.buildFromFile(data, {licenseKey: 'gpl-v3'}) - - expect(hf.getSheetSerialized(0)[0][1]).toBe('=A1+1') - expect(hf.getSheetValues(0)[0]).toEqual([1, 2]) - - hf.destroy() - }) - - it('imports every worksheet name from a multi-sheet workbook', async () => { - const data = await twoSheetBuffer() - - const hf = await HyperFormula.buildFromFile(data, {licenseKey: 'gpl-v3'}) - - const names = hf.getSheetNames() - - expect(names).toContain('Data') - expect(names).toContain('Summary') - - hf.destroy() - }) - - it('builds equivalent engines from the same bytes as Uint8Array and as ArrayBuffer', async () => { - const bytes = await singleSheetBuffer() - const arrayBuffer = toArrayBuffer(bytes) - - const hfFromBytes = await HyperFormula.buildFromFile(bytes, {licenseKey: 'gpl-v3'}) - const hfFromArrayBuffer = await HyperFormula.buildFromFile(arrayBuffer, {licenseKey: 'gpl-v3'}) - - expect(hfFromArrayBuffer.getSheetSerialized(0)).toEqual(hfFromBytes.getSheetSerialized(0)) - expect(hfFromArrayBuffer.getSheetValues(0)).toEqual(hfFromBytes.getSheetValues(0)) - - hfFromBytes.destroy() - hfFromArrayBuffer.destroy() - }) - - it('rejects garbage bytes with UnsupportedFileError', async () => { - let err: unknown - try { - await HyperFormula.buildFromFile(new Uint8Array([1, 2, 3, 4, 5]), {licenseKey: 'gpl-v3'}) - } catch (e) { - err = e - } - - expect(err instanceof UnsupportedFileError).toBe(true) - expect((err as UnsupportedFileError).reason).toBe('unparseable') - }) - - it('builds independent, non-shared engines when run concurrently', async () => { - const wbA = new Workbook() - wbA.addWorksheet('Sheet1').getCell('A1').value = 'A' - const bufA = await wbA.xlsx.writeBuffer() as unknown as Uint8Array - - const wbB = new Workbook() - wbB.addWorksheet('Sheet1').getCell('A1').value = 'B' - const bufB = await wbB.xlsx.writeBuffer() as unknown as Uint8Array - - const [hfA, hfB] = await Promise.all([ - HyperFormula.buildFromFile(bufA, {licenseKey: 'gpl-v3'}), - HyperFormula.buildFromFile(bufB, {licenseKey: 'gpl-v3'}), - ]) - - expect(hfA.getSheetValues(0)).toEqual([['A']]) - expect(hfB.getSheetValues(0)).toEqual([['B']]) - - hfA.destroy() - hfB.destroy() - }) -}) diff --git a/test/io/errors.spec.ts b/test/io/errors.spec.ts deleted file mode 100644 index d685d77e60..0000000000 --- a/test/io/errors.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {UnsupportedFileError} from '../../src/errors' - -describe('UnsupportedFileError', () => { - it('is an instance of Error and UnsupportedFileError', () => { - const error = new UnsupportedFileError('empty') - - expect(error instanceof Error).toBe(true) - expect(error instanceof UnsupportedFileError).toBe(true) - }) - - it('exposes the reason it was constructed with', () => { - expect(new UnsupportedFileError('empty').reason).toBe('empty') - expect(new UnsupportedFileError('unparseable').reason).toBe('unparseable') - }) - - it('includes the reason in the message', () => { - const error = new UnsupportedFileError('empty') - - expect(error.message).toContain('empty') - }) - - it('includes the detail in the message when provided', () => { - const error = new UnsupportedFileError('unparseable', 'unexpected end of file') - - expect(error.message).toContain('unparseable') - expect(error.message).toContain('unexpected end of file') - }) -}) diff --git a/test/io/exporter.spec.ts b/test/io/exporter.spec.ts deleted file mode 100644 index 141149c6e6..0000000000 --- a/test/io/exporter.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import ExcelJS, {Workbook} from 'exceljs' -import {sheetsToXlsx} from '../../src/io/xlsx/exporter' - -/** Loads exported bytes back into a fresh ExcelJS {@link Workbook} for assertions. */ -async function reload(bytes: Uint8Array): Promise { - const wb = new ExcelJS.Workbook() - await wb.xlsx.load(bytes as never) - return wb -} - -describe('sheetsToXlsx', () => { - it('returns a non-empty Uint8Array', async () => { - const bytes = await sheetsToXlsx({Sheet1: [[1]]}) - - expect(bytes instanceof Uint8Array).toBe(true) - expect(bytes.length).toBeGreaterThan(0) - }) - - it('writes a formula cell as a formula and a plain value at the right coordinates', async () => { - const bytes = await sheetsToXlsx({Sheet1: [['=A1+1', 5]]}) - - const wb = await reload(bytes) - const ws = wb.getWorksheet('Sheet1')! - - expect(ws.getCell('A1').formula).toBe('A1+1') - expect(ws.getCell('B1').value).toBe(5) - }) - - it('leaves a null cell blank on reload, not 0 or empty string', async () => { - const bytes = await sheetsToXlsx({Sheet1: [[1, null, 'x']]}) - - const wb = await reload(bytes) - const ws = wb.getWorksheet('Sheet1')! - - const blank = ws.getCell('B1').value - - expect(blank === null || blank === undefined).toBe(true) - }) - - it('creates one worksheet per input sheet, preserving names', async () => { - const bytes = await sheetsToXlsx({Data: [[1]], Summary: [[2]]}) - - const wb = await reload(bytes) - - expect(wb.getWorksheet('Data')).toBeTruthy() - expect(wb.getWorksheet('Summary')).toBeTruthy() - }) - - it('writes a Date value as a Date on reload (best-effort)', async () => { - const date = new Date(2024, 0, 1) - const bytes = await sheetsToXlsx({Sheet1: [[date]]}) - - const wb = await reload(bytes) - const ws = wb.getWorksheet('Sheet1')! - const value = ws.getCell('A1').value - - expect(value instanceof Date).toBe(true) - expect((value as Date).getTime()).toBe(date.getTime()) - }) - - it('writes a boolean value through as a boolean', async () => { - const bytes = await sheetsToXlsx({Sheet1: [[true, false]]}) - - const wb = await reload(bytes) - const ws = wb.getWorksheet('Sheet1')! - - expect(ws.getCell('A1').value).toBe(true) - expect(ws.getCell('B1').value).toBe(false) - }) - - it('produces a loadable file for an empty sheets map (an engine with zero sheets)', async () => { - const bytes = await sheetsToXlsx({}) - - const wb = await reload(bytes) - - expect(wb.worksheets.length).toBe(0) - }) -}) diff --git a/test/io/importer.spec.ts b/test/io/importer.spec.ts deleted file mode 100644 index 678bce2bb9..0000000000 --- a/test/io/importer.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import ExcelJS, {Workbook, Worksheet} from 'exceljs' -import {workbookToSheets} from '../../src/io/xlsx/importer' -import {Sheets} from '../../src/Sheet' - -/** - * Builds a worksheet via `build`, then writes and reloads the workbook through - * ExcelJS so formulas come back in their real stored form (e.g. `_xlfn.` - * prefixes, per-cell-rebased shared formulas) rather than the in-memory form - * that a directly-constructed cell may not exhibit. - */ -async function roundTripSheet(build: (ws: Worksheet) => void): Promise { - const wb = new ExcelJS.Workbook() - build(wb.addWorksheet('S1')) - const buf = await wb.xlsx.writeBuffer() - const wb2 = new ExcelJS.Workbook() - await wb2.xlsx.load(buf as never) - return workbookToSheets(wb2) -} - -describe('workbookToSheets', () => { - it('keys the result by worksheet name, preserving multiple sheets', () => { - const wb = new Workbook() - wb.addWorksheet('Data') - wb.addWorksheet('Summary') - - const sheets = workbookToSheets(wb) - - expect(Object.keys(sheets).sort()).toEqual(['Data', 'Summary']) - }) - - it('maps number, string, boolean and blank cells to the right primitives at the right coordinates', () => { - const wb = new Workbook() - const ws = wb.addWorksheet('Data') - ws.getCell('A1').value = 1 - ws.getCell('B1').value = 'hi' - ws.getCell('A2').value = true - // B2 left blank on purpose - - const sheets = workbookToSheets(wb) - - expect(sheets.Data[0][0]).toBe(1) - expect(sheets.Data[0][1]).toBe('hi') - expect(sheets.Data[1][0]).toBe(true) - expect(sheets.Data[1][1]).toBe(null) - }) - - it('maps a formula cell to a string formula prefixed with =', () => { - const wb = new Workbook() - const ws = wb.addWorksheet('Data') - ws.getCell('A1').value = 1 - ws.getCell('B2').value = {formula: 'A1+1', result: 2} as unknown as number - - const sheets = workbookToSheets(wb) - - expect(sheets.Data[1][1]).toBe('=A1+1') - }) - - it('passes a Date cell through as a Date (best-effort)', () => { - const wb = new Workbook() - const ws = wb.addWorksheet('Data') - const date = new Date(2024, 0, 1) - ws.getCell('A1').value = date - - const sheets = workbookToSheets(wb) - - expect(sheets.Data[0][0] instanceof Date).toBe(true) - expect((sheets.Data[0][0] as Date).getTime()).toBe(date.getTime()) - }) - - it('renders an object-valued error cell via cell.text, never the raw object', () => { - const wb = new Workbook() - const ws = wb.addWorksheet('Data') - ws.getCell('A1').value = {error: '#DIV/0!'} as unknown as string - - const sheets = workbookToSheets(wb) - - expect(sheets.Data[0][0]).toBe('#DIV/0!') - expect(typeof sheets.Data[0][0]).toBe('string') - }) - - it('maps an empty worksheet to an empty array', () => { - const wb = new Workbook() - wb.addWorksheet('Empty') - - const sheets = workbookToSheets(wb) - - expect(sheets.Empty).toEqual([]) - }) -}) - -describe('workbookToSheets - formula dialect normalization (HF-107)', () => { - it('strips the _xlfn. prefix Excel adds for newer functions', async () => { - const sheets = await roundTripSheet(ws => { - ws.getCell('A1').value = 1 - ws.getCell('A2').value = 2 - ws.getCell('A3').value = 3 - ws.getCell('C1').value = {formula: '_xlfn.XLOOKUP(1,A1:A3,A1:A3)', result: 1} as unknown as number - }) - - expect(sheets.S1[0][2]).toBe('=XLOOKUP(1,A1:A3,A1:A3)') - }) - - it('strips both _xlfn. and _xlws. when a formula carries the combined prefix', async () => { - const sheets = await roundTripSheet(ws => { - ws.getCell('A1').value = 1 - ws.getCell('C1').value = {formula: '_xlfn._xlws.ANCHORARRAY(A1)', result: 1} as unknown as number - }) - - expect(sheets.S1[0][2]).toBe('=ANCHORARRAY(A1)') - }) - - it('imports a shared formula group with each cell already rebased to its own row (no rebasing logic needed)', async () => { - const sheets = await roundTripSheet(ws => { - ws.getCell('A1').value = 1 - ws.getCell('A2').value = 2 - ws.getCell('A3').value = 3 - ws.fillFormula('B1:B3', 'A1*2', [2, 4, 6]) - }) - - expect(sheets.S1[0][1]).toBe('=A1*2') - expect(sheets.S1[1][1]).toBe('=A2*2') - expect(sheets.S1[2][1]).toBe('=A3*2') - }) - - it('passes a cross-sheet, quoted-name reference through verbatim', async () => { - const wb = new ExcelJS.Workbook() - const other = wb.addWorksheet('My Sheet') - other.getCell('A1').value = 42 - const s1 = wb.addWorksheet('S1') - s1.getCell('D1').value = {formula: "'My Sheet'!A1", result: 42} as unknown as number - - const buf = await wb.xlsx.writeBuffer() - const wb2 = new ExcelJS.Workbook() - await wb2.xlsx.load(buf as never) - const sheets = workbookToSheets(wb2) - - expect(sheets.S1[0][3]).toBe("='My Sheet'!A1") - }) -}) diff --git a/test/io/loadWorkbook.spec.ts b/test/io/loadWorkbook.spec.ts deleted file mode 100644 index 2b821fe35d..0000000000 --- a/test/io/loadWorkbook.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {Workbook} from 'exceljs' -import {UnsupportedFileError} from '../../src/errors' -import {loadXlsxWorkbook} from '../../src/io/xlsx/loadWorkbook' - -/** Builds a minimal valid xlsx file in memory (Sheet1!A1 = 42). */ -async function xlsxBuffer(): Promise { - const wb = new Workbook() - const ws = wb.addWorksheet('Sheet1') - ws.getCell('A1').value = 42 - const buf = await wb.xlsx.writeBuffer() - return buf as unknown as Uint8Array -} - -describe('loadXlsxWorkbook', () => { - it('loads a valid xlsx buffer into a Workbook', async () => { - const data = await xlsxBuffer() - - const wb = await loadXlsxWorkbook(data) - - const ws = wb.getWorksheet('Sheet1') - - expect(ws?.getCell('A1').value).toBe(42) - }) - - it('rejects an empty Uint8Array with UnsupportedFileError(empty)', async () => { - let err: unknown - try { - await loadXlsxWorkbook(new Uint8Array(0)) - } catch (e) { - err = e - } - - expect(err instanceof UnsupportedFileError).toBe(true) - expect((err as UnsupportedFileError).reason).toBe('empty') - }) - - it('rejects an empty ArrayBuffer with UnsupportedFileError(empty)', async () => { - let err: unknown - try { - await loadXlsxWorkbook(new ArrayBuffer(0)) - } catch (e) { - err = e - } - - expect(err instanceof UnsupportedFileError).toBe(true) - expect((err as UnsupportedFileError).reason).toBe('empty') - }) - - it('rejects garbage bytes with UnsupportedFileError(unparseable)', async () => { - let err: unknown - try { - await loadXlsxWorkbook(new Uint8Array([1, 2, 3, 4, 5])) - } catch (e) { - err = e - } - - expect(err instanceof UnsupportedFileError).toBe(true) - expect((err as UnsupportedFileError).reason).toBe('unparseable') - }) - - it('loads correctly from a subarray view with a non-zero byteOffset', async () => { - const xlsx = await xlsxBuffer() - const offset = 7 - const big = new Uint8Array(offset + xlsx.length + 3) - big.set(xlsx, offset) - const view = big.subarray(offset, offset + xlsx.length) - - const wb = await loadXlsxWorkbook(view) - - const ws = wb.getWorksheet('Sheet1') - - expect(ws?.getCell('A1').value).toBe(42) - }) -}) diff --git a/test/io/roundtrip.spec.ts b/test/io/roundtrip.spec.ts deleted file mode 100644 index 174fcfee84..0000000000 --- a/test/io/roundtrip.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @license - * Copyright (c) 2025 Handsoncode. All rights reserved. - */ - -import ExcelJS, {Workbook} from 'exceljs' -import {HyperFormula} from '../../src' - -/** - * Round-trip tests (import ⇄ export) for the built-in xlsx I/O (HF-107). - * - * These complement the unit specs for `workbookToSheets` / `sheetsToXlsx` / - * `buildFromFile` / `toFile` by exercising the full loop end-to-end and - * asserting on the shape that actually matters to a consumer: serialized - * sheets and computed values. - */ -describe('xlsx round-trip (HF-107)', () => { - it('preserves values and formulas through buildFromSheets -> toFile -> buildFromFile (dates excluded: they degrade to serial by design)', async () => { - const sheets = { - Sheet1: [[1, 2, '=A1+B1'], ['x', true, null]], - Sheet2: [['=Sheet1!A1']], - } - - const hf1 = HyperFormula.buildFromSheets(sheets, {licenseKey: 'gpl-v3'}) - const hf2 = await HyperFormula.buildFromFile(await hf1.toFile(), {licenseKey: 'gpl-v3'}) - - expect(hf2.getAllSheetsSerialized()).toEqual(hf1.getAllSheetsSerialized()) - - // the formula still computes after the round trip, not just round-trips as text - expect(hf2.getSheetValues(0)[0]).toEqual([1, 2, 3]) - - hf1.destroy() - hf2.destroy() - }) - - it('is stable across a second round trip when the source is authored by ExcelJS, not buildFromArray/buildFromSheets', async () => { - const wb = new Workbook() - const data = wb.addWorksheet('Data') - const other = wb.addWorksheet('Other') - - other.getCell('A1').value = 5 - - data.getCell('A1').value = 10 - data.getCell('B1').value = {formula: 'A1+1', result: 11} as unknown as number - data.getCell('C1').value = {formula: 'Other!A1', result: 5} as unknown as number - // best-effort: an error cell falls back to its text on import (see workbookToSheets) - data.getCell('D1').value = {error: '#DIV/0!'} as unknown as string - - const bytes = await wb.xlsx.writeBuffer() as unknown as Uint8Array - - const hf = await HyperFormula.buildFromFile(bytes, {licenseKey: 'gpl-v3'}) - const hf2 = await HyperFormula.buildFromFile(await hf.toFile(), {licenseKey: 'gpl-v3'}) - - // sanity: the ExcelJS-authored content actually landed where expected - expect(hf.getAllSheetsSerialized()).toEqual({ - Data: [[10, '=A1+1', '=Other!A1', '#DIV/0!']], - Other: [[5]], - }) - - // import -> export -> import is stable for that content - expect(hf2.getAllSheetsSerialized()).toEqual(hf.getAllSheetsSerialized()) - expect(hf2.getSheetValues(0)).toEqual(hf.getSheetValues(0)) - - hf.destroy() - hf2.destroy() - }) - - it('does not round-trip cell formatting (fill/font/numFmt), proving the lossy contract is intentional', async () => { - const wb = new Workbook() - const ws = wb.addWorksheet('Data') - const cell = ws.getCell('A1') - - cell.value = 1234.5 - cell.numFmt = '#,##0.00' - cell.font = {bold: true, color: {argb: 'FFFF0000'}} - cell.fill = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FFFFFF00'}} - - const sourceBytes = await wb.xlsx.writeBuffer() as unknown as Uint8Array - - const hf = await HyperFormula.buildFromFile(sourceBytes, {licenseKey: 'gpl-v3'}) - const roundTripBytes = await hf.toFile() - - const reloaded = new ExcelJS.Workbook() - await reloaded.xlsx.load(roundTripBytes as never) - const reloadedCell = reloaded.getWorksheet('Data')!.getCell('A1') - - // the value survived... - expect(reloadedCell.value).toBe(1234.5) - - // ...but none of the formatting did (best-effort: tolerant of either - // "unset" representation ExcelJS may use for a never-styled cell) - expect(reloadedCell.numFmt === undefined || reloadedCell.numFmt === 'General').toBe(true) - expect(reloadedCell.fill).toBeUndefined() - expect(reloadedCell.font?.bold).not.toBe(true) - - hf.destroy() - }) -}) diff --git a/test/io/toFile.spec.ts b/test/io/toFile.spec.ts deleted file mode 100644 index 1992d9f344..0000000000 --- a/test/io/toFile.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import ExcelJS from 'exceljs' -import {HyperFormula} from '../../src' - -describe('HyperFormula#toFile', () => { - it('exports a live engine to loadable xlsx bytes, preserving a formula', async () => { - const hf = HyperFormula.buildFromSheets({Sheet1: [[1, '=A1+1']]}, {licenseKey: 'gpl-v3'}) - - const bytes = await hf.toFile() - - expect(bytes instanceof Uint8Array).toBe(true) - expect(bytes.length).toBeGreaterThan(0) - - const wb = new ExcelJS.Workbook() - await wb.xlsx.load(bytes as never) - const ws = wb.getWorksheet('Sheet1')! - - expect(ws.getCell('A1').value).toBe(1) - expect(ws.getCell('B1').formula).toBe('A1+1') - - hf.destroy() - }) - - it('exports an empty engine to a valid, loadable xlsx file', async () => { - const hf = HyperFormula.buildEmpty({licenseKey: 'gpl-v3'}) - - const bytes = await hf.toFile() - - expect(bytes instanceof Uint8Array).toBe(true) - - const wb = new ExcelJS.Workbook() - await wb.xlsx.load(bytes as never) - - expect(wb.worksheets.length).toBe(0) - - hf.destroy() - }) -}) From f28710ef7344a835924e2fcf8411c0618e15329a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 16 Jul 2026 20:11:25 +0000 Subject: [PATCH 13/22] fix(io): correct xlsx export byte conversion for browser ArrayBuffer (HF-107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/io/xlsx/exporter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/io/xlsx/exporter.ts b/src/io/xlsx/exporter.ts index 8089738894..1865e5c59a 100644 --- a/src/io/xlsx/exporter.ts +++ b/src/io/xlsx/exporter.ts @@ -48,5 +48,9 @@ export async function sheetsToXlsx(sheets: Record): } const buffer = await workbook.xlsx.writeBuffer() - return Uint8Array.from(buffer as Buffer) + // 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) } From 5d9f252ab28bf77c8c197a3590c4fcd3ddef1c4f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 28 Aug 2026 08:17:56 +0000 Subject: [PATCH 14/22] implement-iterate: green after 1 iteration(s) --- CHANGELOG.md | 4 ++++ src/io/xlsx/loadWorkbook.ts | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c4..f1309ff20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added built-in `.xlsx` import/export: the `HyperFormula.buildFromFile()` static factory and the `toFile()` instance method, backed by ExcelJS. [#1702](https://github.com/handsontable/hyperformula/pull/1702) + ## [3.4.0] - 2026-08-10 ### Added diff --git a/src/io/xlsx/loadWorkbook.ts b/src/io/xlsx/loadWorkbook.ts index c36dce2669..d82172a10c 100644 --- a/src/io/xlsx/loadWorkbook.ts +++ b/src/io/xlsx/loadWorkbook.ts @@ -17,10 +17,7 @@ export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise< throw new UnsupportedFileError('empty') } - // Copy to a fresh, zero-offset Uint8Array (works in Node and the browser - // without pulling in the Node `Buffer` polyfill). `.slice()` on a subarray - // view copies exactly the view's logical bytes, so byteOffset is honored. - const bytes = data instanceof Uint8Array ? data.slice() : new Uint8Array(data) + const bytes = toStandaloneBytes(data) try { const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') @@ -32,3 +29,33 @@ export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise< throw new UnsupportedFileError('unparseable', detail) } } + +/** + * 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 +} From 7c4f9e0bb67e1ca278ea20913de00fa147e30f85 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 14 Sep 2026 04:42:16 +0000 Subject: [PATCH 15/22] Declare exceljs an optional peer dependency instead of a runtime one 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 --- CHANGELOG.md | 1 + docs/guide/file-import.md | 22 +++- package-lock.json | 266 +++++++++++++++++++++++++++++++++++++- package.json | 10 +- 4 files changed, 289 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfebf40b7..f3ed29446f 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 built-in `.xlsx` import/export: the `HyperFormula.buildFromFile()` static factory and the `toFile()` instance method, backed by ExcelJS. [#1702](https://github.com/handsontable/hyperformula/pull/1702) +- ExcelJS is declared as an **optional peer dependency**: it is required only by `buildFromFile()` and `toFile()`, is loaded on demand, and is not installed with HyperFormula. Add it with `npm install exceljs` if you use either method. [#1702](https://github.com/handsontable/hyperformula/pull/1702) ### Fixed diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 0b538e70d9..3fbd3c0deb 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -13,11 +13,23 @@ Import and export XLSX files, and import CSV files, with HyperFormula. ## Overview -HyperFormula can import and export `.xlsx` files out of the box, using the -[ExcelJS](https://www.npmjs.com/package/exceljs) library under the hood. Import -with the [`buildFromFile`](../api/classes/hyperformula.md#buildfromfile) factory -method and export with the [`toFile`](../api/classes/hyperformula.md#tofile) -method. +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. + +ExcelJS is an **optional peer dependency**, so it is not installed with +HyperFormula. Add it only if you use these two methods: + +```bash +npm install exceljs +``` + +Everything else in HyperFormula works without it. Calling `buildFromFile` or +`toFile` without ExcelJS installed fails at the point of the call rather than at +import time, because the library is loaded on demand. In the UMD `full` build the +same applies: 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 diff --git a/package-lock.json b/package-lock.json index ef14724527..bc5ab0f3ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "GPL-3.0-only", "dependencies": { "chevrotain": "^6.5.0", - "exceljs": "^4.4.0", "tiny-emitter": "^2.1.0" }, "devDependencies": { @@ -50,6 +49,7 @@ "eslint-plugin-license-header": "^0.6.1", "eslint-plugin-prettier": "^5.2.1", "esm": "^3.2.25", + "exceljs": "^4.4.0", "full-icu": "^1.5.0", "jasmine": "^5.4.0", "jest": "^26.6.3", @@ -83,6 +83,14 @@ "webpack-cli": "^4.10.0", "webpackbar": "^6.0.1", "wrangler": "^4.118.0" + }, + "peerDependencies": { + "exceljs": "^4.4.0" + }, + "peerDependenciesMeta": { + "exceljs": { + "optional": true + } } }, "node_modules/@asamuzakjp/css-color": { @@ -2759,6 +2767,9 @@ }, "node_modules/@fast-csv/format": { "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "^14.0.1", @@ -2771,10 +2782,16 @@ }, "node_modules/@fast-csv/format/node_modules/@types/node": { "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, "license": "MIT" }, "node_modules/@fast-csv/parse": { "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "^14.0.1", @@ -2788,6 +2805,9 @@ }, "node_modules/@fast-csv/parse/node_modules/@types/node": { "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, "license": "MIT" }, "node_modules/@gar/promisify": { @@ -6865,6 +6885,9 @@ }, "node_modules/archiver": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^2.1.0", @@ -6881,6 +6904,9 @@ }, "node_modules/archiver-utils": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, "license": "MIT", "dependencies": { "glob": "^7.1.4", @@ -6900,10 +6926,16 @@ }, "node_modules/archiver-utils/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -6917,10 +6949,16 @@ }, "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -7137,6 +7175,7 @@ }, "node_modules/async": { "version": "3.2.6", + "dev": true, "license": "MIT" }, "node_modules/async-each": { @@ -7604,6 +7643,7 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "dev": true, "license": "MIT" }, "node_modules/base": { @@ -7648,6 +7688,7 @@ }, "node_modules/base64-js": { "version": "1.5.1", + "dev": true, "funding": [ { "type": "github", @@ -7698,6 +7739,9 @@ }, "node_modules/big-integer": { "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, "license": "Unlicense", "engines": { "node": ">=0.6" @@ -7713,10 +7757,16 @@ }, "node_modules/binary": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "dev": true, "license": "MIT", "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" } }, "node_modules/binary-extensions": { @@ -7741,6 +7791,9 @@ }, "node_modules/bl": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -7757,6 +7810,7 @@ }, "node_modules/bluebird": { "version": "3.4.7", + "dev": true, "license": "MIT" }, "node_modules/bn.js": { @@ -7910,6 +7964,7 @@ }, "node_modules/brace-expansion": { "version": "1.1.18", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8101,6 +8156,9 @@ }, "node_modules/buffer": { "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -8123,6 +8181,7 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -8140,6 +8199,9 @@ }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10" @@ -8157,6 +8219,9 @@ }, "node_modules/buffers": { "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "dev": true, "engines": { "node": ">=0.2.0" } @@ -8587,9 +8652,15 @@ }, "node_modules/chainsaw": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "dev": true, "license": "MIT/X11", "dependencies": { "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" } }, "node_modules/chalk": { @@ -8917,6 +8988,9 @@ }, "node_modules/compress-commons": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "^0.2.13", @@ -8979,6 +9053,7 @@ }, "node_modules/concat-map": { "version": "0.0.1", + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9442,6 +9517,7 @@ }, "node_modules/core-util-is": { "version": "1.0.2", + "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -9516,6 +9592,9 @@ }, "node_modules/crc-32": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, "license": "Apache-2.0", "bin": { "crc32": "bin/crc32.njs" @@ -9526,6 +9605,9 @@ }, "node_modules/crc32-stream": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, "license": "MIT", "dependencies": { "crc-32": "^1.2.0", @@ -10136,7 +10218,10 @@ } }, "node_modules/dayjs": { - "version": "1.11.21", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, "license": "MIT" }, "node_modules/de-indent": { @@ -10803,6 +10888,9 @@ }, "node_modules/duplexer2": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" @@ -10810,10 +10898,16 @@ }, "node_modules/duplexer2/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/duplexer2/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -10827,10 +10921,16 @@ }, "node_modules/duplexer2/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/duplexer2/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -10961,6 +11061,7 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -12055,6 +12156,9 @@ }, "node_modules/exceljs": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "dev": true, "license": "MIT", "dependencies": { "archiver": "^5.0.0", @@ -12352,6 +12456,9 @@ }, "node_modules/fast-csv": { "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "dev": true, "license": "MIT", "dependencies": { "@fast-csv/format": "4.3.5", @@ -12853,6 +12960,9 @@ }, "node_modules/fs-constants": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -12945,6 +13055,7 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -12961,6 +13072,10 @@ }, "node_modules/fstream": { "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dev": true, "license": "ISC", "dependencies": { "graceful-fs": "^4.1.2", @@ -12974,6 +13089,10 @@ }, "node_modules/fstream/node_modules/rimraf": { "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -13148,6 +13267,7 @@ }, "node_modules/glob": { "version": "7.2.3", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -13304,6 +13424,7 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -14003,6 +14124,7 @@ }, "node_modules/ieee754": { "version": "1.2.1", + "dev": true, "funding": [ { "type": "github", @@ -14034,6 +14156,9 @@ }, "node_modules/immediate": { "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, "license": "MIT" }, "node_modules/import-cwd": { @@ -14194,6 +14319,7 @@ }, "node_modules/inflight": { "version": "1.0.6", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -14202,6 +14328,7 @@ }, "node_modules/inherits": { "version": "2.0.4", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -17019,7 +17146,10 @@ } }, "node_modules/jszip": { - "version": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { "lie": "~3.3.0", @@ -17030,10 +17160,16 @@ }, "node_modules/jszip/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/jszip/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -17047,10 +17183,16 @@ }, "node_modules/jszip/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/jszip/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -17336,6 +17478,9 @@ }, "node_modules/lazystream": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, "license": "MIT", "dependencies": { "readable-stream": "^2.0.5" @@ -17346,10 +17491,16 @@ }, "node_modules/lazystream/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -17363,10 +17514,16 @@ }, "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -17439,6 +17596,9 @@ }, "node_modules/lie": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, "license": "MIT", "dependencies": { "immediate": "~3.0.5" @@ -17459,6 +17619,9 @@ }, "node_modules/listenercount": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "dev": true, "license": "ISC" }, "node_modules/load-json-file": { @@ -17558,46 +17721,80 @@ }, "node_modules/lodash.defaults": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, "license": "MIT" }, "node_modules/lodash.groupby": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, "license": "MIT" }, "node_modules/lodash.isfunction": { "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isnil": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isundefined": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -17644,10 +17841,14 @@ }, "node_modules/lodash.union": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", + "dev": true, "license": "MIT" }, "node_modules/log4js": { @@ -18199,6 +18400,7 @@ }, "node_modules/minimatch": { "version": "3.1.5", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -18209,6 +18411,7 @@ }, "node_modules/minimist": { "version": "1.2.8", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18359,6 +18562,7 @@ }, "node_modules/mkdirp": { "version": "0.5.6", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -18708,6 +18912,7 @@ }, "node_modules/normalize-path": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19041,6 +19246,7 @@ }, "node_modules/once": { "version": "1.4.0", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -19271,6 +19477,7 @@ }, "node_modules/pako": { "version": "1.0.11", + "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parallel-transform": { @@ -19421,6 +19628,7 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21128,6 +21336,7 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", + "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -21584,6 +21793,7 @@ }, "node_modules/readable-stream": { "version": "3.6.2", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -21596,6 +21806,9 @@ }, "node_modules/readdir-glob": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.1.0" @@ -21603,6 +21816,9 @@ }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -21610,6 +21826,9 @@ }, "node_modules/readdir-glob/node_modules/minimatch": { "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -22373,6 +22592,7 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", + "dev": true, "funding": [ { "type": "github", @@ -22728,6 +22948,7 @@ }, "node_modules/saxes": { "version": "5.0.1", + "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -23019,6 +23240,7 @@ }, "node_modules/setimmediate": { "version": "1.0.5", + "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -23955,6 +24177,7 @@ }, "node_modules/string_decoder": { "version": "1.3.0", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -24456,6 +24679,9 @@ }, "node_modules/tar-stream": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -24763,6 +24989,7 @@ }, "node_modules/tmp": { "version": "0.2.7", + "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -24948,7 +25175,13 @@ }, "node_modules/traverse": { "version": "0.3.9", - "license": "MIT/X11" + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "dev": true, + "license": "MIT/X11", + "engines": { + "node": "*" + } }, "node_modules/treeify": { "version": "1.1.0", @@ -25685,6 +25918,9 @@ }, "node_modules/unzipper": { "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "dev": true, "license": "MIT", "dependencies": { "big-integer": "^1.6.17", @@ -25701,10 +25937,16 @@ }, "node_modules/unzipper/node_modules/isarray": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, "node_modules/unzipper/node_modules/readable-stream": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -25718,10 +25960,16 @@ }, "node_modules/unzipper/node_modules/safe-buffer": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/unzipper/node_modules/string_decoder": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -25981,6 +26229,7 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "dev": true, "license": "MIT" }, "node_modules/util-extend": { @@ -26022,6 +26271,7 @@ }, "node_modules/uuid": { "version": "8.3.2", + "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -28561,6 +28811,7 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -28620,6 +28871,7 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "dev": true, "license": "MIT" }, "node_modules/xtend": { @@ -28803,6 +29055,9 @@ }, "node_modules/zip-stream": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, "license": "MIT", "dependencies": { "archiver-utils": "^3.0.4", @@ -28815,6 +29070,9 @@ }, "node_modules/zip-stream/node_modules/archiver-utils": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, "license": "MIT", "dependencies": { "glob": "^7.2.3", diff --git a/package.json b/package.json index 349ade5d08..aeab046747 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ }, "dependencies": { "chevrotain": "^6.5.0", - "exceljs": "^4.4.0", "tiny-emitter": "^2.1.0" }, "devDependencies": { @@ -147,6 +146,7 @@ "eslint-plugin-license-header": "^0.6.1", "eslint-plugin-prettier": "^5.2.1", "esm": "^3.2.25", + "exceljs": "^4.4.0", "full-icu": "^1.5.0", "jasmine": "^5.4.0", "jest": "^26.6.3", @@ -627,5 +627,13 @@ "import": "./es/i18n/languages/trTR.mjs", "require": "./commonjs/i18n/languages/trTR.js" } + }, + "peerDependencies": { + "exceljs": "^4.4.0" + }, + "peerDependenciesMeta": { + "exceljs": { + "optional": true + } } } From f7803d8b0aa972c1c4987802d534082735220e87 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 14 Sep 2026 04:46:06 +0000 Subject: [PATCH 16/22] Keep the lockfile change to the dependency reclassification alone 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 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc5ab0f3ac..29fefeb188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10218,7 +10218,7 @@ } }, "node_modules/dayjs": { - "version": "1.11.23", + "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "dev": true, @@ -17146,7 +17146,7 @@ } }, "node_modules/jszip": { - "version": "3.10.2", + "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", "dev": true, From 148f8e664f1a037b42cc731db3d62d46d96339b0 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 14 Sep 2026 04:55:51 +0000 Subject: [PATCH 17/22] Keep exceljs external in the documentation's server bundle 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 --- docs/.vuepress/config.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 5caadb41a7..c28fe12034 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -106,6 +106,17 @@ const DOCS_DEST = process.env.DOCS_DEST || buildConfigOverrides.dest || 'docs/.v const DOCS_HOSTNAME = process.env.DOCS_HOSTNAME || buildConfigOverrides.hostname || 'https://hyperformula.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). From 7f67a1fdd224cfbcb5e6395bbf7760b096516e9a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 14 Sep 2026 04:55:51 +0000 Subject: [PATCH 18/22] Bundle exceljs into the Karma test bundle instead of externalising it 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 --- .config/karma/base.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.config/karma/base.js b/.config/karma/base.js index d66811a35f..840dd727cb 100644 --- a/.config/karma/base.js +++ b/.config/karma/base.js @@ -66,6 +66,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', From 10f30e4e72e8f4966806b5cb525788c914aaae78 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:17 +0000 Subject: [PATCH 19/22] HF-107: Load ExcelJS from its default export when a native import() exposes 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 --- hyperformula/src/io/xlsx/loadExcelJs.ts | 27 ++++++++++++++++++++++++ hyperformula/src/io/xlsx/loadWorkbook.ts | 12 ++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 hyperformula/src/io/xlsx/loadExcelJs.ts 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 index d82172a10c..2bea168b58 100644 --- a/hyperformula/src/io/xlsx/loadWorkbook.ts +++ b/hyperformula/src/io/xlsx/loadWorkbook.ts @@ -5,12 +5,13 @@ import type {Workbook} from 'exceljs' import {UnsupportedFileError} from '../../errors' +import {loadExcelJs} from './loadExcelJs' /** * Loads raw xlsx bytes into an ExcelJS {@link Workbook}. * - * v1 scope: attempt the ExcelJS load and wrap any failure. Byte-signature / - * ZIP-magic / CFB format detection is deliberately out of scope (YAGNI). + * 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) { @@ -18,16 +19,17 @@ export async function loadXlsxWorkbook(data: ArrayBuffer | Uint8Array): Promise< } const bytes = toStandaloneBytes(data) + const ExcelJS = await loadExcelJs() + const workbook = new ExcelJS.Workbook() try { - const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') - const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(bytes.buffer) - return workbook } catch (e) { const detail = e instanceof Error ? e.message : String(e) throw new UnsupportedFileError('unparseable', detail) } + + return workbook } /** From 0f6fb42d9838f13a741f50bbf3ec29574bb2c822 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:18 +0000 Subject: [PATCH 20/22] HF-107: Keep an imported text cell that starts with = or an apostrophe as text Co-Authored-By: Claude Opus 5.5 --- hyperformula/src/io/xlsx/importer.ts | 43 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/hyperformula/src/io/xlsx/importer.ts b/hyperformula/src/io/xlsx/importer.ts index d7ae54ff1f..d422f58b6c 100644 --- a/hyperformula/src/io/xlsx/importer.ts +++ b/hyperformula/src/io/xlsx/importer.ts @@ -13,20 +13,20 @@ import {Sheet, Sheets} from '../../Sheet' * * Pure and synchronous: byte-loading happens upstream in `loadXlsxWorkbook`. * - * v1 mapping (deliberately minimal, see HF-107 task brief): + * 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 (best-effort). - * - `number` | `string` | `boolean` pass through as-is. - * - any other (object-valued: error / rich text / hyperlink) cell falls back - * to `cell.text` so we never emit the raw object. + * - 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. - * - * A full Excel/HF error-token table is out of scope here (deferred). */ export function workbookToSheets(workbook: Workbook): Sheets { const sheets: Sheets = {} @@ -76,15 +76,14 @@ function worksheetToSheet(worksheet: Worksheet): Sheet { * fail to recognize the function name (`_xlfn.XLOOKUP` → `#NAME?`) even when * it natively supports the function. * - * NOTE: this operates on the whole formula string, so a matching substring - * inside a string literal (e.g. `="_xlfn."`) would also be rewritten. That - * edge case is not handled here — accepted best-effort limitation for v1. + * 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} per the v1 mapping rule. */ +/** Maps a single ExcelJS cell to a HyperFormula {@link RawCellContent}. */ function cellToRawContent(cell: Cell): RawCellContent { if (cell.formula != null) { return '=' + stripFunctionPrefixes(cell.formula) @@ -96,13 +95,27 @@ function cellToRawContent(cell: Cell): RawCellContent { return null } - if (value instanceof Date) { + if (value instanceof Date || typeof value === 'number' || typeof value === 'boolean') { return value } - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') { - return value + if (typeof value === 'string') { + return asText(value) } - return cell.text ?? null + 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 } From e976e44f11a2d857d5985110644e7960a8668b1d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:18 +0000 Subject: [PATCH 21/22] HF-107: Export cell values instead of their input, and formulas in the 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 --- hyperformula/src/HyperFormula.ts | 35 ++++++++++--- hyperformula/src/io/xlsx/exporter.ts | 64 +++++++++++++++++++---- hyperformula/src/io/xlsx/formulaSyntax.ts | 44 ++++++++++++++++ 3 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 hyperformula/src/io/xlsx/formulaSyntax.ts diff --git a/hyperformula/src/HyperFormula.ts b/hyperformula/src/HyperFormula.ts index 5cfb6fb0ef..80f672308e 100644 --- a/hyperformula/src/HyperFormula.ts +++ b/hyperformula/src/HyperFormula.ts @@ -57,6 +57,7 @@ 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' @@ -342,7 +343,13 @@ export class HyperFormula implements TypedEmitter { * Can be configured with the optional second parameter that represents a [[ConfigParams]]. * If not specified the engine will be built with the default configuration. * - * v1 scope: no import options, no named expressions, no cell-format detection (see HF-107). + * 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 @@ -373,7 +380,17 @@ export class HyperFormula implements TypedEmitter { public static async buildFromFile(data: ArrayBuffer | Uint8Array, configInput: Partial = {}): Promise { const workbook = await loadXlsxWorkbook(data) const sheets = workbookToSheets(workbook) - return HyperFormula.buildFromSheets(sheets, configInput) + + 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) } /** @@ -1232,9 +1249,11 @@ export class HyperFormula implements TypedEmitter { /** * Exports the current engine state to `.xlsx` file bytes. * - * v1 scope: values and formulas only, no styles/number-formats/merged cells - * (see HF-107). Formulas are serialized exactly as {@link getAllSheetsSerialized} - * would return them. + * 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 * @@ -1252,7 +1271,11 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public async toFile(): Promise { - return sheetsToXlsx(this.getAllSheetsSerialized()) + 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) } /** diff --git a/hyperformula/src/io/xlsx/exporter.ts b/hyperformula/src/io/xlsx/exporter.ts index 1865e5c59a..fb82e9364f 100644 --- a/hyperformula/src/io/xlsx/exporter.ts +++ b/hyperformula/src/io/xlsx/exporter.ts @@ -3,28 +3,37 @@ * 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' /** - * Converts HF's serialized sheets (as returned by - * {@link HyperFormula#getAllSheetsSerialized}) into `.xlsx` file bytes. + * Writes HyperFormula's sheets as `.xlsx` file bytes. * * Pure and async (ExcelJS's writer is async); does not touch a live engine. * - * v1 mapping (deliberately minimal, see HF-107 task brief): - * - a `null`/`undefined` cell is left unset, so it reloads blank (not `0`/`''`). - * - a formula string (`cell[0] === '='`) becomes an ExcelJS formula cell. - * - `number` | `string` | `boolean` | `Date` pass through as-is. + * 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(sheets: Record): Promise { - const ExcelJS = await import(/* webpackMode: "eager" */ 'exceljs') +export async function sheetsToXlsx(formulas: Record, values: Record): Promise { + const ExcelJS = await loadExcelJs() const workbook = new ExcelJS.Workbook() - for (const sheetName of Object.keys(sheets)) { + for (const sheetName of Object.keys(formulas)) { const worksheet = workbook.addWorksheet(sheetName) - const rows = sheets[sheetName] + const rows = formulas[sheetName] + const valueRows = values[sheetName] ?? [] for (let r = 0; r < rows.length; r++) { const row = rows[r] @@ -41,7 +50,7 @@ export async function sheetsToXlsx(sheets: Record): if (typeof cell === 'string' && cell[0] === '=') { xc.value = {formula: cell.slice(1)} } else { - xc.value = cell + xc.value = toExcelValue(valueRows[r]?.[c] ?? null) } } } @@ -54,3 +63,36 @@ export async function sheetsToXlsx(sheets: Record): // 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]) +} From 4ca6c467103281868d248b699979b279b7d8159f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:33:18 +0000 Subject: [PATCH 22/22] HF-107: Correct the file import guide and the changelog entry for xlsx import and export Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +-- docs/README.md | 2 +- docs/guide/building.md | 3 +++ docs/guide/file-import.md | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005622135e..3ffa22857d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +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 built-in `.xlsx` import/export: the `HyperFormula.buildFromFile()` static factory and the `toFile()` instance method, backed by ExcelJS. [#1702](https://github.com/handsontable/hyperformula/pull/1702) -- ExcelJS is declared as an **optional peer dependency**: it is required only by `buildFromFile()` and `toFile()`, is loaded on demand, and is not installed with HyperFormula. Add it with `npm install exceljs` if you use either method. [#1702](https://github.com/handsontable/hyperformula/pull/1702) +- 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/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 d31dbf9a2e..0d421a4dcd 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -25,11 +25,13 @@ HyperFormula. Add it only if you use these two methods: npm install exceljs ``` -Everything else in HyperFormula works without it. Calling `buildFromFile` or -`toFile` without ExcelJS installed fails at the point of the call rather than at -import time, because the library is loaded on demand. In the UMD `full` build the -same applies: ExcelJS is kept out of the bundle and has to be provided as the -global `ExcelJS`. +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 @@ -39,8 +41,11 @@ JavaScript arrays, so you can plug in any third-party parser. 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. Formula import assumes the English (`en`) function dialect. -On export, formulas that use newer or dynamic-array Excel functions are written +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. :::