From a697a2acf3254b20957ba900923261c99bf741b4 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 11 Sep 2026 19:09:31 +0000 Subject: [PATCH 1/9] fix: accept Excel's internal function-name prefixes Excel marks every function added after the original OOXML specification with an internal prefix when it serializes a workbook, so an imported file can contain `_xlfn.IFS(...)` where the user typed `IFS(...)`. The ProcedureName token required the name to start with a letter, so none of the prefixes matched and the formula failed with "Parsing error. Redundant input, expecting EOF but found: (". The prefixes are now an optional part of the ProcedureName and OffsetProcedureName patterns, and are dropped when the token is turned into a function name. Doing this in the lexer rather than by rewriting the formula string keeps the prefixes intact wherever they are not a function name, such as inside a string literal. FormulaParser and ParserWithCaching derive the name through one shared helper, so a prefixed formula and its unprefixed equivalent also produce the same cache hash. Closes #1655 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + docs/guide/file-import.md | 21 +++++++++++++++++++++ src/parser/FormulaParser.ts | 4 ++-- src/parser/LexerConfig.ts | 29 +++++++++++++++++++++++++++-- src/parser/ParserWithCaching.ts | 4 ++-- src/parser/parser-consts.ts | 22 ++++++++++++++++++++++ 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..06efb448b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed the parser rejecting formulas that carry the function-name prefixes Excel writes into `.xlsx` files (`_xlfn.`, `_xlfn._xlws.`, `_xlws.`, `_xlpm.`, `_xludf.`), which made every function added after Excel 2007 (e.g. `IFS`, `XLOOKUP`, `TEXTJOIN`, `FILTER`, `SORT`) fail to parse when a workbook was imported with a library that passes the prefixes through. The prefixes are now ignored, and `getCellFormula()` returns the formula without them. [#1655](https://github.com/handsontable/hyperformula/issues/1655) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 470383da9e..e4e735299d 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -23,6 +23,27 @@ To import CSV files, use a third-party [CSV parser](https://www.npmjs.com/search 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. +### Excel's internal function prefixes + +Excel stores some function names with an internal prefix. It marks every function added after Excel 2007 this way, so a file can contain `_xlfn.IFS(...)` where the user typed `IFS(...)`. The prefixes are an artifact of how Excel saves the file, not part of the function name. + +Which prefixes reach your code depends on the parser you use. ExcelJS passes them through. SheetJS removes `_xlfn.` but keeps `_xlws.`. + +HyperFormula ignores these prefixes, so you can pass the formula straight to the engine: + +| Prefix | Example | +| --- | --- | +| `_xlfn.` | `=_xlfn.IFS(A1>B1,"Pass","Fail")` | +| `_xlfn._xlws.` | `=_xlfn._xlws.FILTER(A1:A9,B1:B9>1)` | +| `_xlws.` | `=_xlws.SORT(A1:A9)` | +| `_xludf.` | `=_xludf.MY_FUNCTION()` | + +HyperFormula also ignores the `_xlpm.` prefix, which Excel writes on `LAMBDA` and `LET` parameter names. HyperFormula does not support `LAMBDA` or `LET`, so a formula that uses one still returns an error. + +[`getCellFormula()`](../api/classes/hyperformula.md#getcellformula) returns the formula without the prefix, so `=_xlfn.IFS(A1>B1,"Pass","Fail")` reads back as `=IFS(A1>B1,"Pass","Fail")`. + +A prefix does not add a function. If HyperFormula does not support the function itself, the cell holds a `#NAME?` error. See the [list of supported functions](built-in-functions.md). + ### Example: Import XLSX files in Node This example uses [ExcelJS](https://www.npmjs.com/package/exceljs) to import XLSX files into HyperFormula. diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index 82c70e1c1d..9783b8c5bd 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -69,6 +69,7 @@ import { ArrayLParen, ArrayRParen, BooleanOp, + canonicalProcedureNameFromToken, CellReference, ColumnRange, ConcatenateOp, @@ -157,8 +158,7 @@ export class FormulaParser extends EmbeddedActionsParser { */ private procedureExpression: AstRule = this.RULE('procedureExpression', () => { const procedureNameToken = this.CONSUME(ProcedureName) as ExtendedToken - const procedureName = procedureNameToken.image.toUpperCase().slice(0, -1) - const canonicalProcedureName = this.lexerConfig.functionMapping[procedureName] ?? procedureName + const canonicalProcedureName = canonicalProcedureNameFromToken(procedureNameToken.image, this.lexerConfig.functionMapping) const args: Ast[] = [] let argument = this.SUBRULE(this.booleanExpressionOrEmpty) diff --git a/src/parser/LexerConfig.ts b/src/parser/LexerConfig.ts index 72fc4bb3b6..9614e831d5 100644 --- a/src/parser/LexerConfig.ts +++ b/src/parser/LexerConfig.ts @@ -9,6 +9,7 @@ import {ParserConfig} from './ParserConfig' import { ALL_WHITESPACE_PATTERN, COLUMN_REFERENCE_PATTERN, + EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN, NON_RESERVED_CHARACTER_PATTERN, ODFF_WHITESPACE_PATTERN, RANGE_OPERATOR, @@ -47,7 +48,28 @@ export const RangeSeparator = createToken({ name: 'RangeSeparator', pattern: new export const ColumnRange = createToken({ name: 'ColumnRange', pattern: new RegExp(`${COLUMN_REFERENCE_PATTERN}${RANGE_OPERATOR}${COLUMN_REFERENCE_PATTERN}`) }) export const RowRange = createToken({ name: 'RowRange', pattern: new RegExp(`${ROW_REFERENCE_PATTERN}${RANGE_OPERATOR}${ROW_REFERENCE_PATTERN}`) }) -export const ProcedureName = createToken({ name: 'ProcedureName', pattern: new RegExp(`([${UNICODE_LETTER_PATTERN}][${NON_RESERVED_CHARACTER_PATTERN}]*)\\(`) }) +export const ProcedureName = createToken({ name: 'ProcedureName', pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})?([${UNICODE_LETTER_PATTERN}][${NON_RESERVED_CHARACTER_PATTERN}]*)\\(`) }) + +const excelInternalFunctionPrefixRegexp = new RegExp(`^(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})`) + +/** + * Reads the canonical function name out of a ProcedureName token. + * + * The token image spans the whole match, so it carries the trailing opening parenthesis and, for a + * formula imported from an .xlsx file, one of the prefixes Excel prepends when it serializes a + * workbook (see EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN). Dropping both here is what lets + * `_xlfn.IFS(A1)` resolve to the same function as `IFS(A1)`. + * + * The prefix is removed before the name is upper-cased, because the prefixes are matched in lower + * case only. + * + * @param image - image of the ProcedureName token, for example `_xlfn.IFS(` + * @param functionMapping - maps a translated function name to its canonical English name + */ +export function canonicalProcedureNameFromToken(image: string, functionMapping: Record): string { + const procedureName = image.slice(0, -1).replace(excelInternalFunctionPrefixRegexp, '').toUpperCase() + return functionMapping[procedureName] ?? procedureName +} const cellReferenceMatcher = new CellReferenceMatcher() export const CellReference = createToken({ @@ -93,7 +115,10 @@ export const buildLexerConfig = (config: ParserConfig): LexerConfig => { const ArrayRowSeparator = createToken({name: 'ArrayRowSep', pattern: config.arrayRowSeparator}) const ArrayColSeparator = createToken({name: 'ArrayColSep', pattern: config.arrayColumnSeparator}) const NumberLiteral = createToken({ name: 'NumberLiteral', pattern: new RegExp(`(([${config.decimalSeparator}]\\d+)|(\\d+([${config.decimalSeparator}]\\d*)?))(e[+-]?\\d+)?`) }) - const OffsetProcedureName = createToken({ name: 'OffsetProcedureName', pattern: new RegExp(offsetProcedureNameLiteral, 'i') }) + // OFFSET has its own token because it has its own grammar rule, so it needs the prefix handling of + // ProcedureName repeated here. The 'i' flag is for the translated OFFSET name and incidentally makes + // the prefix case-insensitive too, which is harmless: Excel only ever writes it in lower case. + const OffsetProcedureName = createToken({ name: 'OffsetProcedureName', pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})?${offsetProcedureNameLiteral}`, 'i') }) let ArgSeparator: TokenType let inject: TokenType[] diff --git a/src/parser/ParserWithCaching.ts b/src/parser/ParserWithCaching.ts index a116aa8e56..08c5c87308 100644 --- a/src/parser/ParserWithCaching.ts +++ b/src/parser/ParserWithCaching.ts @@ -19,6 +19,7 @@ import {Cache} from './Cache' import {FormulaLexer, FormulaParser, ExtendedToken} from './FormulaParser' import { buildLexerConfig, + canonicalProcedureNameFromToken, CellReference, ColumnRange, LexerConfig, @@ -239,8 +240,7 @@ export class ParserWithCaching { hash = hash.concat(cellAddress.hash(true)) } } else if (tokenMatcher(token, ProcedureName)) { - const procedureName = token.image.toUpperCase().slice(0, -1) - const canonicalProcedureName = this.lexerConfig.functionMapping[procedureName] ?? procedureName + const canonicalProcedureName = canonicalProcedureNameFromToken(token.image, this.lexerConfig.functionMapping) hash = hash.concat(canonicalProcedureName, '(') } else if (tokenMatcher(token, ColumnRange)) { const [start, end] = token.image.split(':') diff --git a/src/parser/parser-consts.ts b/src/parser/parser-consts.ts index 2572a8f551..2f8360aaa7 100644 --- a/src/parser/parser-consts.ts +++ b/src/parser/parser-consts.ts @@ -22,6 +22,28 @@ export const ROW_REFERENCE_PATTERN = `(${SHEET_NAME_PATTERN})?\\${ABSOLUTE_OPERA export const R1C1_CELL_REFERENCE_PATTERN = '[rR][0-9]*[cC][0-9]*' export const CELL_REFERENCE_WITH_NEXT_CHARACTER_PATTERN = `(${CELL_REFERENCE_PATTERN})[^${NON_RESERVED_CHARACTER_PATTERN}]` +/** + * Prefixes that Excel prepends to a function name when it serializes a workbook. + * + * Excel marks every function added after the original OOXML specification (~Excel 2007) with one + * of these prefixes in the stored XML, so a file may contain `_xlfn.IFS(...)` where the user typed + * `IFS(...)`. They are serialization artifacts rather than a part of the function name, and other + * spreadsheet engines drop them on import, so HyperFormula accepts and ignores them too. + * + * | Prefix | Meaning | + * |----------------|----------------------------------------------------------| + * | `_xlfn.` | function newer than the OOXML specification | + * | `_xlfn._xlws.` | as above, and callable only in a worksheet | + * | `_xlws.` | callable only in a worksheet | + * | `_xlpm.` | `LAMBDA`/`LET` parameter name | + * | `_xludf.` | user-defined function (a `LAMBDA` stored in Name Manager) | + * + * The alternatives are ordered longest-first so that `_xlfn._xlws.` is consumed whole instead of + * leaving `_xlws.` behind. Excel always writes them in lower case, so they are matched in lower + * case only: an upper-cased spelling is not something Excel can produce. + */ +export const EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN = '_xlfn\\._xlws\\.|_xlfn\\.|_xlws\\.|_xlpm\\.|_xludf\\.' + export const NAMED_EXPRESSION_PATTERN = `[${UNICODE_LETTER_PATTERN}_][${NON_RESERVED_CHARACTER_PATTERN}]*` export const ALL_DIGITS_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] From d7f8e4a65292c2679981d4f0aa7946458c9bfaa0 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 12 Sep 2026 14:23:56 +0000 Subject: [PATCH 2/9] changelog: link the PR instead of the issue, per house convention Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06efb448b5..d780b7beaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- Fixed the parser rejecting formulas that carry the function-name prefixes Excel writes into `.xlsx` files (`_xlfn.`, `_xlfn._xlws.`, `_xlws.`, `_xlpm.`, `_xludf.`), which made every function added after Excel 2007 (e.g. `IFS`, `XLOOKUP`, `TEXTJOIN`, `FILTER`, `SORT`) fail to parse when a workbook was imported with a library that passes the prefixes through. The prefixes are now ignored, and `getCellFormula()` returns the formula without them. [#1655](https://github.com/handsontable/hyperformula/issues/1655) +- Fixed the parser rejecting formulas that carry the function-name prefixes Excel writes into `.xlsx` files (`_xlfn.`, `_xlfn._xlws.`, `_xlws.`, `_xlpm.`, `_xludf.`), which made every function added after Excel 2007 (e.g. `IFS`, `XLOOKUP`, `TEXTJOIN`, `FILTER`, `SORT`) fail to parse when a workbook was imported with a library that passes the prefixes through. The prefixes are now ignored, and `getCellFormula()` returns the formula without them. [#1771](https://github.com/handsontable/hyperformula/pull/1771) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) From fbf730682c73edbefd934d229c69b21dfcd7334c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 12 Sep 2026 15:18:11 +0000 Subject: [PATCH 3/9] fix: share cache hash between a prefixed and unprefixed OFFSET call too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeHashFromTokens only special-cased ProcedureName tokens; OffsetProcedureName is a separate, uncategorized token type (OFFSET has its own grammar rule), so it fell through to the generic branch that hashes the raw token image — meaning _xlfn.OFFSET(...) and OFFSET(...) did NOT share a cache entry, contradicting this fix's own stated design goal. Verified empirically before and after (hashes differ pre-fix, match post-fix) using the parser's own tokenizeFormula(), not a separately built lexer — a second buildLexerConfig() call creates a distinct OffsetProcedureName token-type object, so testing this via an external lexer silently never exercises the real bug (fixed the same latent issue in the existing 'shares one cache entry' spec test). Also adds a cheap leading-underscore guard to canonicalProcedureNameFromToken/ canonicalOffsetProcedureNameFromToken before invoking the prefix-stripping regex, since every prefix starts with '_' and the overwhelming majority of tokens don't have one. Verified as a real, correctness-preserving improvement for the common case with a standalone microbenchmark; a reorder alternative suggested by review was tried and measured to NOT help (and slightly hurt the prefixed case), so it was not adopted. Co-Authored-By: Claude Sonnet 5 --- src/parser/LexerConfig.ts | 36 ++++++++++++++++++++++++++++++--- src/parser/ParserWithCaching.ts | 3 +++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/parser/LexerConfig.ts b/src/parser/LexerConfig.ts index 9614e831d5..e5e47c0cf9 100644 --- a/src/parser/LexerConfig.ts +++ b/src/parser/LexerConfig.ts @@ -51,14 +51,31 @@ export const RowRange = createToken({ name: 'RowRange', pattern: new RegExp(`${R export const ProcedureName = createToken({ name: 'ProcedureName', pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})?([${UNICODE_LETTER_PATTERN}][${NON_RESERVED_CHARACTER_PATTERN}]*)\\(`) }) const excelInternalFunctionPrefixRegexp = new RegExp(`^(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})`) +const UNDERSCORE_CHAR_CODE = '_'.charCodeAt(0) + +/** + * Strips one of Excel's internal function-name prefixes (see EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN) + * off the front of a token image, if present. + * + * Every prefix starts with `_`, and this function is called on every ProcedureName and + * OffsetProcedureName token in every parse, so the common case — a name with no prefix at all — takes + * a plain character check instead of always paying for the regex match-and-fail. + * + * @param nameWithoutTrailingParen - a token image with any trailing `(` already removed + */ +function stripExcelInternalFunctionPrefix(nameWithoutTrailingParen: string): string { + if (nameWithoutTrailingParen.charCodeAt(0) !== UNDERSCORE_CHAR_CODE) { + return nameWithoutTrailingParen + } + return nameWithoutTrailingParen.replace(excelInternalFunctionPrefixRegexp, '') +} /** * Reads the canonical function name out of a ProcedureName token. * * The token image spans the whole match, so it carries the trailing opening parenthesis and, for a * formula imported from an .xlsx file, one of the prefixes Excel prepends when it serializes a - * workbook (see EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN). Dropping both here is what lets - * `_xlfn.IFS(A1)` resolve to the same function as `IFS(A1)`. + * workbook. Dropping both here is what lets `_xlfn.IFS(A1)` resolve to the same function as `IFS(A1)`. * * The prefix is removed before the name is upper-cased, because the prefixes are matched in lower * case only. @@ -67,10 +84,23 @@ const excelInternalFunctionPrefixRegexp = new RegExp(`^(?:${EXCEL_INTERNAL_FUNCT * @param functionMapping - maps a translated function name to its canonical English name */ export function canonicalProcedureNameFromToken(image: string, functionMapping: Record): string { - const procedureName = image.slice(0, -1).replace(excelInternalFunctionPrefixRegexp, '').toUpperCase() + const procedureName = stripExcelInternalFunctionPrefix(image.slice(0, -1)).toUpperCase() return functionMapping[procedureName] ?? procedureName } +/** + * Strips an Excel internal function-name prefix off an OffsetProcedureName token's image. + * + * OffsetProcedureName has no trailing parenthesis to remove (unlike ProcedureName — OFFSET's grammar + * rule consumes `(` separately) and no translation lookup (the token pattern already embeds the + * localized OFFSET name), so it needs only the prefix stripped, not the full canonicalization above. + * + * @param image - image of the OffsetProcedureName token, for example `_xlfn.OFFSET` + */ +export function canonicalOffsetProcedureNameFromToken(image: string): string { + return stripExcelInternalFunctionPrefix(image) +} + const cellReferenceMatcher = new CellReferenceMatcher() export const CellReference = createToken({ name: 'CellReference', diff --git a/src/parser/ParserWithCaching.ts b/src/parser/ParserWithCaching.ts index 08c5c87308..e8f8eaa157 100644 --- a/src/parser/ParserWithCaching.ts +++ b/src/parser/ParserWithCaching.ts @@ -19,6 +19,7 @@ import {Cache} from './Cache' import {FormulaLexer, FormulaParser, ExtendedToken} from './FormulaParser' import { buildLexerConfig, + canonicalOffsetProcedureNameFromToken, canonicalProcedureNameFromToken, CellReference, ColumnRange, @@ -242,6 +243,8 @@ export class ParserWithCaching { } else if (tokenMatcher(token, ProcedureName)) { const canonicalProcedureName = canonicalProcedureNameFromToken(token.image, this.lexerConfig.functionMapping) hash = hash.concat(canonicalProcedureName, '(') + } else if (tokenMatcher(token, this.lexerConfig.OffsetProcedureName)) { + hash = hash.concat(canonicalOffsetProcedureNameFromToken(token.image)) } else if (tokenMatcher(token, ColumnRange)) { const [start, end] = token.image.split(':') const startAddress = columnAddressFromString(start, baseAddress, this.resolveSheetReference) From a2185233d6e4d7eef309eb130027fbb25826301b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 12 Sep 2026 19:04:33 +0000 Subject: [PATCH 4/9] docs: correct two overclaims found by exhaustive review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parser-consts.ts / LexerConfig.ts: the 'prefixes are matched in lower case only' claim is true for ProcedureName but not for OffsetProcedureName, which reuses the prefix pattern inside an already case-insensitive regex (the 'i' flag exists for the translated OFFSET name, pre-dating this fix). Replaced 'harmless' with the actual asymmetry and why it's not worth a dedicated case-sensitive-prefix matcher: OFFSET predates the OOXML cutoff this prefix scheme exists for, so Excel can never emit a prefixed OFFSET call in any case — there is no reachable real input, only hand-typed formulas. - docs/guide/file-import.md: corrected the claim that HyperFormula 'ignores' the _xlpm. prefix. It only strips a prefix in front of a function call; _xlpm. never appears there in a real .xlsx — it always prefixes a bare LAMBDA/LET parameter name, which this fix deliberately does not touch (HF has no LAMBDA/LET, so such a formula is #NAME? either way). - LexerConfig.ts: added {type} JSDoc annotations to match the majority convention already used elsewhere in src/parser/. Co-Authored-By: Claude Sonnet 5 --- docs/guide/file-import.md | 2 +- src/parser/LexerConfig.ts | 17 +++++++++++------ src/parser/parser-consts.ts | 7 +++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index e4e735299d..9dbf290b52 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -38,7 +38,7 @@ HyperFormula ignores these prefixes, so you can pass the formula straight to the | `_xlws.` | `=_xlws.SORT(A1:A9)` | | `_xludf.` | `=_xludf.MY_FUNCTION()` | -HyperFormula also ignores the `_xlpm.` prefix, which Excel writes on `LAMBDA` and `LET` parameter names. HyperFormula does not support `LAMBDA` or `LET`, so a formula that uses one still returns an error. +The `_xlpm.` prefix marks a `LAMBDA`/`LET` parameter name. HyperFormula does not support `LAMBDA` or `LET`, so a formula containing one returns `#NAME?` whether or not the prefix is present. [`getCellFormula()`](../api/classes/hyperformula.md#getcellformula) returns the formula without the prefix, so `=_xlfn.IFS(A1>B1,"Pass","Fail")` reads back as `=IFS(A1>B1,"Pass","Fail")`. diff --git a/src/parser/LexerConfig.ts b/src/parser/LexerConfig.ts index e5e47c0cf9..c0f602c7e7 100644 --- a/src/parser/LexerConfig.ts +++ b/src/parser/LexerConfig.ts @@ -61,7 +61,7 @@ const UNDERSCORE_CHAR_CODE = '_'.charCodeAt(0) * OffsetProcedureName token in every parse, so the common case — a name with no prefix at all — takes * a plain character check instead of always paying for the regex match-and-fail. * - * @param nameWithoutTrailingParen - a token image with any trailing `(` already removed + * @param {string} nameWithoutTrailingParen - a token image with any trailing `(` already removed */ function stripExcelInternalFunctionPrefix(nameWithoutTrailingParen: string): string { if (nameWithoutTrailingParen.charCodeAt(0) !== UNDERSCORE_CHAR_CODE) { @@ -80,8 +80,8 @@ function stripExcelInternalFunctionPrefix(nameWithoutTrailingParen: string): str * The prefix is removed before the name is upper-cased, because the prefixes are matched in lower * case only. * - * @param image - image of the ProcedureName token, for example `_xlfn.IFS(` - * @param functionMapping - maps a translated function name to its canonical English name + * @param {string} image - image of the ProcedureName token, for example `_xlfn.IFS(` + * @param {Record} functionMapping - maps a translated function name to its canonical English name */ export function canonicalProcedureNameFromToken(image: string, functionMapping: Record): string { const procedureName = stripExcelInternalFunctionPrefix(image.slice(0, -1)).toUpperCase() @@ -95,7 +95,7 @@ export function canonicalProcedureNameFromToken(image: string, functionMapping: * rule consumes `(` separately) and no translation lookup (the token pattern already embeds the * localized OFFSET name), so it needs only the prefix stripped, not the full canonicalization above. * - * @param image - image of the OffsetProcedureName token, for example `_xlfn.OFFSET` + * @param {string} image - image of the OffsetProcedureName token, for example `_xlfn.OFFSET` */ export function canonicalOffsetProcedureNameFromToken(image: string): string { return stripExcelInternalFunctionPrefix(image) @@ -146,8 +146,13 @@ export const buildLexerConfig = (config: ParserConfig): LexerConfig => { const ArrayColSeparator = createToken({name: 'ArrayColSep', pattern: config.arrayColumnSeparator}) const NumberLiteral = createToken({ name: 'NumberLiteral', pattern: new RegExp(`(([${config.decimalSeparator}]\\d+)|(\\d+([${config.decimalSeparator}]\\d*)?))(e[+-]?\\d+)?`) }) // OFFSET has its own token because it has its own grammar rule, so it needs the prefix handling of - // ProcedureName repeated here. The 'i' flag is for the translated OFFSET name and incidentally makes - // the prefix case-insensitive too, which is harmless: Excel only ever writes it in lower case. + // ProcedureName repeated here. The 'i' flag exists for the translated OFFSET name (pre-dates this + // prefix support) and applies to the whole pattern, so unlike ProcedureName it also makes the prefix + // case-insensitive: `_XLFN.OFFSET(...)` is accepted here where `_XLFN.SUM(...)` is a parsing error. + // Left as-is rather than given a dedicated case-sensitive-prefix matcher: OFFSET predates the OOXML + // cutoff this whole prefix scheme exists for, so Excel can never actually emit a prefixed OFFSET call + // in any case — the asymmetry has no reachable real input, only hand-typed formulas (see the + // "an upper-cased prefix is accepted on OFFSET, unlike everywhere else" test). const OffsetProcedureName = createToken({ name: 'OffsetProcedureName', pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})?${offsetProcedureNameLiteral}`, 'i') }) let ArgSeparator: TokenType diff --git a/src/parser/parser-consts.ts b/src/parser/parser-consts.ts index 2f8360aaa7..6b0d05104c 100644 --- a/src/parser/parser-consts.ts +++ b/src/parser/parser-consts.ts @@ -39,8 +39,11 @@ export const CELL_REFERENCE_WITH_NEXT_CHARACTER_PATTERN = `(${CELL_REFERENCE_PAT * | `_xludf.` | user-defined function (a `LAMBDA` stored in Name Manager) | * * The alternatives are ordered longest-first so that `_xlfn._xlws.` is consumed whole instead of - * leaving `_xlws.` behind. Excel always writes them in lower case, so they are matched in lower - * case only: an upper-cased spelling is not something Excel can produce. + * leaving `_xlws.` behind. Excel always writes them in lower case, so wherever this pattern is used + * to recognize a *function call* (the `ProcedureName` token in LexerConfig.ts) it is matched in lower + * case only: an upper-cased spelling there is not something Excel can produce. The one exception is + * the `OffsetProcedureName` token, which reuses this pattern inside an already case-insensitive regex + * (for the translated OFFSET name) and so matches an upper-cased prefix too — see its comment. */ export const EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN = '_xlfn\\._xlws\\.|_xlfn\\.|_xlws\\.|_xlpm\\.|_xludf\\.' From baf91755b2bd0964782072429f8bef370fecdd8c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 23 Sep 2026 11:10:09 +0000 Subject: [PATCH 5/9] fix: recognize a prefixed OFFSET only when it is called The prefixed OffsetProcedureName pattern matched `_xlfn.OFFSET` without a following parenthesis, so it split a named expression such as `_xlfn.OFFSET_RATE` into an OFFSET token and `_RATE`, and turned a bare `=_xlfn.OFFSET` into a parsing error instead of #NAME?. The prefixed form now looks ahead for an opening parenthesis, allowing whitespace before it as the grammar does for unprefixed OFFSET. The unprefixed pattern is unchanged. Also correct the IFS example in the file-import guide: it had three arguments and evaluated to #N/A. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/file-import.md | 4 ++-- src/parser/LexerConfig.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index 9dbf290b52..e921684d0e 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -33,14 +33,14 @@ HyperFormula ignores these prefixes, so you can pass the formula straight to the | Prefix | Example | | --- | --- | -| `_xlfn.` | `=_xlfn.IFS(A1>B1,"Pass","Fail")` | +| `_xlfn.` | `=_xlfn.IFS(A1>B1,"Pass",A1<=B1,"Fail")` | | `_xlfn._xlws.` | `=_xlfn._xlws.FILTER(A1:A9,B1:B9>1)` | | `_xlws.` | `=_xlws.SORT(A1:A9)` | | `_xludf.` | `=_xludf.MY_FUNCTION()` | The `_xlpm.` prefix marks a `LAMBDA`/`LET` parameter name. HyperFormula does not support `LAMBDA` or `LET`, so a formula containing one returns `#NAME?` whether or not the prefix is present. -[`getCellFormula()`](../api/classes/hyperformula.md#getcellformula) returns the formula without the prefix, so `=_xlfn.IFS(A1>B1,"Pass","Fail")` reads back as `=IFS(A1>B1,"Pass","Fail")`. +[`getCellFormula()`](../api/classes/hyperformula.md#getcellformula) returns the formula without the prefix, so `=_xlfn.IFS(A1>B1,"Pass",A1<=B1,"Fail")` reads back as `=IFS(A1>B1,"Pass",A1<=B1,"Fail")`. A prefix does not add a function. If HyperFormula does not support the function itself, the cell holds a `#NAME?` error. See the [list of supported functions](built-in-functions.md). diff --git a/src/parser/LexerConfig.ts b/src/parser/LexerConfig.ts index c0f602c7e7..5513024c5d 100644 --- a/src/parser/LexerConfig.ts +++ b/src/parser/LexerConfig.ts @@ -153,7 +153,14 @@ export const buildLexerConfig = (config: ParserConfig): LexerConfig => { // cutoff this whole prefix scheme exists for, so Excel can never actually emit a prefixed OFFSET call // in any case — the asymmetry has no reachable real input, only hand-typed formulas (see the // "an upper-cased prefix is accepted on OFFSET, unlike everywhere else" test). - const OffsetProcedureName = createToken({ name: 'OffsetProcedureName', pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})?${offsetProcedureNameLiteral}`, 'i') }) + // Unlike ProcedureName, this token does not include the '(' itself, so the prefixed form must look + // ahead for one: otherwise it would claim the start of a named expression such as `_xlfn.OFFSET_RATE`, + // which lexes as a single NamedExpression without prefix support. The unprefixed form keeps its + // pre-existing pattern unchanged. + const OffsetProcedureName = createToken({ + name: 'OffsetProcedureName', + pattern: new RegExp(`(?:${EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN})${offsetProcedureNameLiteral}(?=(?:${whitespaceTokenRegexp.source})?\\()|${offsetProcedureNameLiteral}`, 'i'), + }) let ArgSeparator: TokenType let inject: TokenType[] From e5587dbed48539bff3706d6728ee2271b9c711bc Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 22 Oct 2026 15:24:51 +0000 Subject: [PATCH 6/9] docs: shorten the changelog entry to one sentence Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d780b7beaa..a6b03ecce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- Fixed the parser rejecting formulas that carry the function-name prefixes Excel writes into `.xlsx` files (`_xlfn.`, `_xlfn._xlws.`, `_xlws.`, `_xlpm.`, `_xludf.`), which made every function added after Excel 2007 (e.g. `IFS`, `XLOOKUP`, `TEXTJOIN`, `FILTER`, `SORT`) fail to parse when a workbook was imported with a library that passes the prefixes through. The prefixes are now ignored, and `getCellFormula()` returns the formula without them. [#1771](https://github.com/handsontable/hyperformula/pull/1771) +- Fixed the parser rejecting formulas with the function-name prefixes that Excel writes into `.xlsx` files (e.g. `_xlfn.XLOOKUP`), which are now ignored and left out of `getCellFormula()`. [#1771](https://github.com/handsontable/hyperformula/pull/1771) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) From 98db8d1fe51c4180a728cdcb8329d3df4d0ad232 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:32:13 +0000 Subject: [PATCH 7/9] HF-217: Link the public issue in the changelog and describe the prefixes in the parser guide The changelog rules ask for the public issue where one exists. The parser guide said OFFSET is hashed exactly as typed; a prefix is now dropped first. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- hyperformula/dev-docs/PARSER.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d76cf5460e..70fe524313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781) -- Fixed the parser rejecting formulas with the function-name prefixes that Excel writes into `.xlsx` files (e.g. `_xlfn.XLOOKUP`), which are now ignored and left out of `getCellFormula()`. [#1771](https://github.com/handsontable/hyperformula/pull/1771) +- Fixed the parser rejecting formulas with the function-name prefixes that Excel writes into `.xlsx` files (e.g. `_xlfn.XLOOKUP`), which are now ignored and left out of `getCellFormula()`. [#1655](https://github.com/handsontable/hyperformula/issues/1655) ## [3.5.0] - 2026-10-09 diff --git a/hyperformula/dev-docs/PARSER.md b/hyperformula/dev-docs/PARSER.md index a190df5bc6..74b652d85b 100644 --- a/hyperformula/dev-docs/PARSER.md +++ b/hyperformula/dev-docs/PARSER.md @@ -20,7 +20,8 @@ - **The lexer is language-dependent.** Function names, the argument separator, the decimal separator, and error literals all come from the config and the translation package. Never hard-code an English name or an English-locale separator. See [`I18N.md`](I18N.md). - **Parsing and unparsing move together.** A grammar change that `Unparser.ts` does not learn about breaks round-tripping: the formula parses, the engine calculates, and `getCellFormula` returns something the user never typed. Add a round-trip test for every grammar change. - **A function name can look like a cell reference.** `HEX2DEC` contains the cell reference `HEX2`, and `LOG10` is one. A new function with such a name needs a parse test and a round-trip test. -- **The cache is load-bearing.** `ParserWithCaching` does not key on the formula string. `computeHashFromTokens` hashes the lexer tokens with each reference in the AST's address form — relative parts as offsets from the formula's address, `$` parts absolute — and every function name except `OFFSET` made canonical (`OFFSET` has its own token and is hashed as typed); rewritten ASTs are stored and fetched under `computeHashFromAst`. The two must give the same key for the same formula, or `fetchCachedResultForAst` throws `There is no AST with such key in the cache`. A new token kind must hash to the same text in both: `computeHashFromTokens` falls back to the raw token image, so it needs its own branch whenever that image is not canonical, and `computeHashOfAstNode` needs a case for every new AST node type. A token that carries an address must be hashed in that address form, as the existing reference branches do with `cellAddressFromString` and `hash(true)`, never by its raw text — otherwise the same text in another cell reuses an AST built for the first. Anything else the parse result depends on must be in the key too. +- **The cache is load-bearing.** `ParserWithCaching` does not key on the formula string. `computeHashFromTokens` hashes the lexer tokens with each reference in the AST's address form — relative parts as offsets from the formula's address, `$` parts absolute — and every function name except `OFFSET` made canonical (`OFFSET` has its own token and is hashed as typed, less any prefix described below); rewritten ASTs are stored and fetched under `computeHashFromAst`. The two must give the same key for the same formula, or `fetchCachedResultForAst` throws `There is no AST with such key in the cache`. A new token kind must hash to the same text in both: `computeHashFromTokens` falls back to the raw token image, so it needs its own branch whenever that image is not canonical, and `computeHashOfAstNode` needs a case for every new AST node type. A token that carries an address must be hashed in that address form, as the existing reference branches do with `cellAddressFromString` and `hash(true)`, never by its raw text — otherwise the same text in another cell reuses an AST built for the first. Anything else the parse result depends on must be in the key too. +- **A function name can carry an Excel internal prefix.** Excel writes `_xlfn.`, `_xlws.` and similar prefixes into `.xlsx` files (`EXCEL_INTERNAL_FUNCTION_PREFIX_PATTERN` in `parser-consts.ts`). The `ProcedureName` and `OffsetProcedureName` tokens accept one, and `canonicalProcedureNameFromToken` and `canonicalOffsetProcedureNameFromToken` drop it. Read a function name from a token through those helpers, never from the raw image. - **Address arithmetic lives in the `*Address.ts` files.** Do not reimplement relative-to-absolute conversion at a call site. - **A parse failure is a value.** It produces a `ParsingErrorVertex` in the dependency graph, not a thrown exception. One bad formula must not take down the engine. From be91f8f6e08a6b019edbc4076944c4e1e1b615f6 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 01:11:36 +0000 Subject: [PATCH 8/9] HF-217: Correct what the file-import guide says about LAMBDA and about import libraries A LAMBDA called in place is a parsing error, not #NAME?. Which libraries keep the prefixes was not measured, so the guide no longer names them. Co-Authored-By: Claude Opus 5.5 --- docs/guide/file-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index d96836a783..e406593cc9 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -27,7 +27,7 @@ To import XLSX files, use a third-party [XLSX parser](https://www.npmjs.com/sear Excel stores some function names with an internal prefix. It marks every function added after Excel 2007 this way, so a file can contain `_xlfn.IFS(...)` where the user typed `IFS(...)`. The prefixes are an artifact of how Excel saves the file, not part of the function name. -Which prefixes reach your code depends on the parser you use. ExcelJS passes them through. SheetJS removes `_xlfn.` but keeps `_xlws.`. +Whether the prefixes reach your code depends on the parser you use: some pass them through, others remove only some of them. HyperFormula ignores these prefixes, so you can pass the formula straight to the engine: @@ -38,7 +38,7 @@ HyperFormula ignores these prefixes, so you can pass the formula straight to the | `_xlws.` | `=_xlws.SORT(A1:A9)` | | `_xludf.` | `=_xludf.MY_FUNCTION()` | -The `_xlpm.` prefix marks a `LAMBDA`/`LET` parameter name. HyperFormula does not support `LAMBDA` or `LET`, so a formula containing one returns `#NAME?` whether or not the prefix is present. +The `_xlpm.` prefix marks a `LAMBDA`/`LET` parameter name. HyperFormula does not support `LAMBDA` or `LET`, with or without the prefixes: a `LET` formula returns `#NAME?`, and a `LAMBDA` called in place, such as `=_xlfn.LAMBDA(_xlpm.x,_xlpm.x+1)(1)`, is a parsing error. [`getCellFormula()`](../api/classes/hyperformula.md#getcellformula) returns the formula without the prefix, so `=_xlfn.IFS(A1>B1,"Pass",A1<=B1,"Fail")` reads back as `=IFS(A1>B1,"Pass",A1<=B1,"Fail")`. From cc890aa8f8ab01e9579c16b99ea7bb679192c0c2 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 08:21:19 +0000 Subject: [PATCH 9/9] HF-217: Name only the import library whose prefix handling was checked Co-Authored-By: Claude Opus 5.5 --- docs/guide/file-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/file-import.md b/docs/guide/file-import.md index e406593cc9..42af33e2ea 100644 --- a/docs/guide/file-import.md +++ b/docs/guide/file-import.md @@ -27,7 +27,7 @@ To import XLSX files, use a third-party [XLSX parser](https://www.npmjs.com/sear Excel stores some function names with an internal prefix. It marks every function added after Excel 2007 this way, so a file can contain `_xlfn.IFS(...)` where the user typed `IFS(...)`. The prefixes are an artifact of how Excel saves the file, not part of the function name. -Whether the prefixes reach your code depends on the parser you use: some pass them through, others remove only some of them. +Whether the prefixes reach your code depends on the parser you use: ExcelJS, for example, passes them through. HyperFormula ignores these prefixes, so you can pass the formula straight to the engine: