From a1b9ca98d41ef60ec1fa094e9563835a0509e07f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:34:10 +0000 Subject: [PATCH 01/12] 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 | 129 +++++++++------------------------------------- package.json | 2 +- 2 files changed, 25 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32585c79d..acb3f1c0b 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": { @@ -49,7 +50,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", @@ -176,6 +176,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2344,6 +2345,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2367,6 +2369,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2479,7 +2482,6 @@ "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", @@ -2494,14 +2496,12 @@ "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", @@ -2517,7 +2517,6 @@ "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": { @@ -4078,7 +4077,8 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -4320,6 +4320,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -6309,6 +6310,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6413,6 +6415,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6615,7 +6618,6 @@ "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", @@ -6634,7 +6636,6 @@ "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", @@ -6656,14 +6657,12 @@ "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", @@ -6679,14 +6678,12 @@ "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" @@ -6953,7 +6950,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, "license": "MIT" }, "node_modules/async-each": { @@ -7503,7 +7499,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base": { @@ -7556,7 +7551,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -7617,7 +7611,6 @@ "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" @@ -7637,7 +7630,6 @@ "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", @@ -7675,7 +7667,6 @@ "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", @@ -7687,7 +7678,6 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true, "license": "MIT" }, "node_modules/bn.js": { @@ -7871,7 +7861,6 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8058,6 +8047,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8099,7 +8089,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -8124,7 +8113,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -8148,7 +8136,6 @@ "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" @@ -8172,7 +8159,6 @@ "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" } @@ -8683,7 +8669,6 @@ "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" @@ -8698,6 +8683,7 @@ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -9054,7 +9040,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/comment-parser": { "version": "1.4.1", @@ -9087,7 +9074,6 @@ "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", @@ -9162,7 +9148,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9722,7 +9707,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -9811,7 +9795,6 @@ "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" @@ -9824,7 +9807,6 @@ "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", @@ -10546,7 +10528,6 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "dev": true, "license": "MIT" }, "node_modules/de-indent": { @@ -11343,7 +11324,6 @@ "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" @@ -11353,14 +11333,12 @@ "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", @@ -11376,14 +11354,12 @@ "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" @@ -11548,7 +11524,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -11885,6 +11860,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12222,6 +12198,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12278,6 +12255,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12607,6 +12585,7 @@ "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -12768,7 +12747,6 @@ "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", @@ -13122,7 +13100,6 @@ "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", @@ -13728,7 +13705,6 @@ "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": { @@ -13844,7 +13820,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -13867,7 +13842,6 @@ "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", @@ -13884,7 +13858,6 @@ "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" @@ -14087,7 +14060,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -14268,7 +14240,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -15117,7 +15088,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -15155,7 +15125,6 @@ "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": { @@ -15349,7 +15318,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -15360,7 +15328,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -16451,6 +16418,7 @@ "integrity": "sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^26.6.3", "import-local": "^3.0.2", @@ -18643,7 +18611,6 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { "lie": "~3.3.0", @@ -18656,14 +18623,12 @@ "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", @@ -18679,14 +18644,12 @@ "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" @@ -19023,7 +18986,6 @@ "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" @@ -19036,14 +18998,12 @@ "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", @@ -19059,14 +19019,12 @@ "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" @@ -19153,7 +19111,6 @@ "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" @@ -19180,7 +19137,6 @@ "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": { @@ -19306,42 +19262,36 @@ "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": { @@ -19349,35 +19299,30 @@ "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": { @@ -19441,14 +19386,12 @@ "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", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true, "license": "MIT" }, "node_modules/log4js": { @@ -19596,6 +19539,7 @@ "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^1.0.7", "entities": "~1.1.1", @@ -20081,7 +20025,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -20094,7 +20037,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -20275,7 +20217,6 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -20694,7 +20635,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21092,7 +21032,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -21367,7 +21306,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parallel-transform": { @@ -21554,7 +21492,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -23589,7 +23526,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -24134,7 +24070,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -24149,7 +24084,6 @@ "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" @@ -24159,7 +24093,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -24169,7 +24102,6 @@ "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" @@ -25083,7 +25015,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -25498,7 +25429,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -25834,7 +25764,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -26903,7 +26832,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -27491,7 +27419,6 @@ "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", @@ -27857,7 +27784,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -28083,7 +28009,6 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "dev": true, "license": "MIT/X11", "engines": { "node": "*" @@ -28270,6 +28195,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -28656,6 +28582,7 @@ "integrity": "sha512-oz1765PN+imfz1MlZzSZPtC/tqcwsCyIYA8L47EkRnRW97ztRk83SzMiWLrnChC0vqoYxSU1fcFUDA5gV/ZiPg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28928,7 +28855,6 @@ "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", @@ -28947,14 +28873,12 @@ "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", @@ -28970,14 +28894,12 @@ "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" @@ -29284,7 +29206,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/util-extend": { @@ -29339,7 +29260,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -29466,6 +29386,7 @@ "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-sfc": "2.7.16", "csstype": "^3.1.0" @@ -30323,6 +30244,7 @@ "integrity": "sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", "@webassemblyjs/helper-module-context": "1.9.0", @@ -30398,6 +30320,7 @@ "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -32195,7 +32118,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -32267,7 +32189,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/xtend": { @@ -32442,7 +32363,6 @@ "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", @@ -32457,7 +32377,6 @@ "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 e2c8d29d1..bdba750f9 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ }, "dependencies": { "chevrotain": "^6.5.0", + "exceljs": "^4.4.0", "tiny-emitter": "^2.1.0" }, "devDependencies": { @@ -141,7 +142,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 e3c484122ed3f701a6358657b59a47a2b6a451cf Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:45:42 +0000 Subject: [PATCH 02/12] 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 66a73a2ad..316987cc3 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 4039d4a20..4698a2b59 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' @@ -102,6 +103,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 @@ -183,5 +185,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 000000000..d685d77e6 --- /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 1a27815a4e553a6684dd74db4c322669efd37a00 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 12:51:58 +0000 Subject: [PATCH 03/12] 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 000000000..68af5951c --- /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 000000000..2b821fe35 --- /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 59d0d7795abe4aca2c46b2ea7792f8d6b47a69a0 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:04:08 +0000 Subject: [PATCH 04/12] 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 000000000..943cc05ce --- /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 000000000..a74d405fd --- /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 e5889f3d6824a52a32650428f13c145b92653027 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:13:00 +0000 Subject: [PATCH 05/12] 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 943cc05ce..2fd3998e9 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 a74d405fd..678bce2bb 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 fe185772a1c6a459488187119ec6303b6634de8a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:26:25 +0000 Subject: [PATCH 06/12] 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 566957270..162573c95 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -49,6 +49,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 { @@ -323,6 +325,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 000000000..28d243290 --- /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 bddf58825182a5ec81c1eb17e4a961cc63a3c559 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 13:47:52 +0000 Subject: [PATCH 07/12] 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 162573c95..811e484ab 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -49,6 +49,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' @@ -1057,6 +1058,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 000000000..4adc63afa --- /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 000000000..141149c6e --- /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 000000000..1992d9f34 --- /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 331630c44c487f71cd59e57282f5328980f1097e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:04:07 +0000 Subject: [PATCH 08/12] 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 000000000..174fcfee8 --- /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 ba45e88cef049853f4de60f8404c08d1c38f8f17 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:06:59 +0000 Subject: [PATCH 09/12] 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 1c9d04d27..a3e697df0 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.1.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.1.x/read-excel-file). From 98b89a052762f7be77f401fb43fb4a778d8c511a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:39:20 +0000 Subject: [PATCH 10/12] 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 | 11 ----------- 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(+), 19 deletions(-) diff --git a/.config/webpack/development.js b/.config/webpack/development.js index 029b8246c..fcb32c697 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 a3e697df0..ed677d2e5 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 acb3f1c0b..b44cdac87 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", @@ -3882,16 +3881,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/exceljs": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@types/exceljs/-/exceljs-0.5.3.tgz", - "integrity": "sha512-a0PLZEJGbA4kHHoSS8cQ20ynIv17vCBV1reqsrX5ksQQb077YYvhEKC82lEq6/+mMufhk68KJ5jwugL3DKG8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", diff --git a/package.json b/package.json index bdba750f9..ae01b753a 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,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 4adc63afa..808973889 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 2fd3998e9..d7ae54ff1 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 68af5951c..5e80cf51d 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 812d95567..d8ef6d31b 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 76405f4c700381ec066aac5c05eb2f9e25d13d42 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:48:12 +0000 Subject: [PATCH 11/12] 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 fcb32c697..5602efd5a 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 5e80cf51d..c36dce266 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 64071417bd89abffb12476fbf1aeed630956e53f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 7 Jul 2026 14:52:57 +0000 Subject: [PATCH 12/12] 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 28d243290..000000000 --- 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 d685d77e6..000000000 --- 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 141149c6e..000000000 --- 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 678bce2bb..000000000 --- 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 2b821fe35..000000000 --- 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 174fcfee8..000000000 --- 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 1992d9f34..000000000 --- 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() - }) -})