From 5c3df0caa1bf84e6fdb07018cb2a2472d013a524 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 4 Aug 2026 18:18:40 +0100 Subject: [PATCH 01/37] feat: add TAKE function --- CHANGELOG.md | 4 + docs/guide/known-limitations.md | 4 + src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/idID.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + .../categories/lookup-and-reference.ts | 7 ++ src/interpreter/plugin/ArrayPlugin.ts | 74 +++++++++++++++++++ 21 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c4..212e9e2fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added the `TAKE` dynamic-array function. + ## [3.4.0] - 2026-08-10 ### Added diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 7f0e2361d6..aeb9327722 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -51,6 +51,10 @@ a circular reference. * Ordering (including mixed types, empty cells, and text collation) follows HyperFormula's own comparison rules, which honor the `caseSensitive` and `accentSensitive` configuration options. Numbers sort before text, and text before logical values. +### TAKE function + +* When a non-empty `rows` or `columns` argument evaluates to 0, or when the source has no effective rows or columns, `TAKE` returns `#N/A` with the "Empty range not allowed" message. Microsoft Excel returns `#CALC!` for a zero-count empty-array result, but HyperFormula does not support the `#CALC!` error type. + ### OFFSET function HyperFormula resolves the OFFSET function at parse time rather than during evaluation. The parser inspects the arguments and rewrites the expression into a plain cell reference or range. This keeps the dependency graph accurate but imposes several restrictions. diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 830b1c1968..254d650498 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ODPIS.NELIN', T: 'T', + TAKE: 'TAKE', TAN: 'TG', TANH: 'TGH', TBILLEQ: 'TBILLEQ', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 90296fd420..0f5ed9234f 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAFSKRIVNING', T: 'T', + TAKE: 'TAG', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'STATSOBLIGATION', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index daaaa0bf70..9aa3b3cb6e 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'DIA', T: 'T', + TAKE: 'ÜBERNEHMEN', TAN: 'TAN', TANH: 'TANHYP', TBILLEQ: 'TBILLÄQUIV', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732cd..e8286f5da4 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -227,6 +227,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'SWITCH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEQ', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 6fea52e4e2..b41794de15 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -225,6 +225,7 @@ export const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'TOMAR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'LETRA.DE.TEST.EQV.A.BONO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index a3b319af2a..c80e18186e 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'VUOSIPOISTO', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OBLIG.TUOTTOPROS', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a29109053c..19f9573510 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TAUX.ESCOMPTE.R', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index fafc2c4b9b..b8c175e4f6 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÉSZÖ', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'KJEGY.EGYENÉRT', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db6..337e32d573 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -227,6 +227,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'BERALIH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILL.SAMA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 40ec9cf414..64dc5afa9e 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'AMMORT.ANNUO', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'BOT.EQUIV', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 89c0f85f76..721d096f3e 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVS', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEKV', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 73ef99687b..d63edfccd8 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'NEMEN', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SCHATK.OBL', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 003b0fd648..22e181175c 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'PRZEŁĄCZ', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'RENT.EKW.BS', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 408d7f7722..e58a1ffc8c 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SDA', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OTN', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 657b0db373..d2213035cc 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'АСЧ', T: 'Т', + TAKE: 'ВЗЯТЬ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'РАВНОКЧЕК', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f87580087..ef7d340693 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVSKR', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SSVXEKV', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index dc243600d5..79c25f8060 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -225,6 +225,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'YAT', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'HTAHEŞ', diff --git a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index ef6b848d7f..d6089cd0d0 100644 --- a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts @@ -121,6 +121,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=SORT(A1:A10)', '=SORT(A1:B10, 2, -1)'], }, + TAKE: { + category: 'Lookup and reference', + shortDescription: 'Returns specified rows or columns from the beginning or end of an array.', + parameters: [{name: 'array', description: 'The array or range from which to take rows or columns.'}, {name: 'rows', description: 'The number of rows to take; a negative value takes rows from the end. An empty argument keeps all rows.'}, {name: 'columns', description: 'The number of columns to take; a negative value takes columns from the end. When omitted or empty, all columns are kept.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=TAKE(A1:C5, 2)', '=TAKE(A1:C5, -2, -1)', '=TAKE(A1:C5, , 2)'], + }, TRANSPOSE: { category: 'Lookup and reference', shortDescription: 'Transposes the rows and columns of an array.', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 27b47096e0..b392dea546 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -43,6 +43,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ], repeatLastArgs: 1, }, + 'TAKE': { + method: 'take', + sizeOfResultArrayMethod: 'takeArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true}, + ], + vectorizationForbidden: true, + }, 'VSTACK': { method: 'vstack', sizeOfResultArrayMethod: 'vstackArraySize', @@ -166,6 +177,69 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return new ArraySize(width, height) } + /** + * Corresponds to TAKE(array, rows, [columns]). + * + * Returns the requested number of rows and columns from the beginning or end + * of the source array. Syntactically empty dimensions keep the corresponding + * source dimension, while evaluated zero counts produce an empty-range error. + * + * @param {ProcedureAst} ast - The parsed function-call AST node. + * @param {InterpreterState} state - The current interpreter state. + */ + public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + const rowsArg = ast.args[1] + const columnsArg = ast.args[2] + const rowsIsEmpty = rowsArg?.type === AstNodeType.EMPTY + const columnsIsMissingOrEmpty = columnsArg === undefined || columnsArg.type === AstNodeType.EMPTY + + return this.runFunction(ast.args, state, this.metadata('TAKE'), + (range: SimpleRangeValue, rows: number, columns: number | undefined) => { + const sourceHeight = range.height() + const sourceWidth = range.width() + const requestedRows = rowsIsEmpty ? sourceHeight : Math.trunc(rows) + const requestedColumns = columnsIsMissingOrEmpty || columns === undefined ? sourceWidth : Math.trunc(columns) + + if (requestedRows === 0 || requestedColumns === 0 || sourceHeight === 0 || sourceWidth === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + } + + const rowsToTake = Math.min(Math.abs(requestedRows), sourceHeight) + const columnsToTake = Math.min(Math.abs(requestedColumns), sourceWidth) + const startRow = requestedRows > 0 ? 0 : sourceHeight - rowsToTake + const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake + const result = range.data + .slice(startRow, startRow + rowsToTake) + .map(row => row.slice(startColumn, startColumn + columnsToTake)) + + return SimpleRangeValue.onlyValues(result) + } + ) + } + + /** + * Predicts TAKE's maximum result size from the source array. + * + * The requested dimensions can depend on evaluated expressions, so the source + * dimensions are used as a safe upper bound for the spill area. + * + * @param {ProcedureAst} ast - The parsed function-call AST node. + * @param {InterpreterState} state - The current interpreter state. + */ + public takeArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 2 || ast.args.length > 3) { + return ArraySize.error() + } + + const metadata = this.metadata('TAKE') + const sourceSize = this.arraySizeForAst( + ast.args[0], + new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), + ) + + return new ArraySize(sourceSize.width, sourceSize.height) + } + /** * Corresponds to VSTACK(array1, [array2], ...) * From efde710f2ad2ba8f1563cbe21c334dfb2b3e6148 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 4 Aug 2026 21:17:27 +0100 Subject: [PATCH 02/37] docs: align TAKE comments with array plugin style --- src/interpreter/plugin/ArrayPlugin.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index b392dea546..402ace8a4f 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -180,12 +180,11 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Corresponds to TAKE(array, rows, [columns]). * - * Returns the requested number of rows and columns from the beginning or end - * of the source array. Syntactically empty dimensions keep the corresponding - * source dimension, while evaluated zero counts produce an empty-range error. + * Returns rows and columns from the beginning or end of the source array. + * Empty dimensions keep all rows or columns. * - * @param {ProcedureAst} ast - The parsed function-call AST node. - * @param {InterpreterState} state - The current interpreter state. + * @param ast + * @param state */ public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { const rowsArg = ast.args[1] @@ -218,13 +217,11 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } /** - * Predicts TAKE's maximum result size from the source array. - * - * The requested dimensions can depend on evaluated expressions, so the source - * dimensions are used as a safe upper bound for the spill area. + * Calculates the spilled array size of TAKE using the source dimensions as + * the upper bound. * - * @param {ProcedureAst} ast - The parsed function-call AST node. - * @param {InterpreterState} state - The current interpreter state. + * @param ast + * @param state */ public takeArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { if (ast.args.length < 2 || ast.args.length > 3) { From d76e2b4262b9edc1c5d52c9dbcfb0a4a2bec7c29 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 5 Aug 2026 12:11:38 +0100 Subject: [PATCH 03/37] fix: return CALC for empty TAKE results --- src/Cell.ts | 3 +++ src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/idID.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + src/interpreter/plugin/ArrayPlugin.ts | 5 +++-- 19 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Cell.ts b/src/Cell.ts index faab24babe..6f460e9f02 100644 --- a/src/Cell.ts +++ b/src/Cell.ts @@ -23,6 +23,9 @@ import {AddressWithSheet} from './parser/Address' * Possible errors returned by our interpreter. */ export enum ErrorType { + /** Calculation error. */ + CALC = 'CALC', + /** Division by zero. */ DIV_BY_ZERO = 'DIV_BY_ZERO', diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 254d650498..fa310523df 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DĚLENÍ_NULOU!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 0f5ed9234f..587817507b 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIVISION/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 9aa3b3cb6e..b21329940c 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index e8286f5da4..c133d912d0 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index b41794de15..2d918fab16 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' export const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#¡DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index c80e18186e..26685ab18d 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#JAKO/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 19f9573510..00151e2b44 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index b8c175e4f6..f9a95e0839 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#ZÉRÓOSZTÓ!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 337e32d573..afabf7e2ba 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#SIKLUS!', DIV_BY_ZERO: '#BAGI0!', ERROR: '#GALAT!', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 64dc5afa9e..3c8257f40e 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 721d096f3e..20e8a8fd84 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index d63edfccd8..1f3aa00cfb 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DELING.DOOR.0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 22e181175c..2df2445dd2 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYKL!', DIV_BY_ZERO: '#DZIEL/0!', ERROR: '#BŁĄD!', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index e58a1ffc8c..b376ebe035 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index d2213035cc..2134e4e4b5 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#ДЕЛ/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index ef7d340693..765a9d3014 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIVISION/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 79c25f8060..b09020d347 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -7,6 +7,7 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { + CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#SAYI/0!', ERROR: '#ERROR!', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 402ace8a4f..7942680ea8 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -181,7 +181,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * Corresponds to TAKE(array, rows, [columns]). * * Returns rows and columns from the beginning or end of the source array. - * Empty dimensions keep all rows or columns. + * Syntactically empty dimensions keep all rows or columns. Counts that + * evaluate to zero return a #CALC! error. * * @param ast * @param state @@ -200,7 +201,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const requestedColumns = columnsIsMissingOrEmpty || columns === undefined ? sourceWidth : Math.trunc(columns) if (requestedRows === 0 || requestedColumns === 0 || sourceHeight === 0 || sourceWidth === 0) { - return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + return new CellError(ErrorType.CALC, ErrorMessage.EmptyRange) } const rowsToTake = Math.min(Math.abs(requestedRows), sourceHeight) From 2174419574321482dbb316fb295f66af960787c5 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 5 Aug 2026 12:11:43 +0100 Subject: [PATCH 04/37] docs: document TAKE CALC behavior --- CHANGELOG.md | 2 +- docs/guide/known-limitations.md | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 212e9e2fe4..bd72cf8deb 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/), ### Added -- Added the `TAKE` dynamic-array function. +- Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results. ## [3.4.0] - 2026-08-10 diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index aeb9327722..7f0e2361d6 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -51,10 +51,6 @@ a circular reference. * Ordering (including mixed types, empty cells, and text collation) follows HyperFormula's own comparison rules, which honor the `caseSensitive` and `accentSensitive` configuration options. Numbers sort before text, and text before logical values. -### TAKE function - -* When a non-empty `rows` or `columns` argument evaluates to 0, or when the source has no effective rows or columns, `TAKE` returns `#N/A` with the "Empty range not allowed" message. Microsoft Excel returns `#CALC!` for a zero-count empty-array result, but HyperFormula does not support the `#CALC!` error type. - ### OFFSET function HyperFormula resolves the OFFSET function at parse time rather than during evaluation. The parser inspects the arguments and rewrites the expression into a plain cell reference or range. This keeps the dependency graph accurate but imposes several restrictions. From 1f3e435ba3781daa41bc1dbe82286e8589a46bbc Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 6 Aug 2026 22:38:21 +0100 Subject: [PATCH 05/37] docs: link TAKE PR in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd72cf8deb..92a47f304f 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/), ### Added -- Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results. +- Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results. [#1722](https://github.com/handsontable/hyperformula/pull/1722) ## [3.4.0] - 2026-08-10 From a66b330b6edb3e3f5c3d110ad770b7064711cb62 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 7 Aug 2026 16:49:29 +0100 Subject: [PATCH 06/37] fix: refine TAKE argument and error handling --- docs/guide/types-of-errors.md | 3 +- src/error-message.ts | 1 + .../buildFunctionDescriptions.ts | 8 +++-- src/interpreter/plugin/ArrayPlugin.ts | 21 ++++++------ src/interpreter/plugin/FunctionPlugin.ts | 34 +++++++++++++++---- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 7f8546a90f..038415c2ce 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -21,6 +21,7 @@ according to the language settings. | #NUM! | Invalid number | This error arises when your formula contains an invalid number. | | #REF! | Invalid reference | It occurs when a formula contains an invalid reference. It is one of the most common errors users encounter when working with spreadsheets. | | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | +| #CALC! | Calculation error | It occurs when a formula cannot produce a valid array result, such as when TAKE requests zero rows or columns. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | -| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | \ No newline at end of file +| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | diff --git a/src/error-message.ts b/src/error-message.ts index 5e3afdbeab..0d9dfb7b96 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -66,6 +66,7 @@ export class ErrorMessage { public static IncorrectDateTime = 'String does not represent correct DateTime.' public static CharacterCodeBounds = 'Character code out of bounds.' public static NonZero = 'Argument cannot be 0.' + public static ZeroRowOrColumnCount = 'Row or column count cannot be zero.' public static LessThanOne = 'Argument cannot be less than 1.' public static WeekendString = 'Incorrect weekend bitmask string.' public static InvalidRoman = 'Invalid roman numeral.' diff --git a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts index 8d1cdde779..176f2e1e6c 100644 --- a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts +++ b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts @@ -23,7 +23,7 @@ * placeholder. */ -import {FunctionMetadata, FunctionArgument} from '../plugin/FunctionPlugin' +import {FunctionMetadata, FunctionArgument, isFunctionArgumentOptional} from '../plugin/FunctionPlugin' import {CUSTOM_FUNCTION_CATEGORY, FunctionDoc, FunctionListEntry, FunctionDetails, FunctionParameterDescription} from './FunctionDescription' /** Resolves a function's display name: the translation for the active language, or the canonical id as fallback. */ @@ -33,12 +33,14 @@ type TranslateName = (canonicalName: string) => string | undefined export type StructuralMetadata = Pick /** - * Returns whether a parameter may be omitted: it declares `optionalArg`, or it has a `defaultValue`. + * Returns whether a parameter may be omitted. An explicit `optionalArg` + * declaration takes precedence; otherwise, a `defaultValue` makes the + * parameter optional. * * @param {FunctionArgument | undefined} arg - the structural argument metadata, or `undefined` */ export function isParameterOptional(arg: FunctionArgument | undefined): boolean { - return arg?.optionalArg === true || arg?.defaultValue !== undefined + return isFunctionArgumentOptional(arg) } /** diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 7942680ea8..3fe058433e 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -49,8 +49,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche enableArrayArithmeticForArguments: true, parameters: [ {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: true}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: false, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, ], vectorizationForbidden: true, }, @@ -188,19 +188,18 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * @param state */ public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { - const rowsArg = ast.args[1] - const columnsArg = ast.args[2] - const rowsIsEmpty = rowsArg?.type === AstNodeType.EMPTY - const columnsIsMissingOrEmpty = columnsArg === undefined || columnsArg.type === AstNodeType.EMPTY - return this.runFunction(ast.args, state, this.metadata('TAKE'), - (range: SimpleRangeValue, rows: number, columns: number | undefined) => { + (range: SimpleRangeValue, rows: number, columns: number) => { const sourceHeight = range.height() const sourceWidth = range.width() - const requestedRows = rowsIsEmpty ? sourceHeight : Math.trunc(rows) - const requestedColumns = columnsIsMissingOrEmpty || columns === undefined ? sourceWidth : Math.trunc(columns) + const requestedRows = Math.trunc(rows) + const requestedColumns = Math.trunc(columns) + + if (requestedRows === 0 || requestedColumns === 0) { + return new CellError(ErrorType.CALC, ErrorMessage.ZeroRowOrColumnCount) + } - if (requestedRows === 0 || requestedColumns === 0 || sourceHeight === 0 || sourceWidth === 0) { + if (sourceHeight === 0 || sourceWidth === 0) { return new CellError(ErrorType.CALC, ErrorMessage.EmptyRange) } diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts index 810f140479..b1e0b1fa69 100644 --- a/src/interpreter/plugin/FunctionPlugin.ts +++ b/src/interpreter/plugin/FunctionPlugin.ts @@ -200,15 +200,18 @@ export interface FunctionArgument { passSubtype?: boolean, /** - * If an argument is missing, its value defaults to `defaultValue`. + * If an argument is missing and omission is allowed, its value defaults to + * `defaultValue`. Declaring a default makes the argument optional unless + * `optionalArg` is explicitly set to `false`. */ defaultValue?: InternalScalarValue | RawScalarValue, /** - * If set to `true`: - * if an argument is missing, and no `defaultValue` is set, the argument is `undefined` (instead of throwing an error). + * Explicitly controls whether the argument may be omitted. When omitted, + * the presence of `defaultValue` makes the argument optional. * - * This is logically equivalent to setting `defaultValue` to `undefined`. + * If set to `true` without a `defaultValue`, a missing argument is passed as + * `undefined` instead of producing an error. */ optionalArg?: boolean, @@ -233,8 +236,9 @@ export interface FunctionArgument { greaterThan?: number, /** - * If set to `true`, an empty argument is treated as if the argument was not - * provided at all — that is, it falls back to `defaultValue`. + * If set to `true`, a syntactically empty argument falls back to + * `defaultValue`. This does not make the argument optional; `optionalArg` + * controls whether the argument may be omitted. * * By default (`false`), an empty argument is coerced to the zero-value for its * type (`0` for numbers, `FALSE` for booleans, `""` for strings). @@ -249,6 +253,22 @@ export interface FunctionArgument { emptyAsDefault?: boolean, } +/** + * Returns whether a function argument may be omitted. + * + * An explicit `optionalArg` declaration takes precedence. Otherwise, the + * presence of a default value preserves the existing implicit-optional + * behavior. + * + * @param {FunctionArgument | undefined} argument - The argument metadata to inspect. + */ +export function isFunctionArgumentOptional(argument: FunctionArgument | undefined): boolean { + if (argument?.optionalArg !== undefined) { + return argument.optionalArg + } + return argument?.defaultValue !== undefined +} + export type PluginFunctionType = (ast: ProcedureAst, state: InterpreterState) => InterpreterValue export type PluginArraySizeFunctionType = (ast: ProcedureAst, state: InterpreterState) => ArraySize @@ -539,7 +559,7 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck argMetadata?.optionalArg || argMetadata?.defaultValue !== undefined) + const areMissingArgumentsOptional = metadataForMissingArguments.every(isFunctionArgumentOptional) return areMissingArgumentsOptional } From 14da55aff0ebbb3119bf774f0133e2d0c0c19337 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 7 Aug 2026 17:57:03 +0100 Subject: [PATCH 07/37] fix: predict TAKE size from literal counts --- src/interpreter/plugin/ArrayPlugin.ts | 34 +++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 3fe058433e..46e0dca35e 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -6,7 +6,7 @@ import {ArraySize} from '../../ArraySize' import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' -import {AstNodeType, ProcedureAst} from '../../parser' +import {Ast, AstNodeType, ProcedureAst} from '../../parser' import {coerceScalarToBoolean} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' import {InternalScalarValue, InterpreterValue} from '../InterpreterValue' @@ -14,6 +14,27 @@ import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { + /** + * Converts a numeric TAKE count literal into its predicted result dimension. + * + * @param {Ast | undefined} argument - The count argument to inspect before evaluation. + * @returns {number | undefined} The truncated absolute count, or `undefined` when it is not a numeric literal. + */ + private static parseTakeLiteralDimension(argument: Ast | undefined): number | undefined { + if (argument?.type === AstNodeType.NUMBER) { + return Math.abs(Math.trunc(argument.value)) + } + + if ( + (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) + && argument.value.type === AstNodeType.NUMBER + ) { + return Math.abs(Math.trunc(argument.value.value)) + } + + return undefined + } + public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -234,7 +255,16 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), ) - return new ArraySize(sourceSize.width, sourceSize.height) + const literalRows = ArrayPlugin.parseTakeLiteralDimension(ast.args[1]) + const literalColumns = ArrayPlugin.parseTakeLiteralDimension(ast.args[2]) + const height = literalRows === undefined ? sourceSize.height : Math.min(sourceSize.height, literalRows) + const width = literalColumns === undefined ? sourceSize.width : Math.min(sourceSize.width, literalColumns) + + if (height < 1 || width < 1) { + return ArraySize.error() + } + + return new ArraySize(width, height) } /** From 7ed72560ce6858650f214f4b247cba2d8d914df4 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 7 Aug 2026 20:46:44 +0100 Subject: [PATCH 08/37] perf: avoid materializing unused TAKE cells --- src/interpreter/plugin/ArrayPlugin.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 46e0dca35e..b406a37965 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -3,6 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {AbsoluteCellRange} from '../../AbsoluteCellRange' import {ArraySize} from '../../ArraySize' import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' @@ -228,6 +229,18 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const columnsToTake = Math.min(Math.abs(requestedColumns), sourceWidth) const startRow = requestedRows > 0 ? 0 : sourceHeight - rowsToTake const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake + const sourceRange = range.range + + // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. + if (sourceRange !== undefined) { + const resultRange = AbsoluteCellRange.spanFrom( + sourceRange.getAddress(startColumn, startRow), + columnsToTake, + rowsToTake, + ) + return SimpleRangeValue.onlyRange(resultRange, this.dependencyGraph) + } + const result = range.data .slice(startRow, startRow + rowsToTake) .map(row => row.slice(startColumn, startColumn + columnsToTake)) From b902d53bcf31521de73739c9bc7d0000140eee09 Mon Sep 17 00:00:00 2001 From: Kuba Sekowski Date: Mon, 10 Aug 2026 17:40:29 +0200 Subject: [PATCH 09/37] i18n: use Excel's official names for TAKE, VSTACK and HSTACK TAKE shipped with the English name in 10 of 16 packs while 6 carried a translation, and VSTACK/HSTACK were English in all 16. Microsoft localizes all three in most locales, so a user could not type the name their Excel uses. Names taken from Microsoft's localized "Excel functions (alphabetical)" page, one locale at a time. Each row there links to the function's own page using the English slug in the href while the link text is the localized name, so the lookup is exact: WYCINEK Left as English where Microsoft itself does not translate: TAKE, VSTACK and HSTACK in Indonesian, and VSTACK/HSTACK in Swedish. Note that a function's own localized page is not a usable source: for several locales its syntax block still shows the English name even though the prose and argument names are translated (the French page shows "=TAKE(tableau, lignes,[colonnes])" while the product uses PRENDRE). DEV_DOCS records the lookup method and adds the governing policy: ship a localized name only when it can be confirmed against the product, and keep the English name otherwise, since an invented name matches nothing, reads plausibly enough to be typed first, and fails as #NAME?. No changelog entry: TAKE, VSTACK and HSTACK are all still in [Unreleased], so no wrong name has been released. Co-Authored-By: Claude Opus 5 --- DEV_DOCS.md | 36 +++++++++++++++++++++++++++++++++++- src/i18n/languages/csCZ.ts | 6 +++--- src/i18n/languages/daDK.ts | 4 ++-- src/i18n/languages/deDE.ts | 4 ++-- src/i18n/languages/esES.ts | 4 ++-- src/i18n/languages/fiFI.ts | 6 +++--- src/i18n/languages/frFR.ts | 6 +++--- src/i18n/languages/huHU.ts | 6 +++--- src/i18n/languages/itIT.ts | 6 +++--- src/i18n/languages/nbNO.ts | 6 +++--- src/i18n/languages/nlNL.ts | 4 ++-- src/i18n/languages/plPL.ts | 6 +++--- src/i18n/languages/ptPT.ts | 6 +++--- src/i18n/languages/ruRU.ts | 4 ++-- src/i18n/languages/svSE.ts | 2 +- src/i18n/languages/trTR.ts | 6 +++--- 16 files changed, 73 insertions(+), 39 deletions(-) diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 13630fd4cb..431433ed1f 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -159,12 +159,46 @@ Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of th HyperFormula supports internationalization and provides localized function names for all built-in languages. Translation files live in `src/i18n/languages/`. New functions must include translations for all built-in languages. +### Policy: only ship a name you can source + +Add a localized function name **only when you have confirmed that Microsoft Excel ships that exact name in the product**. If you cannot confirm it, keep the English name. + +An unverified translation is worse than the English fallback. The English name at least matches what Excel uses in the locales Microsoft leaves untranslated, and users can look it up; an invented one matches nothing, is what users will type first because it reads plausibly, and silently produces `#NAME?`. It is also hard to spot in review — a made-up name looks exactly like a real one to anyone who does not speak the language. + +Two things this rules out: + +- Translating the English word yourself, or having a tool do it. A dictionary rendering is not what Excel ships: `TAKE` is `WYCINEK` in Polish (not "WEŹ"), `INCLUDI` in Italian, and `ÁTHELYEZ` in Hungarian — none of which a translator would produce from the word "take". +- Guessing from a sibling function. Names within one release batch are not translated consistently: Swedish localizes `TAKE` as `TA` but keeps `VSTACK`/`HSTACK` in English. + +Keeping the English name is a legitimate, complete answer, and it is what Microsoft itself does in several locales. Prefer it over a guess. + When looking for the valid translations for new functions, try these sources: - https://support.microsoft.com/en-us/office/excel-functions-translator-f262d0c0-991c-485b-89b6-32cc8d326889 - http://dolf.trieschnigg.nl/excel/index.php -For languages not officially supported by Microsoft Excel, the two sources above do not apply. For these languages, use Google Sheets as the reference. Switch the `hl` query parameter to the target locale, for example: +### Reading the translations off Microsoft's localized function list + +The two sources above are a manual add-in and a third-party table that only covers older functions, so neither answers "what is `TAKE` called in Czech?". The **alphabetical function list** does, for every locale at once: + +``` +https://support.microsoft.com//office/excel-functions-alphabetical-b3944572-255d-4efb-bb96-c6d90033e188 +``` + +Each row links to the function's own page using the **English slug** in the `href` while the link text is the **localized name**, which makes the lookup exact rather than a guess: + +```html +WYCINEK
(2024) +``` + +So `grep`ping the page for `functions/-function` and reading the anchor text gives the authoritative name. Do this per locale (`pl-pl`, `cs-cz`, `fi-fi`, …) rather than trusting a single translated page. + +Two traps this avoids: + +- **A function's own localized page is not a reliable source.** For several locales the syntax block is still the English one even though the surrounding prose and the argument names are translated — the French page shows `=TAKE(tableau, lignes,[colonnes])` and the Hungarian one `=TAKE(tömb, sorok,[oszlopok])`, while the product actually uses `PRENDRE` and `ÁTHELYEZ`. Reading the function page alone concludes "not translated" and is wrong. +- **Do not translate the name yourself.** A dictionary rendering of the English word is not what Excel ships: `TAKE` is `WYCINEK` in Polish (not "WEŹ"), `INCLUDI` in Italian, `ÁTHELYEZ` in Hungarian, and `TA` in both Swedish and Norwegian. Where the list genuinely keeps the English name — `VSTACK`/`HSTACK` in Swedish and Indonesian, `TAKE` in Indonesian — keep it too. + +For languages not officially supported by Microsoft Excel, none of the sources above apply. For these languages, use Google Sheets as the reference. Switch the `hl` query parameter to the target locale, for example: - https://support.google.com/docs/table/25273?hl=id (Indonesian) diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index fa310523df..e4a62b922b 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'SROVNAT.SVISLE', + HSTACK: 'SROVNAT.VODOROVNĚ', ADDRESS: 'ODKAZ', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ODPIS.NELIN', T: 'T', - TAKE: 'TAKE', + TAKE: 'VZÍT', TAN: 'TG', TANH: 'TGH', TBILLEQ: 'TBILLEQ', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 587817507b..60de6b246d 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'LSTAK', + HSTACK: 'VSTAK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index b21329940c..2a267fba5b 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAPELN', + HSTACK: 'HSTAPELN', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 2d918fab16..f9047cbddd 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -20,8 +20,8 @@ export const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'APILARV', + HSTACK: 'APILARH', ADDRESS: 'DIRECCION', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 26685ab18d..029df4a8f4 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VPINO', + HSTACK: 'HPINO', ADDRESS: 'OSOITE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'VUOSIPOISTO', T: 'T', - TAKE: 'TAKE', + TAKE: 'OTA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OBLIG.TUOTTOPROS', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 00151e2b44..0ca1d7a0e2 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ASSEMB.V', + HSTACK: 'ASSEMB.H', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', - TAKE: 'TAKE', + TAKE: 'PRENDRE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TAUX.ESCOMPTE.R', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index f9a95e0839..e5a46d082f 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'FÜGG.HALMOZÁS', + HSTACK: 'VÍZSZ.HALMOZÁS', ADDRESS: 'CÍM', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÉSZÖ', T: 'T', - TAKE: 'TAKE', + TAKE: 'ÁTHELYEZ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'KJEGY.EGYENÉRT', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 3c8257f40e..789a758bfc 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STACK.VERT', + HSTACK: 'STACK.ORIZ', ADDRESS: 'INDIRIZZO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'AMMORT.ANNUO', T: 'T', - TAKE: 'TAKE', + TAKE: 'INCLUDI', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'BOT.EQUIV', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 20e8a8fd84..28f594b150 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VSTAKK', + HSTACK: 'HSTAKK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVS', T: 'T', - TAKE: 'TAKE', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEKV', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 1f3aa00cfb..838e583055 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'VERT.STAPELEN', + HSTACK: 'HOR.STAPELEN', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 2df2445dd2..7275a746d2 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'STOS.PION', + HSTACK: 'STOS.POZ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'PRZEŁĄCZ', SYD: 'SYD', T: 'T', - TAKE: 'TAKE', + TAKE: 'WYCINEK', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'RENT.EKW.BS', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index b376ebe035..d8e46ad2ee 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'JUNTARV', + HSTACK: 'JUNTARH', ADDRESS: 'ENDEREÇO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SDA', T: 'T', - TAKE: 'TAKE', + TAKE: 'INCLUIR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OTN', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 2134e4e4b5..6dce4df009 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'ВСТОЛБИК', + HSTACK: 'ГСТОЛБИК', ADDRESS: 'АДРЕС', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 765a9d3014..4190105aaa 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVSKR', T: 'T', - TAKE: 'TAKE', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SSVXEKV', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index b09020d347..b490bc2019 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -20,8 +20,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTACK', - HSTACK: 'HSTACK', + VSTACK: 'DÜŞEYYIĞ', + HSTACK: 'YATAYYIĞ', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', @@ -226,7 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'YAT', T: 'T', - TAKE: 'TAKE', + TAKE: 'AL', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'HTAHEŞ', From 417202387b4a9b95e3d11b70968252a83f1df9d5 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 11 Aug 2026 15:26:33 +0100 Subject: [PATCH 10/37] fix: avoid breaking language packs for TAKE --- CHANGELOG.md | 2 +- docs/guide/list-of-differences.md | 1 + docs/guide/types-of-errors.md | 1 - src/Cell.ts | 3 --- src/i18n/languages/csCZ.ts | 1 - src/i18n/languages/daDK.ts | 1 - src/i18n/languages/deDE.ts | 1 - src/i18n/languages/enGB.ts | 1 - src/i18n/languages/esES.ts | 1 - src/i18n/languages/fiFI.ts | 1 - src/i18n/languages/frFR.ts | 1 - src/i18n/languages/huHU.ts | 1 - src/i18n/languages/idID.ts | 1 - src/i18n/languages/itIT.ts | 1 - src/i18n/languages/nbNO.ts | 1 - src/i18n/languages/nlNL.ts | 1 - src/i18n/languages/plPL.ts | 1 - src/i18n/languages/ptPT.ts | 1 - src/i18n/languages/ruRU.ts | 1 - src/i18n/languages/svSE.ts | 1 - src/i18n/languages/trTR.ts | 1 - src/interpreter/plugin/ArrayPlugin.ts | 6 +++--- 22 files changed, 5 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a47f304f..5851706f40 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/), ### Added -- Added the `TAKE` dynamic-array function and the `CALC` error type used for empty-array results. [#1722](https://github.com/handsontable/hyperformula/pull/1722) +- Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) ## [3.4.0] - 2026-08-10 diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index fae264e4bd..e4df0258c5 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -106,6 +106,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | NORMSDIST | =NORMSDIST(0, TRUE()) | 0.5 | Wrong number | Wrong number | | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | +| TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | | INT | =INT(-8.9) | -8 | -9 | -9 | | MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 038415c2ce..92defde208 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -21,7 +21,6 @@ according to the language settings. | #NUM! | Invalid number | This error arises when your formula contains an invalid number. | | #REF! | Invalid reference | It occurs when a formula contains an invalid reference. It is one of the most common errors users encounter when working with spreadsheets. | | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | -| #CALC! | Calculation error | It occurs when a formula cannot produce a valid array result, such as when TAKE requests zero rows or columns. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | | #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | diff --git a/src/Cell.ts b/src/Cell.ts index 6f460e9f02..faab24babe 100644 --- a/src/Cell.ts +++ b/src/Cell.ts @@ -23,9 +23,6 @@ import {AddressWithSheet} from './parser/Address' * Possible errors returned by our interpreter. */ export enum ErrorType { - /** Calculation error. */ - CALC = 'CALC', - /** Division by zero. */ DIV_BY_ZERO = 'DIV_BY_ZERO', diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index e4a62b922b..2e132b159c 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DĚLENÍ_NULOU!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 60de6b246d..d349a14498 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIVISION/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 2a267fba5b..43e1362a36 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index c133d912d0..e8286f5da4 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index f9047cbddd..48df21d5e1 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' export const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#¡DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 029df4a8f4..57491a6fbe 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#JAKO/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 0ca1d7a0e2..a99ffa2402 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e5a46d082f..6095f001bf 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#ZÉRÓOSZTÓ!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index afabf7e2ba..337e32d573 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#SIKLUS!', DIV_BY_ZERO: '#BAGI0!', ERROR: '#GALAT!', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 789a758bfc..43fb67409d 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 28f594b150..9cc2f3c483 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 838e583055..34e27f436b 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DELING.DOOR.0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 7275a746d2..e24b4f8e43 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYKL!', DIV_BY_ZERO: '#DZIEL/0!', ERROR: '#BŁĄD!', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index d8e46ad2ee..3d45eb9d59 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIV/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 6dce4df009..b8b1378211 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#ДЕЛ/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 4190105aaa..7d4ba7b467 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#DIVISION/0!', ERROR: '#ERROR!', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index b490bc2019..9524f793fe 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -7,7 +7,6 @@ import {RawTranslationPackage} from '..' const dictionary: RawTranslationPackage = { errors: { - CALC: '#CALC!', CYCLE: '#CYCLE!', DIV_BY_ZERO: '#SAYI/0!', ERROR: '#ERROR!', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index b406a37965..d6d5121275 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -204,7 +204,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * * Returns rows and columns from the beginning or end of the source array. * Syntactically empty dimensions keep all rows or columns. Counts that - * evaluate to zero return a #CALC! error. + * evaluate to zero return a #N/A error. * * @param ast * @param state @@ -218,11 +218,11 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const requestedColumns = Math.trunc(columns) if (requestedRows === 0 || requestedColumns === 0) { - return new CellError(ErrorType.CALC, ErrorMessage.ZeroRowOrColumnCount) + return new CellError(ErrorType.NA, ErrorMessage.ZeroRowOrColumnCount) } if (sourceHeight === 0 || sourceWidth === 0) { - return new CellError(ErrorType.CALC, ErrorMessage.EmptyRange) + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) } const rowsToTake = Math.min(Math.abs(requestedRows), sourceHeight) From d6708dc7e8f0046a1b376f6084f001b4d90a4ad2 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 11 Aug 2026 15:59:23 +0100 Subject: [PATCH 11/37] fix: enforce TAKE arity locally --- src/interpreter/plugin/ArrayPlugin.ts | 8 +++++++- src/interpreter/plugin/FunctionPlugin.ts | 10 +++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index d6d5121275..1817144264 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -71,7 +71,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche enableArrayArithmeticForArguments: true, parameters: [ {argumentType: FunctionArgumentType.RANGE}, - {argumentType: FunctionArgumentType.NUMBER, optionalArg: false, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, ], vectorizationForbidden: true, @@ -210,6 +210,12 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * @param state */ public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + // The default supports TAKE(array, , columns), but the rows argument + // position must still be present. + if (ast.args.length < 2) { + return new CellError(ErrorType.NA, ErrorMessage.WrongArgNumber) + } + return this.runFunction(ast.args, state, this.metadata('TAKE'), (range: SimpleRangeValue, rows: number, columns: number) => { const sourceHeight = range.height() diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts index b1e0b1fa69..79de6453bb 100644 --- a/src/interpreter/plugin/FunctionPlugin.ts +++ b/src/interpreter/plugin/FunctionPlugin.ts @@ -256,17 +256,13 @@ export interface FunctionArgument { /** * Returns whether a function argument may be omitted. * - * An explicit `optionalArg` declaration takes precedence. Otherwise, the - * presence of a default value preserves the existing implicit-optional - * behavior. + * Arguments explicitly marked as optional and arguments with default values + * may be omitted. * * @param {FunctionArgument | undefined} argument - The argument metadata to inspect. */ export function isFunctionArgumentOptional(argument: FunctionArgument | undefined): boolean { - if (argument?.optionalArg !== undefined) { - return argument.optionalArg - } - return argument?.defaultValue !== undefined + return argument?.optionalArg || argument?.defaultValue !== undefined } export type PluginFunctionType = (ast: ProcedureAst, state: InterpreterState) => InterpreterValue From 3335d993a8a3f010ef488a805eb4cb354e33e407 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 11 Aug 2026 17:21:37 +0100 Subject: [PATCH 12/37] docs: remove unrelated translation policy --- DEV_DOCS.md | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/DEV_DOCS.md b/DEV_DOCS.md index 431433ed1f..13630fd4cb 100644 --- a/DEV_DOCS.md +++ b/DEV_DOCS.md @@ -159,46 +159,12 @@ Descriptions must describe **HyperFormula's** behaviour, not Excel's. Much of th HyperFormula supports internationalization and provides localized function names for all built-in languages. Translation files live in `src/i18n/languages/`. New functions must include translations for all built-in languages. -### Policy: only ship a name you can source - -Add a localized function name **only when you have confirmed that Microsoft Excel ships that exact name in the product**. If you cannot confirm it, keep the English name. - -An unverified translation is worse than the English fallback. The English name at least matches what Excel uses in the locales Microsoft leaves untranslated, and users can look it up; an invented one matches nothing, is what users will type first because it reads plausibly, and silently produces `#NAME?`. It is also hard to spot in review — a made-up name looks exactly like a real one to anyone who does not speak the language. - -Two things this rules out: - -- Translating the English word yourself, or having a tool do it. A dictionary rendering is not what Excel ships: `TAKE` is `WYCINEK` in Polish (not "WEŹ"), `INCLUDI` in Italian, and `ÁTHELYEZ` in Hungarian — none of which a translator would produce from the word "take". -- Guessing from a sibling function. Names within one release batch are not translated consistently: Swedish localizes `TAKE` as `TA` but keeps `VSTACK`/`HSTACK` in English. - -Keeping the English name is a legitimate, complete answer, and it is what Microsoft itself does in several locales. Prefer it over a guess. - When looking for the valid translations for new functions, try these sources: - https://support.microsoft.com/en-us/office/excel-functions-translator-f262d0c0-991c-485b-89b6-32cc8d326889 - http://dolf.trieschnigg.nl/excel/index.php -### Reading the translations off Microsoft's localized function list - -The two sources above are a manual add-in and a third-party table that only covers older functions, so neither answers "what is `TAKE` called in Czech?". The **alphabetical function list** does, for every locale at once: - -``` -https://support.microsoft.com//office/excel-functions-alphabetical-b3944572-255d-4efb-bb96-c6d90033e188 -``` - -Each row links to the function's own page using the **English slug** in the `href` while the link text is the **localized name**, which makes the lookup exact rather than a guess: - -```html -WYCINEK
(2024) -``` - -So `grep`ping the page for `functions/-function` and reading the anchor text gives the authoritative name. Do this per locale (`pl-pl`, `cs-cz`, `fi-fi`, …) rather than trusting a single translated page. - -Two traps this avoids: - -- **A function's own localized page is not a reliable source.** For several locales the syntax block is still the English one even though the surrounding prose and the argument names are translated — the French page shows `=TAKE(tableau, lignes,[colonnes])` and the Hungarian one `=TAKE(tömb, sorok,[oszlopok])`, while the product actually uses `PRENDRE` and `ÁTHELYEZ`. Reading the function page alone concludes "not translated" and is wrong. -- **Do not translate the name yourself.** A dictionary rendering of the English word is not what Excel ships: `TAKE` is `WYCINEK` in Polish (not "WEŹ"), `INCLUDI` in Italian, `ÁTHELYEZ` in Hungarian, and `TA` in both Swedish and Norwegian. Where the list genuinely keeps the English name — `VSTACK`/`HSTACK` in Swedish and Indonesian, `TAKE` in Indonesian — keep it too. - -For languages not officially supported by Microsoft Excel, none of the sources above apply. For these languages, use Google Sheets as the reference. Switch the `hl` query parameter to the target locale, for example: +For languages not officially supported by Microsoft Excel, the two sources above do not apply. For these languages, use Google Sheets as the reference. Switch the `hl` query parameter to the target locale, for example: - https://support.google.com/docs/table/25273?hl=id (Indonesian) From 86a6a937de0701a2e13f2a5852ee56f7f628f74f Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 11 Aug 2026 17:58:32 +0100 Subject: [PATCH 13/37] fix: preserve TAKE result identity --- src/interpreter/plugin/ArrayPlugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 1817144264..85e2815f53 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -244,7 +244,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche columnsToTake, rowsToTake, ) - return SimpleRangeValue.onlyRange(resultRange, this.dependencyGraph) + const result = SimpleRangeValue.onlyRange(resultRange, this.dependencyGraph).data + return SimpleRangeValue.onlyValues(result) } const result = range.data From d7c87b8862f80cc572dc7a17dc212973677a1837 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 11 Aug 2026 23:21:12 +0100 Subject: [PATCH 14/37] fix: reject unbounded TAKE results --- docs/guide/list-of-differences.md | 1 + src/interpreter/plugin/ArrayPlugin.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index e4df0258c5..bd98d2eaca 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -107,6 +107,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | +| TAKE | =TAKE(Data!A:A, , 1) | VALUE because HyperFormula cannot allocate an unbounded direct whole-column result. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | | INT | =INT(-8.9) | -8 | -9 | -9 | | MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 85e2815f53..bf00332963 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -259,7 +259,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. + * the upper bound. Unbounded results are rejected so the dependency graph + * never reserves an infinite spill range. * * @param ast * @param state @@ -280,7 +281,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const height = literalRows === undefined ? sourceSize.height : Math.min(sourceSize.height, literalRows) const width = literalColumns === undefined ? sourceSize.width : Math.min(sourceSize.width, literalColumns) - if (height < 1 || width < 1) { + if (!Number.isFinite(height) || !Number.isFinite(width) || height < 1 || width < 1) { return ArraySize.error() } From f34f6a8a79fa2c9c46a82694e0cf66be5b64010f Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 12 Aug 2026 13:22:04 +0100 Subject: [PATCH 15/37] fix: size TAKE from numeric text literals --- src/interpreter/plugin/ArrayPlugin.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index bf00332963..f9050960fd 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -10,22 +10,30 @@ import {ErrorMessage} from '../../error-message' import {Ast, AstNodeType, ProcedureAst} from '../../parser' import {coerceScalarToBoolean} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' -import {InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Converts a numeric TAKE count literal into its predicted result dimension. + * Converts a numeric or numeric-text TAKE count literal into its predicted result dimension. * * @param {Ast | undefined} argument - The count argument to inspect before evaluation. - * @returns {number | undefined} The truncated absolute count, or `undefined` when it is not a numeric literal. + * @returns {number | undefined} The truncated absolute count, or `undefined` when it is not a supported literal. */ - private static parseTakeLiteralDimension(argument: Ast | undefined): number | undefined { + private parseTakeLiteralDimension(argument: Ast | undefined): number | undefined { if (argument?.type === AstNodeType.NUMBER) { return Math.abs(Math.trunc(argument.value)) } + if (argument?.type === AstNodeType.STRING) { + const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value) + if (coercedValue === undefined) { + return undefined + } + return Math.abs(Math.trunc(getRawValue(coercedValue))) + } + if ( (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) && argument.value.type === AstNodeType.NUMBER @@ -276,8 +284,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), ) - const literalRows = ArrayPlugin.parseTakeLiteralDimension(ast.args[1]) - const literalColumns = ArrayPlugin.parseTakeLiteralDimension(ast.args[2]) + const literalRows = this.parseTakeLiteralDimension(ast.args[1]) + const literalColumns = this.parseTakeLiteralDimension(ast.args[2]) const height = literalRows === undefined ? sourceSize.height : Math.min(sourceSize.height, literalRows) const width = literalColumns === undefined ? sourceSize.width : Math.min(sourceSize.width, literalColumns) From a18429c5805c6f8dbcc6c97257fc71546702e11d Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 12 Aug 2026 14:22:42 +0100 Subject: [PATCH 16/37] fix: preserve TAKE coercion errors --- src/interpreter/plugin/ArrayPlugin.ts | 34 +++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index f9050960fd..a10a8e4fdf 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -14,34 +14,39 @@ import {getRawValue, InternalScalarValue, InterpreterValue} from '../Interpreter import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +type TakeLiteralDimension = + | {kind: 'value', value: number} + | {kind: 'invalid'} + | {kind: 'unresolved'} + export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Converts a numeric or numeric-text TAKE count literal into its predicted result dimension. + * Classifies a TAKE count for static result-size prediction. * * @param {Ast | undefined} argument - The count argument to inspect before evaluation. - * @returns {number | undefined} The truncated absolute count, or `undefined` when it is not a supported literal. + * @returns {TakeLiteralDimension} The literal value, an invalid-literal marker, or an unresolved marker. */ - private parseTakeLiteralDimension(argument: Ast | undefined): number | undefined { + private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { if (argument?.type === AstNodeType.NUMBER) { - return Math.abs(Math.trunc(argument.value)) + return {kind: 'value', value: Math.abs(Math.trunc(argument.value))} } if (argument?.type === AstNodeType.STRING) { const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value) if (coercedValue === undefined) { - return undefined + return {kind: 'invalid'} } - return Math.abs(Math.trunc(getRawValue(coercedValue))) + return {kind: 'value', value: Math.abs(Math.trunc(getRawValue(coercedValue)))} } if ( (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) && argument.value.type === AstNodeType.NUMBER ) { - return Math.abs(Math.trunc(argument.value.value)) + return {kind: 'value', value: Math.abs(Math.trunc(argument.value.value))} } - return undefined + return {kind: 'unresolved'} } public static implementedFunctions: ImplementedFunctions = { @@ -284,10 +289,15 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), ) - const literalRows = this.parseTakeLiteralDimension(ast.args[1]) - const literalColumns = this.parseTakeLiteralDimension(ast.args[2]) - const height = literalRows === undefined ? sourceSize.height : Math.min(sourceSize.height, literalRows) - const width = literalColumns === undefined ? sourceSize.width : Math.min(sourceSize.width, literalColumns) + const rowDimension = this.parseTakeLiteralDimension(ast.args[1]) + const columnDimension = this.parseTakeLiteralDimension(ast.args[2]) + + if (rowDimension.kind === 'invalid' || columnDimension.kind === 'invalid') { + return ArraySize.error() + } + + const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height + const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width if (!Number.isFinite(height) || !Number.isFinite(width) || height < 1 || width < 1) { return ArraySize.error() From 4459034087d68290cdcc639aa9e2241131b9b943 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 17 Aug 2026 14:11:23 +0100 Subject: [PATCH 17/37] fix: align TAKE sizing with Excel --- docs/guide/known-limitations.md | 7 ++ docs/guide/list-of-differences.md | 2 +- src/interpreter/plugin/ArrayPlugin.ts | 111 ++++++++++++++++++++++---- 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 7f0e2361d6..1aaa78461c 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -43,6 +43,13 @@ a circular reference. * Comparison of values follows HyperFormula's own equality rules, which honor the `caseSensitive` and `accentSensitive` configuration options. By default comparison is case-insensitive. * When `ExactlyOnce` is TRUE and no row or column occurs exactly once, `UNIQUE` returns a `#N/A` error (the result would otherwise be empty). + +### TAKE function + +* A whole-column result can spill only from the first row of the source sheet, and a whole-row result can spill only from its first column. Cross-sheet whole-column and whole-row results return `#SPILL!`, matching Excel. + +* When a row or column count depends on a cell or another non-constant expression, TAKE reserves the corresponding source dimension as an upper bound. The reserved footprint can therefore be larger than the values returned at runtime; an error result, including a count that evaluates to zero, fills that footprint. + ### SORT function * The `SortIndex` argument accepts a single key only. Multi-key sorting through an array constant (for example `=SORT(A1:B9, {1,2})`) is not supported; sort by one column or row at a time. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index bd98d2eaca..a30792ac15 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -107,7 +107,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | -| TAKE | =TAKE(Data!A:A, , 1) | VALUE because HyperFormula cannot allocate an unbounded direct whole-column result. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | +| TAKE | =TAKE(Data!A:A, , 1) | SPILL | No such function. | SPILL | | INT | =INT(-8.9) | -8 | -9 | -9 | | MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index a10a8e4fdf..a0aaef68fd 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -21,14 +21,15 @@ type TakeLiteralDimension = export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Classifies a TAKE count for static result-size prediction. + * Evaluates the constant subset of TAKE count expressions needed for static + * result-size prediction. * - * @param {Ast | undefined} argument - The count argument to inspect before evaluation. - * @returns {TakeLiteralDimension} The literal value, an invalid-literal marker, or an unresolved marker. + * @param {Ast | undefined} argument - The count expression to inspect before evaluation. + * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. */ - private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralDimension { if (argument?.type === AstNodeType.NUMBER) { - return {kind: 'value', value: Math.abs(Math.trunc(argument.value))} + return {kind: 'value', value: argument.value} } if (argument?.type === AstNodeType.STRING) { @@ -36,19 +37,74 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche if (coercedValue === undefined) { return {kind: 'invalid'} } - return {kind: 'value', value: Math.abs(Math.trunc(getRawValue(coercedValue)))} + return {kind: 'value', value: getRawValue(coercedValue)} } - if ( - (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) - && argument.value.type === AstNodeType.NUMBER - ) { - return {kind: 'value', value: Math.abs(Math.trunc(argument.value.value))} + if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { + const dimension = this.parseTakeLiteralNumber(argument.value) + if (dimension.kind !== 'value') { + return dimension + } + return {kind: 'value', value: argument.type === AstNodeType.MINUS_UNARY_OP ? -dimension.value : dimension.value} + } + + if (argument?.type === AstNodeType.PARENTHESIS) { + return this.parseTakeLiteralNumber(argument.expression) + } + + if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { + if (argument.procedureName === 'TRUE') { + return {kind: 'value', value: 1} + } + if (argument.procedureName === 'FALSE') { + return {kind: 'value', value: 0} + } + } + + if (argument?.type === AstNodeType.PLUS_OP) { + const left = this.parseTakeLiteralNumber(argument.left) + const right = this.parseTakeLiteralNumber(argument.right) + if (left.kind === 'invalid' || right.kind === 'invalid') { + return {kind: 'invalid'} + } + if (left.kind === 'value' && right.kind === 'value') { + return {kind: 'value', value: left.value + right.value} + } } return {kind: 'unresolved'} } + /** + * Converts a statically resolved TAKE count into its output dimension. + * + * @param {Ast | undefined} argument - The count expression to classify. + * @returns {TakeLiteralDimension} The non-negative truncated dimension or the unresolved classification. + */ + private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + const dimension = this.parseTakeLiteralNumber(argument) + return dimension.kind === 'value' + ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} + : dimension + } + + /** + * Resolves a direct TAKE source reference without evaluating its values. + * + * @param {Ast} argument - The source expression to inspect. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {AbsoluteCellRange | undefined} The source range, or `undefined` for a computed array. + */ + private takeSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { + if (argument.type === AstNodeType.PARENTHESIS) { + return this.takeSourceRange(argument.expression, state) + } + if (argument.type === AstNodeType.CELL_RANGE || argument.type === AstNodeType.COLUMN_RANGE || argument.type === AstNodeType.ROW_RANGE) { + return AbsoluteCellRange.fromAstOrUndef(argument, state.formulaAddress) + } + return undefined + } + public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -250,6 +306,18 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake const sourceRange = range.range + if (sourceRange !== undefined) { + const leavesHeightUnbounded = !Number.isFinite(sourceRange.height()) && !Number.isFinite(requestedRows) + const leavesWidthUnbounded = !Number.isFinite(sourceRange.width()) && !Number.isFinite(requestedColumns) + const crossesSheets = sourceRange.sheet !== state.formulaAddress.sheet + const startsBelowFirstRow = leavesHeightUnbounded && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = leavesWidthUnbounded && state.formulaAddress.col !== 0 + + if ((leavesHeightUnbounded || leavesWidthUnbounded) && (crossesSheets || startsBelowFirstRow || startsRightOfFirstColumn)) { + return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) + } + } + // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. if (sourceRange !== undefined) { const resultRange = AbsoluteCellRange.spanFrom( @@ -272,8 +340,9 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. Unbounded results are rejected so the dependency graph - * never reserves an infinite spill range. + * the upper bound. A same-sheet whole-column or whole-row result can retain + * its unbounded dimension when its formula starts at the corresponding sheet + * edge; other unbounded results are evaluated as scalar errors. * * @param ast * @param state @@ -298,8 +367,22 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width + const sourceRange = this.takeSourceRange(ast.args[0], state) + const canSpillUnboundedHeight = !Number.isFinite(height) + && sourceRange !== undefined + && sourceRange.sheet === state.formulaAddress.sheet + && state.formulaAddress.row === 0 + const canSpillUnboundedWidth = !Number.isFinite(width) + && sourceRange !== undefined + && sourceRange.sheet === state.formulaAddress.sheet + && state.formulaAddress.col === 0 - if (!Number.isFinite(height) || !Number.isFinite(width) || height < 1 || width < 1) { + if ( + (!Number.isFinite(height) && !canSpillUnboundedHeight) + || (!Number.isFinite(width) && !canSpillUnboundedWidth) + || height < 1 + || width < 1 + ) { return ArraySize.error() } From 5ca25fcded973098073327d63bf830ab5c42b6ee Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 17 Aug 2026 14:35:01 +0100 Subject: [PATCH 18/37] Revert "fix: align TAKE sizing with Excel" This reverts commit 4459034087d68290cdcc639aa9e2241131b9b943. --- docs/guide/known-limitations.md | 7 -- docs/guide/list-of-differences.md | 2 +- src/interpreter/plugin/ArrayPlugin.ts | 111 ++++---------------------- 3 files changed, 15 insertions(+), 105 deletions(-) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 1aaa78461c..7f0e2361d6 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -43,13 +43,6 @@ a circular reference. * Comparison of values follows HyperFormula's own equality rules, which honor the `caseSensitive` and `accentSensitive` configuration options. By default comparison is case-insensitive. * When `ExactlyOnce` is TRUE and no row or column occurs exactly once, `UNIQUE` returns a `#N/A` error (the result would otherwise be empty). - -### TAKE function - -* A whole-column result can spill only from the first row of the source sheet, and a whole-row result can spill only from its first column. Cross-sheet whole-column and whole-row results return `#SPILL!`, matching Excel. - -* When a row or column count depends on a cell or another non-constant expression, TAKE reserves the corresponding source dimension as an upper bound. The reserved footprint can therefore be larger than the values returned at runtime; an error result, including a count that evaluates to zero, fills that footprint. - ### SORT function * The `SortIndex` argument accepts a single key only. Multi-key sorting through an array constant (for example `=SORT(A1:B9, {1,2})`) is not supported; sort by one column or row at a time. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index a30792ac15..bd98d2eaca 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -107,7 +107,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | -| TAKE | =TAKE(Data!A:A, , 1) | SPILL | No such function. | SPILL | +| TAKE | =TAKE(Data!A:A, , 1) | VALUE because HyperFormula cannot allocate an unbounded direct whole-column result. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | | INT | =INT(-8.9) | -8 | -9 | -9 | | MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index a0aaef68fd..a10a8e4fdf 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -21,15 +21,14 @@ type TakeLiteralDimension = export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Evaluates the constant subset of TAKE count expressions needed for static - * result-size prediction. + * Classifies a TAKE count for static result-size prediction. * - * @param {Ast | undefined} argument - The count expression to inspect before evaluation. - * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. + * @param {Ast | undefined} argument - The count argument to inspect before evaluation. + * @returns {TakeLiteralDimension} The literal value, an invalid-literal marker, or an unresolved marker. */ - private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralDimension { + private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { if (argument?.type === AstNodeType.NUMBER) { - return {kind: 'value', value: argument.value} + return {kind: 'value', value: Math.abs(Math.trunc(argument.value))} } if (argument?.type === AstNodeType.STRING) { @@ -37,74 +36,19 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche if (coercedValue === undefined) { return {kind: 'invalid'} } - return {kind: 'value', value: getRawValue(coercedValue)} + return {kind: 'value', value: Math.abs(Math.trunc(getRawValue(coercedValue)))} } - if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { - const dimension = this.parseTakeLiteralNumber(argument.value) - if (dimension.kind !== 'value') { - return dimension - } - return {kind: 'value', value: argument.type === AstNodeType.MINUS_UNARY_OP ? -dimension.value : dimension.value} - } - - if (argument?.type === AstNodeType.PARENTHESIS) { - return this.parseTakeLiteralNumber(argument.expression) - } - - if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { - if (argument.procedureName === 'TRUE') { - return {kind: 'value', value: 1} - } - if (argument.procedureName === 'FALSE') { - return {kind: 'value', value: 0} - } - } - - if (argument?.type === AstNodeType.PLUS_OP) { - const left = this.parseTakeLiteralNumber(argument.left) - const right = this.parseTakeLiteralNumber(argument.right) - if (left.kind === 'invalid' || right.kind === 'invalid') { - return {kind: 'invalid'} - } - if (left.kind === 'value' && right.kind === 'value') { - return {kind: 'value', value: left.value + right.value} - } + if ( + (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) + && argument.value.type === AstNodeType.NUMBER + ) { + return {kind: 'value', value: Math.abs(Math.trunc(argument.value.value))} } return {kind: 'unresolved'} } - /** - * Converts a statically resolved TAKE count into its output dimension. - * - * @param {Ast | undefined} argument - The count expression to classify. - * @returns {TakeLiteralDimension} The non-negative truncated dimension or the unresolved classification. - */ - private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { - const dimension = this.parseTakeLiteralNumber(argument) - return dimension.kind === 'value' - ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} - : dimension - } - - /** - * Resolves a direct TAKE source reference without evaluating its values. - * - * @param {Ast} argument - The source expression to inspect. - * @param {InterpreterState} state - The formula state used to resolve relative addresses. - * @returns {AbsoluteCellRange | undefined} The source range, or `undefined` for a computed array. - */ - private takeSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { - if (argument.type === AstNodeType.PARENTHESIS) { - return this.takeSourceRange(argument.expression, state) - } - if (argument.type === AstNodeType.CELL_RANGE || argument.type === AstNodeType.COLUMN_RANGE || argument.type === AstNodeType.ROW_RANGE) { - return AbsoluteCellRange.fromAstOrUndef(argument, state.formulaAddress) - } - return undefined - } - public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -306,18 +250,6 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake const sourceRange = range.range - if (sourceRange !== undefined) { - const leavesHeightUnbounded = !Number.isFinite(sourceRange.height()) && !Number.isFinite(requestedRows) - const leavesWidthUnbounded = !Number.isFinite(sourceRange.width()) && !Number.isFinite(requestedColumns) - const crossesSheets = sourceRange.sheet !== state.formulaAddress.sheet - const startsBelowFirstRow = leavesHeightUnbounded && state.formulaAddress.row !== 0 - const startsRightOfFirstColumn = leavesWidthUnbounded && state.formulaAddress.col !== 0 - - if ((leavesHeightUnbounded || leavesWidthUnbounded) && (crossesSheets || startsBelowFirstRow || startsRightOfFirstColumn)) { - return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) - } - } - // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. if (sourceRange !== undefined) { const resultRange = AbsoluteCellRange.spanFrom( @@ -340,9 +272,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. A same-sheet whole-column or whole-row result can retain - * its unbounded dimension when its formula starts at the corresponding sheet - * edge; other unbounded results are evaluated as scalar errors. + * the upper bound. Unbounded results are rejected so the dependency graph + * never reserves an infinite spill range. * * @param ast * @param state @@ -367,22 +298,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width - const sourceRange = this.takeSourceRange(ast.args[0], state) - const canSpillUnboundedHeight = !Number.isFinite(height) - && sourceRange !== undefined - && sourceRange.sheet === state.formulaAddress.sheet - && state.formulaAddress.row === 0 - const canSpillUnboundedWidth = !Number.isFinite(width) - && sourceRange !== undefined - && sourceRange.sheet === state.formulaAddress.sheet - && state.formulaAddress.col === 0 - if ( - (!Number.isFinite(height) && !canSpillUnboundedHeight) - || (!Number.isFinite(width) && !canSpillUnboundedWidth) - || height < 1 - || width < 1 - ) { + if (!Number.isFinite(height) || !Number.isFinite(width) || height < 1 || width < 1) { return ArraySize.error() } From cb5bc3c95351f4eaf39c2c7145c9879178c85919 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 17 Aug 2026 19:54:49 +0100 Subject: [PATCH 19/37] fix: align TAKE result sizing with Excel --- docs/guide/list-of-differences.md | 2 +- src/interpreter/plugin/ArrayPlugin.ts | 124 +++++++++++++++++++++----- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index bd98d2eaca..11433559d6 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -107,7 +107,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | -| TAKE | =TAKE(Data!A:A, , 1) | VALUE because HyperFormula cannot allocate an unbounded direct whole-column result. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | +| TAKE | =TAKE(Data!A:A, , 1) | Spills the whole column from row 1; returns SPILL below row 1. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | | INT | =INT(-8.9) | -8 | -9 | -9 | | MOD | =MOD(-10, 3) | -1 | 2 | 2 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index a10a8e4fdf..13876bc65b 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -21,14 +21,16 @@ type TakeLiteralDimension = export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Classifies a TAKE count for static result-size prediction. + * Evaluates the dependency-free subset of TAKE count expressions used for + * static result-size prediction. * - * @param {Ast | undefined} argument - The count argument to inspect before evaluation. - * @returns {TakeLiteralDimension} The literal value, an invalid-literal marker, or an unresolved marker. + * @param {Ast | undefined} argument - The count expression to inspect before evaluation. + * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. + * @internal */ - private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralDimension { if (argument?.type === AstNodeType.NUMBER) { - return {kind: 'value', value: Math.abs(Math.trunc(argument.value))} + return {kind: 'value', value: argument.value} } if (argument?.type === AstNodeType.STRING) { @@ -36,19 +38,78 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche if (coercedValue === undefined) { return {kind: 'invalid'} } - return {kind: 'value', value: Math.abs(Math.trunc(getRawValue(coercedValue)))} + return {kind: 'value', value: getRawValue(coercedValue)} + } + + if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { + const dimension = this.parseTakeLiteralNumber(argument.value) + if (dimension.kind !== 'value') { + return dimension + } + return {kind: 'value', value: argument.type === AstNodeType.MINUS_UNARY_OP ? -dimension.value : dimension.value} + } + + if (argument?.type === AstNodeType.PARENTHESIS) { + return this.parseTakeLiteralNumber(argument.expression) + } + + if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { + if (argument.procedureName === 'TRUE') { + return {kind: 'value', value: 1} + } + if (argument.procedureName === 'FALSE') { + return {kind: 'value', value: 0} + } } - if ( - (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) - && argument.value.type === AstNodeType.NUMBER - ) { - return {kind: 'value', value: Math.abs(Math.trunc(argument.value.value))} + if (argument?.type === AstNodeType.PLUS_OP) { + const left = this.parseTakeLiteralNumber(argument.left) + const right = this.parseTakeLiteralNumber(argument.right) + if (left.kind === 'invalid' || right.kind === 'invalid') { + return {kind: 'invalid'} + } + if (left.kind === 'value' && right.kind === 'value') { + return {kind: 'value', value: left.value + right.value} + } } return {kind: 'unresolved'} } + /** + * Converts a statically resolved TAKE count into its output dimension. + * + * @param {Ast | undefined} argument - The count expression to classify. + * @returns {TakeLiteralDimension} The non-negative truncated dimension or its unresolved classification. + * @internal + */ + private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + const dimension = this.parseTakeLiteralNumber(argument) + return dimension.kind === 'value' + ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} + : dimension + } + + /** + * Resolves a direct TAKE source reference without evaluating its values. + * The range supplies materialized dimensions only; spill placement never + * depends on its sheet. + * + * @param {Ast} argument - The source expression to inspect. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {AbsoluteCellRange | undefined} The source range, or `undefined` for a computed array. + * @internal + */ + private takeSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { + if (argument.type === AstNodeType.PARENTHESIS) { + return this.takeSourceRange(argument.expression, state) + } + if (argument.type === AstNodeType.CELL_RANGE || argument.type === AstNodeType.COLUMN_RANGE || argument.type === AstNodeType.ROW_RANGE) { + return AbsoluteCellRange.fromAstOrUndef(argument, state.formulaAddress) + } + return undefined + } + public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -219,8 +280,9 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * Syntactically empty dimensions keep all rows or columns. Counts that * evaluate to zero return a #N/A error. * - * @param ast - * @param state + * @param {ProcedureAst} ast - The parsed TAKE call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} The selected source values or a spreadsheet error. */ public take(ast: ProcedureAst, state: InterpreterState): InterpreterValue { // The default supports TAKE(array, , columns), but the rows argument @@ -250,8 +312,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake const sourceRange = range.range - // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. if (sourceRange !== undefined) { + const leavesHeightUnbounded = !Number.isFinite(sourceRange.height()) && !Number.isFinite(requestedRows) + const leavesWidthUnbounded = !Number.isFinite(sourceRange.width()) && !Number.isFinite(requestedColumns) + const startsBelowFirstRow = leavesHeightUnbounded && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = leavesWidthUnbounded && state.formulaAddress.col !== 0 + + if (startsBelowFirstRow || startsRightOfFirstColumn) { + return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) + } + + // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. const resultRange = AbsoluteCellRange.spanFrom( sourceRange.getAddress(startColumn, startRow), columnsToTake, @@ -272,11 +343,13 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. Unbounded results are rejected so the dependency graph - * never reserves an infinite spill range. + * the upper bound. Unbounded dimensions are valid only at the corresponding + * output-sheet edge, then direct references use their effective dimensions + * to register the materialized spill footprint. * - * @param ast - * @param state + * @param {ProcedureAst} ast - The parsed TAKE call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. */ public takeArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { if (ast.args.length < 2 || ast.args.length > 3) { @@ -298,12 +371,21 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width - - if (!Number.isFinite(height) || !Number.isFinite(width) || height < 1 || width < 1) { + const startsBelowFirstRow = !Number.isFinite(height) && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = !Number.isFinite(width) && state.formulaAddress.col !== 0 + const sourceRange = this.takeSourceRange(ast.args[0], state) + const effectiveHeight = !Number.isFinite(height) && sourceRange !== undefined + ? sourceRange.effectiveHeight(this.dependencyGraph) + : height + const effectiveWidth = !Number.isFinite(width) && sourceRange !== undefined + ? sourceRange.effectiveWidth(this.dependencyGraph) + : width + + if (startsBelowFirstRow || startsRightOfFirstColumn || effectiveHeight < 1 || effectiveWidth < 1) { return ArraySize.error() } - return new ArraySize(width, height) + return new ArraySize(effectiveWidth, effectiveHeight) } /** From 9e381163b31e2f6e2a783034d7111ed80a2c8880 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 17 Aug 2026 19:54:54 +0100 Subject: [PATCH 20/37] docs: document localized stack function names --- CHANGELOG.md | 4 ++++ docs/guide/release-notes.md | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5851706f40..90a7542104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) +### Changed + +- **Breaking change**: Replaced the placeholder English `VSTACK` and `HSTACK` names with their official localized names in 14 language packs. Formulas parsed with one of those language packs must use the localized function names. [#1722](https://github.com/handsontable/hyperformula/pull/1722) + ## [3.4.0] - 2026-08-10 ### Added diff --git a/docs/guide/release-notes.md b/docs/guide/release-notes.md index db77299bef..c74f6ca7d2 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -6,6 +6,12 @@ This page lists HyperFormula release notes. The format is based on HyperFormula adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- **Breaking change**: The Czech, Danish, German, Spanish, Finnish, French, Hungarian, Italian, Norwegian Bokmål, Dutch, Polish, Portuguese, Russian, and Turkish language packs now use the official localized names for `VSTACK` and `HSTACK`. If an existing formula uses the English `VSTACK` or `HSTACK` name while one of these language packs is active, update that formula to the localized function name exported by the language pack. + ## 3.4.0 **Release date: August 10, 2026** From 3fd5a702c785acc83521a88e6fb86b7ed1146bd0 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 25 Aug 2026 19:42:40 +0100 Subject: [PATCH 21/37] fix: evaluate constant TAKE dimensions before spill allocation --- docs/guide/release-notes.md | 6 --- docs/guide/types-of-errors.md | 2 +- src/i18n/languages/csCZ.ts | 4 +- src/i18n/languages/daDK.ts | 4 +- src/i18n/languages/deDE.ts | 4 +- src/i18n/languages/esES.ts | 4 +- src/i18n/languages/fiFI.ts | 4 +- src/i18n/languages/frFR.ts | 4 +- src/i18n/languages/huHU.ts | 4 +- src/i18n/languages/itIT.ts | 4 +- src/i18n/languages/nbNO.ts | 4 +- src/i18n/languages/nlNL.ts | 4 +- src/i18n/languages/plPL.ts | 4 +- src/i18n/languages/ptPT.ts | 4 +- src/i18n/languages/ruRU.ts | 4 +- src/i18n/languages/trTR.ts | 4 +- .../buildFunctionDescriptions.ts | 8 ++-- src/interpreter/plugin/ArrayPlugin.ts | 45 ++++++++++++++++++- src/interpreter/plugin/FunctionPlugin.ts | 30 +++---------- 19 files changed, 82 insertions(+), 65 deletions(-) diff --git a/docs/guide/release-notes.md b/docs/guide/release-notes.md index c74f6ca7d2..db77299bef 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -6,12 +6,6 @@ This page lists HyperFormula release notes. The format is based on HyperFormula adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased - -### Changed - -- **Breaking change**: The Czech, Danish, German, Spanish, Finnish, French, Hungarian, Italian, Norwegian Bokmål, Dutch, Polish, Portuguese, Russian, and Turkish language packs now use the official localized names for `VSTACK` and `HSTACK`. If an existing formula uses the English `VSTACK` or `HSTACK` name while one of these language packs is active, update that formula to the localized function name exported by the language pack. - ## 3.4.0 **Release date: August 10, 2026** diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 92defde208..7f8546a90f 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -23,4 +23,4 @@ according to the language settings. | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | -| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | +| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | \ No newline at end of file diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 2e132b159c..504853241e 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'SROVNAT.SVISLE', - HSTACK: 'SROVNAT.VODOROVNĚ', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ODKAZ', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index d349a14498..0f5ed9234f 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'LSTAK', - HSTACK: 'VSTAK', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 43e1362a36..9aa3b3cb6e 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTAPELN', - HSTACK: 'HSTAPELN', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 48df21d5e1..b41794de15 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -19,8 +19,8 @@ export const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'APILARV', - HSTACK: 'APILARH', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'DIRECCION', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 57491a6fbe..d7dd216101 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VPINO', - HSTACK: 'HPINO', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'OSOITE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a99ffa2402..6072393653 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'ASSEMB.V', - HSTACK: 'ASSEMB.H', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 6095f001bf..053385b89d 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'FÜGG.HALMOZÁS', - HSTACK: 'VÍZSZ.HALMOZÁS', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'CÍM', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 43fb67409d..89fac39ec1 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'STACK.VERT', - HSTACK: 'STACK.ORIZ', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'INDIRIZZO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 9cc2f3c483..6e1ea1fd7c 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VSTAKK', - HSTACK: 'HSTAKK', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 34e27f436b..d63edfccd8 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'VERT.STAPELEN', - HSTACK: 'HOR.STAPELEN', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index e24b4f8e43..c7cf51198a 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'STOS.PION', - HSTACK: 'STOS.POZ', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3d45eb9d59..e16d7b2fb9 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'JUNTARV', - HSTACK: 'JUNTARH', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ENDEREÇO', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index b8b1378211..d2213035cc 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'ВСТОЛБИК', - HSTACK: 'ГСТОЛБИК', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'АДРЕС', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 9524f793fe..96f35a7494 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -19,8 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', - VSTACK: 'DÜŞEYYIĞ', - HSTACK: 'YATAYYIĞ', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts index 176f2e1e6c..8d1cdde779 100644 --- a/src/interpreter/functionMetadata/buildFunctionDescriptions.ts +++ b/src/interpreter/functionMetadata/buildFunctionDescriptions.ts @@ -23,7 +23,7 @@ * placeholder. */ -import {FunctionMetadata, FunctionArgument, isFunctionArgumentOptional} from '../plugin/FunctionPlugin' +import {FunctionMetadata, FunctionArgument} from '../plugin/FunctionPlugin' import {CUSTOM_FUNCTION_CATEGORY, FunctionDoc, FunctionListEntry, FunctionDetails, FunctionParameterDescription} from './FunctionDescription' /** Resolves a function's display name: the translation for the active language, or the canonical id as fallback. */ @@ -33,14 +33,12 @@ type TranslateName = (canonicalName: string) => string | undefined export type StructuralMetadata = Pick /** - * Returns whether a parameter may be omitted. An explicit `optionalArg` - * declaration takes precedence; otherwise, a `defaultValue` makes the - * parameter optional. + * Returns whether a parameter may be omitted: it declares `optionalArg`, or it has a `defaultValue`. * * @param {FunctionArgument | undefined} arg - the structural argument metadata, or `undefined` */ export function isParameterOptional(arg: FunctionArgument | undefined): boolean { - return isFunctionArgumentOptional(arg) + return arg?.optionalArg === true || arg?.defaultValue !== undefined } /** diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 13876bc65b..99ee5c3c46 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -41,6 +41,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return {kind: 'value', value: getRawValue(coercedValue)} } + if (argument?.type === AstNodeType.ERROR || argument?.type === AstNodeType.ERROR_WITH_RAW_INPUT) { + return {kind: 'invalid'} + } + if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { const dimension = this.parseTakeLiteralNumber(argument.value) if (dimension.kind !== 'value') { @@ -53,6 +57,14 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return this.parseTakeLiteralNumber(argument.expression) } + if (argument?.type === AstNodeType.PERCENT_OP) { + const dimension = this.parseTakeLiteralNumber(argument.value) + if (dimension.kind !== 'value') { + return dimension + } + return {kind: 'value', value: getRawValue(this.arithmeticHelper.unaryPercent(dimension.value))} + } + if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { if (argument.procedureName === 'TRUE') { return {kind: 'value', value: 1} @@ -62,14 +74,43 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } } - if (argument?.type === AstNodeType.PLUS_OP) { + if ( + argument?.type === AstNodeType.PLUS_OP + || argument?.type === AstNodeType.MINUS_OP + || argument?.type === AstNodeType.TIMES_OP + || argument?.type === AstNodeType.DIV_OP + || argument?.type === AstNodeType.POWER_OP + ) { const left = this.parseTakeLiteralNumber(argument.left) const right = this.parseTakeLiteralNumber(argument.right) if (left.kind === 'invalid' || right.kind === 'invalid') { return {kind: 'invalid'} } if (left.kind === 'value' && right.kind === 'value') { - return {kind: 'value', value: left.value + right.value} + let value: number + switch (argument.type) { + case AstNodeType.PLUS_OP: + value = this.arithmeticHelper.addWithEpsilonRaw(left.value, right.value) + break + case AstNodeType.MINUS_OP: + value = getRawValue(this.arithmeticHelper.subtract(left.value, right.value)) + break + case AstNodeType.TIMES_OP: + value = getRawValue(this.arithmeticHelper.multiply(left.value, right.value)) + break + case AstNodeType.DIV_OP: { + const quotient = this.arithmeticHelper.divide(left.value, right.value) + if (quotient instanceof CellError) { + return {kind: 'invalid'} + } + value = getRawValue(quotient) + break + } + case AstNodeType.POWER_OP: + value = this.arithmeticHelper.pow(left.value, right.value) + break + } + return Number.isFinite(value) ? {kind: 'value', value} : {kind: 'invalid'} } } diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts index 79de6453bb..810f140479 100644 --- a/src/interpreter/plugin/FunctionPlugin.ts +++ b/src/interpreter/plugin/FunctionPlugin.ts @@ -200,18 +200,15 @@ export interface FunctionArgument { passSubtype?: boolean, /** - * If an argument is missing and omission is allowed, its value defaults to - * `defaultValue`. Declaring a default makes the argument optional unless - * `optionalArg` is explicitly set to `false`. + * If an argument is missing, its value defaults to `defaultValue`. */ defaultValue?: InternalScalarValue | RawScalarValue, /** - * Explicitly controls whether the argument may be omitted. When omitted, - * the presence of `defaultValue` makes the argument optional. + * If set to `true`: + * if an argument is missing, and no `defaultValue` is set, the argument is `undefined` (instead of throwing an error). * - * If set to `true` without a `defaultValue`, a missing argument is passed as - * `undefined` instead of producing an error. + * This is logically equivalent to setting `defaultValue` to `undefined`. */ optionalArg?: boolean, @@ -236,9 +233,8 @@ export interface FunctionArgument { greaterThan?: number, /** - * If set to `true`, a syntactically empty argument falls back to - * `defaultValue`. This does not make the argument optional; `optionalArg` - * controls whether the argument may be omitted. + * If set to `true`, an empty argument is treated as if the argument was not + * provided at all — that is, it falls back to `defaultValue`. * * By default (`false`), an empty argument is coerced to the zero-value for its * type (`0` for numbers, `FALSE` for booleans, `""` for strings). @@ -253,18 +249,6 @@ export interface FunctionArgument { emptyAsDefault?: boolean, } -/** - * Returns whether a function argument may be omitted. - * - * Arguments explicitly marked as optional and arguments with default values - * may be omitted. - * - * @param {FunctionArgument | undefined} argument - The argument metadata to inspect. - */ -export function isFunctionArgumentOptional(argument: FunctionArgument | undefined): boolean { - return argument?.optionalArg || argument?.defaultValue !== undefined -} - export type PluginFunctionType = (ast: ProcedureAst, state: InterpreterState) => InterpreterValue export type PluginArraySizeFunctionType = (ast: ProcedureAst, state: InterpreterState) => ArraySize @@ -555,7 +539,7 @@ export abstract class FunctionPlugin implements FunctionPluginTypecheck argMetadata?.optionalArg || argMetadata?.defaultValue !== undefined) return areMissingArgumentsOptional } From 5078a8c155a524ef3bc57d87ff7bcc59301e2f88 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 2 Sep 2026 22:17:46 +0100 Subject: [PATCH 22/37] fix: allow nested unbounded TAKE results --- src/interpreter/plugin/ArrayPlugin.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 99ee5c3c46..2c08a28b08 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -354,15 +354,6 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const sourceRange = range.range if (sourceRange !== undefined) { - const leavesHeightUnbounded = !Number.isFinite(sourceRange.height()) && !Number.isFinite(requestedRows) - const leavesWidthUnbounded = !Number.isFinite(sourceRange.width()) && !Number.isFinite(requestedColumns) - const startsBelowFirstRow = leavesHeightUnbounded && state.formulaAddress.row !== 0 - const startsRightOfFirstColumn = leavesWidthUnbounded && state.formulaAddress.col !== 0 - - if (startsBelowFirstRow || startsRightOfFirstColumn) { - return new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult) - } - // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. const resultRange = AbsoluteCellRange.spanFrom( sourceRange.getAddress(startColumn, startRow), @@ -388,6 +379,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * output-sheet edge, then direct references use their effective dimensions * to register the materialized spill footprint. * + * An unbounded result anchored away from the required sheet edge retains its + * unbounded predicted dimension so array-space validation returns a spill + * error without evaluating TAKE as a scalar formula. + * * @param {ProcedureAst} ast - The parsed TAKE call. * @param {InterpreterState} state - The formula state whose address anchors the spill. * @returns {ArraySize} The predicted result dimensions or an invalid size. @@ -422,7 +417,11 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ? sourceRange.effectiveWidth(this.dependencyGraph) : width - if (startsBelowFirstRow || startsRightOfFirstColumn || effectiveHeight < 1 || effectiveWidth < 1) { + if (startsBelowFirstRow || startsRightOfFirstColumn) { + return new ArraySize(width, height) + } + + if (effectiveHeight < 1 || effectiveWidth < 1) { return ArraySize.error() } From 9bd7be4658f2ba0012dd1f1e87bafd97c8e0d6d6 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 2 Sep 2026 23:06:04 +0100 Subject: [PATCH 23/37] fix: preserve TAKE zero-count errors --- src/interpreter/plugin/ArrayPlugin.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 2c08a28b08..1ac87ad240 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -417,14 +417,14 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ? sourceRange.effectiveWidth(this.dependencyGraph) : width - if (startsBelowFirstRow || startsRightOfFirstColumn) { - return new ArraySize(width, height) - } - if (effectiveHeight < 1 || effectiveWidth < 1) { return ArraySize.error() } + if (startsBelowFirstRow || startsRightOfFirstColumn) { + return new ArraySize(width, height) + } + return new ArraySize(effectiveWidth, effectiveHeight) } From 5b1bd12508864354f557e2ecd5938bd9c5aed395 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 2 Sep 2026 23:21:30 +0100 Subject: [PATCH 24/37] fix: bound unbounded TAKE predictions --- src/interpreter/plugin/ArrayPlugin.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 1ac87ad240..edf5cae4f4 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -375,9 +375,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. Unbounded dimensions are valid only at the corresponding - * output-sheet edge, then direct references use their effective dimensions - * to register the materialized spill footprint. + * the upper bound. Direct unbounded references use their effective dimensions + * to cap finite counts and register the materialized spill footprint. + * Dimensions that remain unbounded are valid only at the corresponding + * output-sheet edge. * * An unbounded result anchored away from the required sheet edge retains its * unbounded predicted dimension so array-space validation returns a spill @@ -410,14 +411,19 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const startsBelowFirstRow = !Number.isFinite(height) && state.formulaAddress.row !== 0 const startsRightOfFirstColumn = !Number.isFinite(width) && state.formulaAddress.col !== 0 const sourceRange = this.takeSourceRange(ast.args[0], state) - const effectiveHeight = !Number.isFinite(height) && sourceRange !== undefined - ? sourceRange.effectiveHeight(this.dependencyGraph) + const effectiveHeight = !Number.isFinite(sourceSize.height) && sourceRange !== undefined + ? Math.min(height, sourceRange.effectiveHeight(this.dependencyGraph)) : height - const effectiveWidth = !Number.isFinite(width) && sourceRange !== undefined - ? sourceRange.effectiveWidth(this.dependencyGraph) + const effectiveWidth = !Number.isFinite(sourceSize.width) && sourceRange !== undefined + ? Math.min(width, sourceRange.effectiveWidth(this.dependencyGraph)) : width - if (effectiveHeight < 1 || effectiveWidth < 1) { + if ( + effectiveHeight < 1 + || effectiveWidth < 1 + || effectiveHeight > this.config.maxRows + || effectiveWidth > this.config.maxColumns + ) { return ArraySize.error() } From 7c5e0b443bd8c55465b22dbc1f53adc9dc87b1b8 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 2 Sep 2026 23:46:31 +0100 Subject: [PATCH 25/37] fix: preserve bounded TAKE predictions --- src/interpreter/plugin/ArrayPlugin.ts | 37 +++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index edf5cae4f4..52a2ab394e 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -375,10 +375,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche /** * Calculates the spilled array size of TAKE using the source dimensions as - * the upper bound. Direct unbounded references use their effective dimensions - * to cap finite counts and register the materialized spill footprint. - * Dimensions that remain unbounded are valid only at the corresponding - * output-sheet edge. + * the upper bound. Finite counts are capped at the configured sheet limits. + * Direct references that remain unbounded use their effective dimensions, + * while computed sources use the configured limit. Unbounded dimensions are + * valid only at the corresponding output-sheet edge. * * An unbounded result anchored away from the required sheet edge retains its * unbounded predicted dimension so array-space validation returns a spill @@ -406,24 +406,25 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return ArraySize.error() } - const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value) : sourceSize.height - const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value) : sourceSize.width + const height = rowDimension.kind === 'value' + ? Math.min(sourceSize.height, rowDimension.value, this.config.maxRows) + : sourceSize.height + const width = columnDimension.kind === 'value' + ? Math.min(sourceSize.width, columnDimension.value, this.config.maxColumns) + : sourceSize.width const startsBelowFirstRow = !Number.isFinite(height) && state.formulaAddress.row !== 0 const startsRightOfFirstColumn = !Number.isFinite(width) && state.formulaAddress.col !== 0 const sourceRange = this.takeSourceRange(ast.args[0], state) - const effectiveHeight = !Number.isFinite(sourceSize.height) && sourceRange !== undefined - ? Math.min(height, sourceRange.effectiveHeight(this.dependencyGraph)) + const effectiveHeight = !Number.isFinite(height) + ? sourceRange?.effectiveHeight(this.dependencyGraph) ?? this.config.maxRows : height - const effectiveWidth = !Number.isFinite(sourceSize.width) && sourceRange !== undefined - ? Math.min(width, sourceRange.effectiveWidth(this.dependencyGraph)) + const effectiveWidth = !Number.isFinite(width) + ? sourceRange?.effectiveWidth(this.dependencyGraph) ?? this.config.maxColumns : width + const hasZeroDimension = (rowDimension.kind === 'value' && rowDimension.value === 0) + || (columnDimension.kind === 'value' && columnDimension.value === 0) - if ( - effectiveHeight < 1 - || effectiveWidth < 1 - || effectiveHeight > this.config.maxRows - || effectiveWidth > this.config.maxColumns - ) { + if (hasZeroDimension) { return ArraySize.error() } @@ -431,6 +432,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return new ArraySize(width, height) } + if (effectiveHeight < 1 || effectiveWidth < 1) { + return ArraySize.error() + } + return new ArraySize(effectiveWidth, effectiveHeight) } From 67b26a274e4329c1c34cd1cd9cae6ed3494409ec Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 3 Sep 2026 00:17:20 +0100 Subject: [PATCH 26/37] refactor: short-circuit zero TAKE dimensions --- src/interpreter/plugin/ArrayPlugin.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 52a2ab394e..e635b21100 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -406,6 +406,13 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return ArraySize.error() } + const hasZeroDimension = (rowDimension.kind === 'value' && rowDimension.value === 0) + || (columnDimension.kind === 'value' && columnDimension.value === 0) + + if (hasZeroDimension) { + return ArraySize.error() + } + const height = rowDimension.kind === 'value' ? Math.min(sourceSize.height, rowDimension.value, this.config.maxRows) : sourceSize.height @@ -421,12 +428,6 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const effectiveWidth = !Number.isFinite(width) ? sourceRange?.effectiveWidth(this.dependencyGraph) ?? this.config.maxColumns : width - const hasZeroDimension = (rowDimension.kind === 'value' && rowDimension.value === 0) - || (columnDimension.kind === 'value' && columnDimension.value === 0) - - if (hasZeroDimension) { - return ArraySize.error() - } if (startsBelowFirstRow || startsRightOfFirstColumn) { return new ArraySize(width, height) From 453b0974b7d206e6ce3ff45d20cb0ad13ea26152 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 3 Sep 2026 01:42:13 +0100 Subject: [PATCH 27/37] fix: respect boolean overrides in TAKE size prediction --- src/interpreter/Interpreter.ts | 13 +++++++++++++ src/interpreter/plugin/ArrayPlugin.ts | 21 +++++++++------------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 8edf08f0a9..c5f816c1fc 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -31,6 +31,7 @@ import { import {CriterionBuilder} from './Criterion' import {FunctionRegistry} from './FunctionRegistry' import {InterpreterState} from './InterpreterState' +import {FunctionPluginDefinition} from './plugin/FunctionPlugin' import { cloneNumber, EmptyValue, @@ -61,6 +62,18 @@ export class Interpreter { this.criterionBuilder = new CriterionBuilder(config) } + /** + * Checks whether a function is implemented by a specific plugin in this interpreter's engine instance. + * + * @param {string} functionId - The canonical function identifier. + * @param {FunctionPluginDefinition} plugin - The plugin class to compare with the registered implementation. + * @returns {boolean} `true` when the function is implemented by the given plugin class. + * @internal + */ + public isFunctionImplementedBy(functionId: string, plugin: FunctionPluginDefinition): boolean { + return this.functionRegistry.getFunctionPlugin(functionId) === plugin + } + public evaluateAst(ast: Ast, state: InterpreterState): InterpreterValue { let val = this.evaluateAstWithoutPostprocessing(ast, state) if (isExtendedNumber(val)) { diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index e635b21100..14ccf67636 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -12,6 +12,7 @@ import {coerceScalarToBoolean} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' import {getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' +import {BooleanPlugin} from './BooleanPlugin' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' type TakeLiteralDimension = @@ -65,13 +66,13 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return {kind: 'value', value: getRawValue(this.arithmeticHelper.unaryPercent(dimension.value))} } - if (argument?.type === AstNodeType.FUNCTION_CALL && argument.args.length === 0) { - if (argument.procedureName === 'TRUE') { - return {kind: 'value', value: 1} - } - if (argument.procedureName === 'FALSE') { - return {kind: 'value', value: 0} - } + if ( + argument?.type === AstNodeType.FUNCTION_CALL + && argument.args.length === 0 + && (argument.procedureName === 'TRUE' || argument.procedureName === 'FALSE') + && this.interpreter.isFunctionImplementedBy(argument.procedureName, BooleanPlugin) + ) { + return {kind: 'value', value: argument.procedureName === 'TRUE' ? 1 : 0} } if ( @@ -402,14 +403,10 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const rowDimension = this.parseTakeLiteralDimension(ast.args[1]) const columnDimension = this.parseTakeLiteralDimension(ast.args[2]) - if (rowDimension.kind === 'invalid' || columnDimension.kind === 'invalid') { - return ArraySize.error() - } - const hasZeroDimension = (rowDimension.kind === 'value' && rowDimension.value === 0) || (columnDimension.kind === 'value' && columnDimension.value === 0) - if (hasZeroDimension) { + if (rowDimension.kind === 'invalid' || columnDimension.kind === 'invalid' || hasZeroDimension) { return ArraySize.error() } From 2f5a74d27ba0df6e11718e30b236ac98b83c1277 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 3 Sep 2026 21:36:56 +0100 Subject: [PATCH 28/37] fix: distinguish unresolved TAKE dimensions --- src/interpreter/plugin/ArrayPlugin.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 14ccf67636..2e269524bb 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -15,11 +15,24 @@ import {SimpleRangeValue} from '../../SimpleRangeValue' import {BooleanPlugin} from './BooleanPlugin' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' -type TakeLiteralDimension = +/** + * Classifies a TAKE count expression whose value may be known before evaluation. + * + * @internal + */ +type TakeLiteralNumber = | {kind: 'value', value: number} | {kind: 'invalid'} | {kind: 'unresolved'} +/** + * Distinguishes an omitted or empty TAKE count from an expression whose value + * cannot be resolved during static result-size prediction. + * + * @internal + */ +type TakeLiteralDimension = TakeLiteralNumber | {kind: 'unbounded'} + export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** * Evaluates the dependency-free subset of TAKE count expressions used for @@ -29,7 +42,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. * @internal */ - private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralDimension { + private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralNumber { if (argument?.type === AstNodeType.NUMBER) { return {kind: 'value', value: argument.value} } @@ -122,10 +135,14 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * Converts a statically resolved TAKE count into its output dimension. * * @param {Ast | undefined} argument - The count expression to classify. - * @returns {TakeLiteralDimension} The non-negative truncated dimension or its unresolved classification. + * @returns {TakeLiteralDimension} The non-negative truncated dimension or its invalid, unresolved, or unbounded classification. * @internal */ private parseTakeLiteralDimension(argument: Ast | undefined): TakeLiteralDimension { + if (argument === undefined || argument.type === AstNodeType.EMPTY) { + return {kind: 'unbounded'} + } + const dimension = this.parseTakeLiteralNumber(argument) return dimension.kind === 'value' ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} @@ -416,8 +433,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value, this.config.maxColumns) : sourceSize.width - const startsBelowFirstRow = !Number.isFinite(height) && state.formulaAddress.row !== 0 - const startsRightOfFirstColumn = !Number.isFinite(width) && state.formulaAddress.col !== 0 + const startsBelowFirstRow = rowDimension.kind === 'unbounded' && !Number.isFinite(height) && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = columnDimension.kind === 'unbounded' && !Number.isFinite(width) && state.formulaAddress.col !== 0 const sourceRange = this.takeSourceRange(ast.args[0], state) const effectiveHeight = !Number.isFinite(height) ? sourceRange?.effectiveHeight(this.dependencyGraph) ?? this.config.maxRows From 8d9e74e808aafc6e8b38ad4019d4e04825ac4630 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 3 Sep 2026 22:22:23 +0100 Subject: [PATCH 29/37] docs: correct TAKE compatibility details --- docs/guide/list-of-differences.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 4aa0c833e1..6990e7241d 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -117,7 +117,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | ADDRESS | =ADDRESS(1,1,4, TRUE(), "") | !A1 | ''!A1 | !A1 | | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | -| TAKE | =TAKE(Data!A:A, , 1) | Spills the whole column from row 1; returns SPILL below row 1. | No such function. | Spills the whole column from row 1; returns SPILL below row 1. | +| TAKE | =TAKE(Data!A:A, , 1) | Spills to the source sheet's current height from row 1; returns SPILL below row 1. | No such function. | Spills all 1,048,576 rows from row 1, with blank source cells as 0; returns SPILL below row 1. | | INT | =INT(-8.9) | -8 | -9 | -9 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | From 216e20536b5f0c5d8c742cae03b16a84e355cef3 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 6 Oct 2026 17:18:19 +0100 Subject: [PATCH 30/37] feat: add DROP function (HF-384) Add the DROP dynamic-array function to ArrayPlugin. DROP removes rows and columns from the start (positive counts) or end (negative counts) of an array. Counts truncate toward zero, and a zero, omitted, or empty count drops nothing. Dropping every row or column returns #N/A, because HyperFormula has no #CALC! error type. Spill-size prediction uses statically known counts, so dropped rows and columns are never reserved. A whole-column or whole-row source spills from its effective size and fits only when the formula starts within the number of rows or columns dropped, matching Excel. TAKE and DROP now share the static count parser, source-range lookup, effective source sizing, and the slice that reads only the kept cells. Localized names are confirmed in Excel Online for every language pack. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/guide/list-of-differences.md | 3 + src/i18n/languages/csCZ.ts | 1 + src/i18n/languages/daDK.ts | 1 + src/i18n/languages/deDE.ts | 1 + src/i18n/languages/enGB.ts | 1 + src/i18n/languages/esES.ts | 1 + src/i18n/languages/fiFI.ts | 1 + src/i18n/languages/frFR.ts | 1 + src/i18n/languages/huHU.ts | 1 + src/i18n/languages/idID.ts | 1 + src/i18n/languages/itIT.ts | 1 + src/i18n/languages/nbNO.ts | 1 + src/i18n/languages/nlNL.ts | 1 + src/i18n/languages/plPL.ts | 1 + src/i18n/languages/ptPT.ts | 1 + src/i18n/languages/ruRU.ts | 1 + src/i18n/languages/svSE.ts | 1 + src/i18n/languages/trTR.ts | 1 + .../categories/lookup-and-reference.ts | 7 + src/interpreter/plugin/ArrayPlugin.ts | 228 +++++++++++++++--- 21 files changed, 218 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db697ef82..e24cf6d55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) +- Added the `DROP` dynamic-array function. ### Fixed diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 6990e7241d..0292d6d2ce 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -118,6 +118,9 @@ To remove the differences, create [custom implementations](custom-functions.md) | SEQUENCE | =SEQUENCE(0) | VALUE | N/A | CALC | | TAKE | =TAKE(A1:A3, 0) | N/A | No such function. | CALC | | TAKE | =TAKE(Data!A:A, , 1) | Spills to the source sheet's current height from row 1; returns SPILL below row 1. | No such function. | Spills all 1,048,576 rows from row 1, with blank source cells as 0; returns SPILL below row 1. | +| DROP | =DROP(A1:A3, 3) | N/A | No such function. | CALC | +| DROP | =DROP(Data!A:A, 1) | Spills to the source sheet's current height minus one row; returns SPILL below row 2. | No such function. | Spills all 1,048,575 remaining rows, with blank source cells as 0; returns SPILL below row 2. | +| DROP | =DROP(A1:C3, {1,2}) | Uses the first count and drops one row. | No such function. | Returns a 1x2 array of VALUE errors. | | INT | =INT(-8.9) | -8 | -9 | -9 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 2e132b159c..e1cded7ff5 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARFR', + DROP: 'ZAHODIT', EDATE: 'EDATE', EFFECT: "EFFECT", EOMONTH: 'EOMONTH', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index ed00deba20..5f33989f94 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'KR.DECIMAL', DOLLARFR: 'KR.BRØK', + DROP: 'UDELAD', EDATE: 'EDATO', EFFECT: "EFFEKTIV.RENTE", EOMONTH: 'SLUT.PÅ.MÅNED', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 43e1362a36..74303a762c 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'NOTIERUNGDEZ', DOLLARFR: 'NOTIERUNGBRU', + DROP: 'WEGLASSEN', EDATE: 'EDATUM', EFFECT: "EFFEKTIV", EOMONTH: 'MONATSENDE', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index e8286f5da4..43b4205100 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -99,6 +99,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARFR', + DROP: 'DROP', EDATE: 'EDATE', EFFECT: 'EFFECT', EOMONTH: 'EOMONTH', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 48df21d5e1..7bbf669a3f 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -98,6 +98,7 @@ export const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'MONEDA.DEC', DOLLARFR: 'MONEDA.FRAC', + DROP: 'EXCLUIR', EDATE: 'FECHA.MES', EFFECT: "INT.EFECTIVO", EOMONTH: 'FIN.MES', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 57491a6fbe..79c31be60a 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'SAMA.ARVO', DOLLARDE: 'VALUUTTA.DES', DOLLARFR: 'VALUUTTA.MURTO', + DROP: 'HYLKÄÄ', EDATE: 'PÄIVÄ.KUUKAUSI', EFFECT: "KORKO.EFEKT", EOMONTH: 'KUUKAUSI.LOPPU', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index a99ffa2402..4995c04005 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'PRIX.DEC', DOLLARFR: 'PRIX.FRAC', + DROP: 'EXCLURE', EDATE: 'MOIS.DECALER', EFFECT: "TAUX.EFFECTIF", EOMONTH: 'FIN.MOIS', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 6095f001bf..0cee154cf6 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'FORINT.DEC', DOLLARFR: 'FORINT.TÖRT', + DROP: 'ELTÁVOLÍT', EDATE: 'KALK.DÁTUM', EFFECT: "TÉNYLEGES", EOMONTH: 'HÓNAP.UTOLSÓ.NAP', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 337e32d573..aa39395e3b 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -99,6 +99,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'DOLAR.DES', DOLLARFR: 'DOLAR.PEC', + DROP: 'DROP', EDATE: 'EDATE', EFFECT: 'EFEKTIF', EOMONTH: 'AKHIR.BULAN', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 43fb67409d..d2882ea358 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'VALUTA.DEC', DOLLARFR: 'VALUTA.FRAZ', + DROP: 'ESCLUDI', EDATE: 'DATA.MESE', EFFECT: "EFFETTIVO", EOMONTH: 'FINE.MESE', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 9cc2f3c483..f975466ed6 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARBR', + DROP: 'UTELAT', EDATE: 'DAG.ETTER', EFFECT: "EFFEKTIV.RENTE", EOMONTH: 'MÅNEDSSLUTT', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 34e27f436b..8f50ea2650 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'EURO.DE', DOLLARFR: 'EURO.BR', + DROP: 'WEGLATEN', EDATE: 'ZELFDE.DAG', EFFECT: "EFFECT.RENTE", EOMONTH: 'LAATSTE.DAG', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index e24b4f8e43..7c286370b1 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'CENA.DZIES', DOLLARFR: 'CENA.UŁAM', + DROP: 'POMIŃ', EDATE: 'NR.SER.DATY', EFFECT: "EFEKTYWNA", EOMONTH: 'NR.SER.OST.DN.MIEŚ', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3d45eb9d59..e0412c4772 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'MOEDADEC', DOLLARFR: 'MOEDAFRA', + DROP: 'EXCLUIR', EDATE: 'DATAM', EFFECT: "EFETIVA", EOMONTH: 'FIMMÊS', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index b8b1378211..9e774e14eb 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'ДЕЛЬТА', DOLLARDE: 'РУБЛЬ.ДЕС', DOLLARFR: 'РУБЛЬ.ДРОБЬ', + DROP: 'СБРОСИТЬ', EDATE: 'ДАТАМЕС', EFFECT: "ЭФФЕКТ", EOMONTH: 'КОНМЕСЯЦА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 7d4ba7b467..2cbb3baf1c 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'DECTAL', DOLLARFR: 'BRÅK', + DROP: 'UTESLUT', EDATE: 'EDATUM', EFFECT: "EFFRÄNTA", EOMONTH: 'SLUTMÅNAD', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 9524f793fe..8bbe3b4a75 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -98,6 +98,7 @@ const dictionary: RawTranslationPackage = { DELTA: 'DELTA', DOLLARDE: 'LİRAON', DOLLARFR: 'LİRAKES', + DROP: 'BIRAK', EDATE: 'SERİTARİH', EFFECT: "ETKİN", EOMONTH: 'SERİAY', diff --git a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index d6089cd0d0..cdf459d09a 100644 --- a/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts @@ -39,6 +39,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=COLUMNS(A1:C5)', '=COLUMNS(A1:F1)'], }, + DROP: { + category: 'Lookup and reference', + shortDescription: 'Removes specified rows or columns from the beginning or end of an array.', + parameters: [{name: 'array', description: 'The array or range from which to remove rows or columns.'}, {name: 'rows', description: 'The number of rows to remove; a negative value removes rows from the end. Zero or an empty argument removes no rows.'}, {name: 'columns', description: 'The number of columns to remove; a negative value removes columns from the end. When omitted, empty, or zero, no columns are removed.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=DROP(A1:C5, 2)', '=DROP(A1:C5, -1, -1)', '=DROP(A1:C5, , 1)'], + }, FILTER: { category: 'Lookup and reference', shortDescription: 'Filters an array, based on multiple conditions (boolean arrays).', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index 2e269524bb..f9fd3cf08c 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -16,11 +16,11 @@ import {BooleanPlugin} from './BooleanPlugin' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** - * Classifies a TAKE count expression whose value may be known before evaluation. + * Classifies a TAKE or DROP count expression whose value may be known before evaluation. * * @internal */ -type TakeLiteralNumber = +type LiteralCount = | {kind: 'value', value: number} | {kind: 'invalid'} | {kind: 'unresolved'} @@ -31,18 +31,18 @@ type TakeLiteralNumber = * * @internal */ -type TakeLiteralDimension = TakeLiteralNumber | {kind: 'unbounded'} +type TakeLiteralDimension = LiteralCount | {kind: 'unbounded'} export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Evaluates the dependency-free subset of TAKE count expressions used for - * static result-size prediction. + * Evaluates the dependency-free subset of TAKE and DROP count expressions + * used for static result-size prediction. * * @param {Ast | undefined} argument - The count expression to inspect before evaluation. - * @returns {TakeLiteralDimension} The constant value, an invalid-literal marker, or an unresolved marker. + * @returns {LiteralCount} The constant value, an invalid-literal marker, or an unresolved marker. * @internal */ - private parseTakeLiteralNumber(argument: Ast | undefined): TakeLiteralNumber { + private parseLiteralCount(argument: Ast | undefined): LiteralCount { if (argument?.type === AstNodeType.NUMBER) { return {kind: 'value', value: argument.value} } @@ -60,7 +60,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } if (argument?.type === AstNodeType.PLUS_UNARY_OP || argument?.type === AstNodeType.MINUS_UNARY_OP) { - const dimension = this.parseTakeLiteralNumber(argument.value) + const dimension = this.parseLiteralCount(argument.value) if (dimension.kind !== 'value') { return dimension } @@ -68,11 +68,11 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } if (argument?.type === AstNodeType.PARENTHESIS) { - return this.parseTakeLiteralNumber(argument.expression) + return this.parseLiteralCount(argument.expression) } if (argument?.type === AstNodeType.PERCENT_OP) { - const dimension = this.parseTakeLiteralNumber(argument.value) + const dimension = this.parseLiteralCount(argument.value) if (dimension.kind !== 'value') { return dimension } @@ -95,8 +95,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche || argument?.type === AstNodeType.DIV_OP || argument?.type === AstNodeType.POWER_OP ) { - const left = this.parseTakeLiteralNumber(argument.left) - const right = this.parseTakeLiteralNumber(argument.right) + const left = this.parseLiteralCount(argument.left) + const right = this.parseLiteralCount(argument.right) if (left.kind === 'invalid' || right.kind === 'invalid') { return {kind: 'invalid'} } @@ -143,14 +143,14 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return {kind: 'unbounded'} } - const dimension = this.parseTakeLiteralNumber(argument) + const dimension = this.parseLiteralCount(argument) return dimension.kind === 'value' ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} : dimension } /** - * Resolves a direct TAKE source reference without evaluating its values. + * Resolves a direct TAKE or DROP source reference without evaluating its values. * The range supplies materialized dimensions only; spill placement never * depends on its sheet. * @@ -159,9 +159,9 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * @returns {AbsoluteCellRange | undefined} The source range, or `undefined` for a computed array. * @internal */ - private takeSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { + private directSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { if (argument.type === AstNodeType.PARENTHESIS) { - return this.takeSourceRange(argument.expression, state) + return this.directSourceRange(argument.expression, state) } if (argument.type === AstNodeType.CELL_RANGE || argument.type === AstNodeType.COLUMN_RANGE || argument.type === AstNodeType.ROW_RANGE) { return AbsoluteCellRange.fromAstOrUndef(argument, state.formulaAddress) @@ -169,6 +169,96 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return undefined } + /** + * Classifies a DROP count before evaluation. An omitted or empty count + * drops nothing, so it resolves to zero. + * + * @param {Ast | undefined} argument - The count expression to classify. + * @returns {LiteralCount} The constant count, an invalid-literal marker, or an unresolved marker. + * @internal + */ + private parseDropLiteralCount(argument: Ast | undefined): LiteralCount { + if (argument === undefined || argument.type === AstNodeType.EMPTY) { + return {kind: 'value', value: 0} + } + return this.parseLiteralCount(argument) + } + + /** + * Predicts how many rows or columns of the source remain after DROP. + * + * An unresolved count may drop nothing, so the source dimension is the upper + * bound. An unbounded source (a whole column or row) is measured by its + * effective size. In a spreadsheet such a result reaches the sheet edge, so + * it fits only when the formula starts no further from the first row or + * column than the number of rows or columns dropped; otherwise the dimension + * stays unbounded, which makes array-space validation report a spill error. + * + * @param {number} sourceDimension - The predicted source height or width, possibly unbounded. + * @param {LiteralCount} count - The classified count; must not be invalid. + * @param {number} anchor - The formula's row or column index along the same axis. + * @param {number} effectiveDimension - The effective source height or width along the same axis. + * @returns {number} The remaining dimension, zero when everything is dropped, or `Infinity` for a spill. + * @internal + */ + private predictDropDimension(sourceDimension: number, count: LiteralCount, anchor: number, effectiveDimension: number): number { + const requestedDrop = count.kind === 'value' ? Math.abs(Math.trunc(count.value)) : 0 + + if (!Number.isFinite(sourceDimension) && count.kind === 'value' && anchor > requestedDrop) { + return Number.POSITIVE_INFINITY + } + + const boundedDimension = Number.isFinite(sourceDimension) ? sourceDimension : effectiveDimension + return boundedDimension - Math.min(requestedDrop, boundedDimension) + } + + /** + * Measures a TAKE or DROP source whose predicted size is unbounded. A direct + * whole-column or whole-row reference is measured by its effective size; a + * computed array falls back to the configured sheet limits. + * + * @param {Ast} argument - The source expression to measure. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {ArraySize} The effective source size. + * @internal + */ + private effectiveSourceSize(argument: Ast, state: InterpreterState): ArraySize { + const sourceRange = this.directSourceRange(argument, state) + return new ArraySize( + sourceRange?.effectiveWidth(this.dependencyGraph) ?? this.config.maxColumns, + sourceRange?.effectiveHeight(this.dependencyGraph) ?? this.config.maxRows, + ) + } + + /** + * Returns a block of a source array as values. An address-backed source is + * read only inside the block, so the cost is proportional to the result + * rather than to the source. The block is returned as values rather than as + * a range so that it coerces to a scalar like any other array result. + * + * @param {SimpleRangeValue} source - The evaluated source array. + * @param {number} startRow - The first row of the block, relative to the source. + * @param {number} startColumn - The first column of the block, relative to the source. + * @param {number} height - The number of rows in the block. + * @param {number} width - The number of columns in the block. + * @returns {SimpleRangeValue} The block's values. + * @internal + */ + private sliceSourceArray(source: SimpleRangeValue, startRow: number, startColumn: number, height: number, width: number): SimpleRangeValue { + const sourceRange = source.range + + if (sourceRange !== undefined) { + const blockRange = AbsoluteCellRange.spanFrom(sourceRange.getAddress(startColumn, startRow), width, height) + return SimpleRangeValue.onlyValues(SimpleRangeValue.onlyRange(blockRange, this.dependencyGraph).data) + } + + const block = source.data + .slice(startRow, startRow + height) + .map(row => row.slice(startColumn, startColumn + width)) + + return SimpleRangeValue.onlyValues(block) + } + public static implementedFunctions: ImplementedFunctions = { 'ARRAYFORMULA': { method: 'arrayformula', @@ -198,6 +288,17 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ], repeatLastArgs: 1, }, + 'DROP': { + method: 'drop', + sizeOfResultArrayMethod: 'dropArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 0}, + ], + vectorizationForbidden: true, + }, 'TAKE': { method: 'take', sizeOfResultArrayMethod: 'takeArraySize', @@ -369,28 +470,83 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const columnsToTake = Math.min(Math.abs(requestedColumns), sourceWidth) const startRow = requestedRows > 0 ? 0 : sourceHeight - rowsToTake const startColumn = requestedColumns > 0 ? 0 : sourceWidth - columnsToTake - const sourceRange = range.range - - if (sourceRange !== undefined) { - // Keep address-backed ranges lazy to avoid materializing cells outside the TAKE result. - const resultRange = AbsoluteCellRange.spanFrom( - sourceRange.getAddress(startColumn, startRow), - columnsToTake, - rowsToTake, - ) - const result = SimpleRangeValue.onlyRange(resultRange, this.dependencyGraph).data - return SimpleRangeValue.onlyValues(result) + + return this.sliceSourceArray(range, startRow, startColumn, rowsToTake, columnsToTake) + } + ) + } + + /** + * Corresponds to DROP(array, rows, [columns]). + * + * Removes rows and columns from the beginning (positive counts) or the end + * (negative counts) of the source array and returns the rest. Counts are + * truncated toward zero; a zero, omitted, or empty count drops nothing. + * Dropping every row or every column returns a #N/A error. + * + * @param {ProcedureAst} ast - The parsed DROP call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} The remaining source values or a spreadsheet error. + */ + public drop(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('DROP'), + (range: SimpleRangeValue, rows: number, columns: number) => { + const rowsToDrop = Math.min(Math.abs(Math.trunc(rows)), range.height()) + const columnsToDrop = Math.min(Math.abs(Math.trunc(columns)), range.width()) + const remainingRows = range.height() - rowsToDrop + const remainingColumns = range.width() - columnsToDrop + + if (remainingRows === 0 || remainingColumns === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArray) } - const result = range.data - .slice(startRow, startRow + rowsToTake) - .map(row => row.slice(startColumn, startColumn + columnsToTake)) + const startRow = rows > 0 ? rowsToDrop : 0 + const startColumn = columns > 0 ? columnsToDrop : 0 - return SimpleRangeValue.onlyValues(result) + return this.sliceSourceArray(range, startRow, startColumn, remainingRows, remainingColumns) } ) } + /** + * Calculates the spilled array size of DROP. Statically known counts give + * the exact size; counts that depend on other cells use the source size as + * the upper bound. Dropping every row or column from a known count is + * reported as an invalid size, so the formula evaluates to its #N/A error + * instead of reserving space. + * + * @param {ProcedureAst} ast - The parsed DROP call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + public dropArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 2 || ast.args.length > 3) { + return ArraySize.error() + } + + const metadata = this.metadata('DROP') + const sourceSize = this.arraySizeForAst( + ast.args[0], + new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), + ) + const rowCount = this.parseDropLiteralCount(ast.args[1]) + const columnCount = this.parseDropLiteralCount(ast.args[2]) + + if (rowCount.kind === 'invalid' || columnCount.kind === 'invalid') { + return ArraySize.error() + } + + const effectiveSourceSize = this.effectiveSourceSize(ast.args[0], state) + const height = this.predictDropDimension(sourceSize.height, rowCount, state.formulaAddress.row, effectiveSourceSize.height) + const width = this.predictDropDimension(sourceSize.width, columnCount, state.formulaAddress.col, effectiveSourceSize.width) + + if (height < 1 || width < 1) { + return ArraySize.error() + } + + return new ArraySize(width, height) + } + /** * Calculates the spilled array size of TAKE using the source dimensions as * the upper bound. Finite counts are capped at the configured sheet limits. @@ -435,13 +591,9 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche : sourceSize.width const startsBelowFirstRow = rowDimension.kind === 'unbounded' && !Number.isFinite(height) && state.formulaAddress.row !== 0 const startsRightOfFirstColumn = columnDimension.kind === 'unbounded' && !Number.isFinite(width) && state.formulaAddress.col !== 0 - const sourceRange = this.takeSourceRange(ast.args[0], state) - const effectiveHeight = !Number.isFinite(height) - ? sourceRange?.effectiveHeight(this.dependencyGraph) ?? this.config.maxRows - : height - const effectiveWidth = !Number.isFinite(width) - ? sourceRange?.effectiveWidth(this.dependencyGraph) ?? this.config.maxColumns - : width + const effectiveSourceSize = this.effectiveSourceSize(ast.args[0], state) + const effectiveHeight = !Number.isFinite(height) ? effectiveSourceSize.height : height + const effectiveWidth = !Number.isFinite(width) ? effectiveSourceSize.width : width if (startsBelowFirstRow || startsRightOfFirstColumn) { return new ArraySize(width, height) From 60fbf903cfa23496525119a9bf097bc8238c293a Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Tue, 6 Oct 2026 17:23:45 +0100 Subject: [PATCH 31/37] docs: link the DROP pull request in the changelog 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 e24cf6d55e..b481e5ace5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) -- Added the `DROP` dynamic-array function. +- Added the `DROP` dynamic-array function. [#1796](https://github.com/handsontable/hyperformula/pull/1796) ### Fixed From ebd7363ddb5a065db2de8875d8bbb2d82771e882 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 14 Oct 2026 21:18:47 +0100 Subject: [PATCH 32/37] fix: return #SPILL! for unresolved DROP counts below the first row A whole-column or whole-row DROP result fits only when the formula starts within the number of rows or columns dropped. Size prediction applied that rule only to statically known counts, so a count from a cell reserved the source's current height wherever the formula was. On another sheet that spilled where Excel returns #SPILL!; on the source's own sheet the reservation made the column taller and the result threw "Resizing to smaller array". An unresolved count already counts as dropping nothing for the size bound; it now does for the placement rule too, so the result fits only in the first row or column. Documented in list-of-differences. Co-Authored-By: Claude Opus 5.5 --- docs/guide/list-of-differences.md | 1 + src/interpreter/plugin/ArrayPlugin.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 0292d6d2ce..2e8172f7ad 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -120,6 +120,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | TAKE | =TAKE(Data!A:A, , 1) | Spills to the source sheet's current height from row 1; returns SPILL below row 1. | No such function. | Spills all 1,048,576 rows from row 1, with blank source cells as 0; returns SPILL below row 1. | | DROP | =DROP(A1:A3, 3) | N/A | No such function. | CALC | | DROP | =DROP(Data!A:A, 1) | Spills to the source sheet's current height minus one row; returns SPILL below row 2. | No such function. | Spills all 1,048,575 remaining rows, with blank source cells as 0; returns SPILL below row 2. | +| DROP | =DROP(Data!A:A, B1) | Spills from row 1 only; returns SPILL below row 1, because the count is read after the result size is set. | No such function. | Spills 1,048,576 minus B1 rows when entered in row B1+1 or above; returns SPILL below. | | DROP | =DROP(A1:C3, {1,2}) | Uses the first count and drops one row. | No such function. | Returns a 1x2 array of VALUE errors. | | INT | =INT(-8.9) | -8 | -9 | -9 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index f9fd3cf08c..4255e15e98 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -193,6 +193,8 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche * it fits only when the formula starts no further from the first row or * column than the number of rows or columns dropped; otherwise the dimension * stays unbounded, which makes array-space validation report a spill error. + * An unresolved count drops nothing for this rule too, so its result fits + * only in the first row or column. * * @param {number} sourceDimension - The predicted source height or width, possibly unbounded. * @param {LiteralCount} count - The classified count; must not be invalid. @@ -204,7 +206,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche private predictDropDimension(sourceDimension: number, count: LiteralCount, anchor: number, effectiveDimension: number): number { const requestedDrop = count.kind === 'value' ? Math.abs(Math.trunc(count.value)) : 0 - if (!Number.isFinite(sourceDimension) && count.kind === 'value' && anchor > requestedDrop) { + if (!Number.isFinite(sourceDimension) && anchor > requestedDrop) { return Number.POSITIVE_INFINITY } From f16a63f26a2a3c7b1c1e6e77d8f371c765efc23c Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Thu, 15 Oct 2026 12:54:41 +0100 Subject: [PATCH 33/37] docs: list more DROP differences from Excel Add two general rows that apply to array functions whose size is predicted before evaluation: an error result fills the whole reserved area, and a size that depends on cell values reserves the upper bound, so an occupied cell inside it gives #SPILL! where Excel spills. Add a DROP row for ISREF, which is FALSE because DROP returns values rather than a reference. Co-Authored-By: Claude Opus 5.5 --- docs/guide/list-of-differences.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 2e8172f7ad..38e7785e00 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -46,6 +46,8 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | Ranges created with `:` | A1:A2

A$1:$A$2

A:C

1:2

Sheet1!A1:A2 | Allowed ranges consist of two addresses (A1:B5), columns (A:C) or rows (3:5).
They cannot be mixed or contain named expressions. | Everything allowed. | Same as Google Sheets. | | Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | To support all date, time and currency formats, set [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) configuration options. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | | Cell references inside inline arrays | ={A1, A2} | The array's value is calculated but not updated when the cells' values change. | The array's value is calculated and updated when the cells' values change. | ERROR: invalid array | +| Errors in array results | =DROP(Data!A1:C3, NA()) | The error fills the whole area reserved for the array (here 3x3), because the array's size is predicted before the formula is evaluated. | No such function. | A single N/A error. | +| Array size that depends on cell values | =DROP(Data!A1:C3, Counts!A1) in A1,
where Counts!A1 = 1 and A3 holds a value | Returns SPILL: the whole source (3x3) is reserved, because the count is read after the array's size is set. | No such function. | Spills the two remaining rows into A1:C2. | | SPLIT function | =SPLIT("Lorem ipsum dolor", 0) | This function works differently from Google Sheets version but should be sufficient to achieve the same functionality in most scenarios. Read SPLIT function description on [the Built-in Functions page](built-in-functions.md#text). | Different syntax and return value. | No such function. | | DATEVALUE function | =DATEVALUE("25/02/1991") | Type of the returned value: `CellValueDetailedType.NUMBER_DATE` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | | TIMEVALUE function | =TIMEVALUE("14:31") | Type of the returned value: `CellValueDetailedType.NUMBER_TIME` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** | @@ -122,6 +124,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | DROP | =DROP(Data!A:A, 1) | Spills to the source sheet's current height minus one row; returns SPILL below row 2. | No such function. | Spills all 1,048,575 remaining rows, with blank source cells as 0; returns SPILL below row 2. | | DROP | =DROP(Data!A:A, B1) | Spills from row 1 only; returns SPILL below row 1, because the count is read after the result size is set. | No such function. | Spills 1,048,576 minus B1 rows when entered in row B1+1 or above; returns SPILL below. | | DROP | =DROP(A1:C3, {1,2}) | Uses the first count and drops one row. | No such function. | Returns a 1x2 array of VALUE errors. | +| DROP | =ISREF(DROP(Data!A1:C3, 1)) | FALSE (returns values, not a reference) | No such function. | TRUE | | INT | =INT(-8.9) | -8 | -9 | -9 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | From d0e4a3c73ea6f6965b01182859631e1c0e414a19 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 21 Oct 2026 16:23:01 +0100 Subject: [PATCH 34/37] Register TAKE in the license capability table Co-Authored-By: Claude Opus 5.5 --- hyperformula/src/license/functionCapabilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hyperformula/src/license/functionCapabilities.ts b/hyperformula/src/license/functionCapabilities.ts index a26733ec5f..baccc751c4 100644 --- a/hyperformula/src/license/functionCapabilities.ts +++ b/hyperformula/src/license/functionCapabilities.ts @@ -95,7 +95,7 @@ const UNGROUPED_FUNCTIONS = [ 'PHI', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', 'STEYX', 'SUMX2MY2', 'SUMX2PY2', 'SYD', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', - 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', + 'TAKE', 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', ] From df8aa44cd0c2d19b50c830fa2a6102a59dcc4ac8 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 21 Oct 2026 16:35:16 +0100 Subject: [PATCH 35/37] Register DROP in the license capability table Co-Authored-By: Claude Opus 5.5 --- hyperformula/src/license/functionCapabilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hyperformula/src/license/functionCapabilities.ts b/hyperformula/src/license/functionCapabilities.ts index baccc751c4..3f1317968d 100644 --- a/hyperformula/src/license/functionCapabilities.ts +++ b/hyperformula/src/license/functionCapabilities.ts @@ -82,7 +82,7 @@ const UNGROUPED_FUNCTIONS = [ 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COVARIANCE.P', 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', 'DCOUNT', 'DCOUNTA', 'DDB', 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', - 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', + 'DOLLARFR', 'DPRODUCT', 'DROP', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FORMULATEXT', 'FVSCHEDULE', 'GAMMA', 'GAMMA.DIST', 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', 'HEX2OCT', 'HYPGEOM.DIST', From dc9cfb1932d4780cc17c015edb40cd96275c97e0 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Sat, 24 Oct 2026 23:33:03 +0100 Subject: [PATCH 36/37] feat: add TRIMRANGE function (HF-391) TRIMRANGE removes the blank rows and columns at the edges of a range or array. A cell of a range is blank only when it is empty, so a formula is kept even when its value is blank. Each trim code is an integer from 0 to 3; any other value is a #VALUE! error. A source with nothing left to keep returns #REF!. The result size is predicted before evaluation, so the untrimmed source is reserved and the result fills its top-left corner. Whole columns and rows follow TAKE's sheet-edge rule, now one helper shared by both functions; TAKE's results are unchanged. Adds the catalogue entry, the license table entry, the name in every language pack, list-of-differences rows and a changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/guide/list-of-differences.md | 5 + hyperformula/src/i18n/languages/csCZ.ts | 1 + hyperformula/src/i18n/languages/daDK.ts | 1 + hyperformula/src/i18n/languages/deDE.ts | 1 + hyperformula/src/i18n/languages/enGB.ts | 1 + hyperformula/src/i18n/languages/esES.ts | 1 + hyperformula/src/i18n/languages/fiFI.ts | 1 + hyperformula/src/i18n/languages/frFR.ts | 1 + hyperformula/src/i18n/languages/huHU.ts | 1 + hyperformula/src/i18n/languages/idID.ts | 1 + hyperformula/src/i18n/languages/itIT.ts | 1 + hyperformula/src/i18n/languages/nbNO.ts | 1 + hyperformula/src/i18n/languages/nlNL.ts | 1 + hyperformula/src/i18n/languages/plPL.ts | 1 + hyperformula/src/i18n/languages/ptPT.ts | 1 + hyperformula/src/i18n/languages/ruRU.ts | 1 + hyperformula/src/i18n/languages/svSE.ts | 1 + hyperformula/src/i18n/languages/trTR.ts | 1 + .../categories/lookup-and-reference.ts | 7 + .../src/interpreter/plugin/ArrayPlugin.ts | 381 +++++++++++++++++- .../src/license/functionCapabilities.ts | 2 +- 22 files changed, 400 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fe935ad4..a1d18b53cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829) - Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) - Added the `DROP` dynamic-array function. [#1796](https://github.com/handsontable/hyperformula/pull/1796) +- Added the `TRIMRANGE` dynamic-array function. ### Fixed diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index c41be7a4ff..b17705f7e5 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -126,6 +126,11 @@ To remove the differences, create [custom implementations](custom-functions.md) | DROP | =DROP(Data!A:A, B1) | Spills from row 1 only; returns SPILL below row 1, because the count is read after the result size is set. | No such function. | Spills 1,048,576 minus B1 rows when entered in row B1+1 or above; returns SPILL below. | | DROP | =DROP(A1:C3, {1,2}) | Uses the first count and drops one row. | No such function. | Returns a 1x2 array of VALUE errors. | | DROP | =ISREF(DROP(Data!A1:C3, 1)) | FALSE (returns values, not a reference) | No such function. | TRUE | +| TRIMRANGE | =TRIMRANGE(A1:C10) | Reserves the whole range (10x3): the trimmed values fill its top-left corner, the other cells stay blank, and a value in one of them makes the formula return SPILL. | No such function. | Spills only the trimmed rows and columns, and follows the data. | +| TRIMRANGE | =TRIMRANGE(Data!A:A) | Spills to the source sheet's current height from row 1; returns SPILL below row 1. | No such function. | Spills the trimmed rows from any row. | +| TRIMRANGE | =TRIMRANGE(A1:C3, {0,3}) | VALUE | No such function. | Returns a 1x2 array of VALUE errors. | +| TRIMRANGE | =ISREF(TRIMRANGE(A1:C3)) | FALSE (returns values, not a reference) | No such function. | TRUE | +| TRIMRANGE | =A1:.C10 | ERROR: the trim reference operators (`.:`, `:.`, `.:.`) are not supported. | ERROR: formula parse error. | Same as =TRIMRANGE(A1:C10, 2, 2). | | INT | =INT(-8.9) | -8 | -9 | -9 | | ISEVEN | =ISEVEN(2.5) | FALSE | TRUE | TRUE | | ISODD | =ISODD(3.5) | FALSE | TRUE | TRUE | diff --git a/hyperformula/src/i18n/languages/csCZ.ts b/hyperformula/src/i18n/languages/csCZ.ts index e1cded7ff5..151283aeaf 100644 --- a/hyperformula/src/i18n/languages/csCZ.ts +++ b/hyperformula/src/i18n/languages/csCZ.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'DNES', TRANSPOSE: 'TRANSPOZICE', TRIM: 'PROČISTIT', + TRIMRANGE: 'OŘÍZNOUTOBLAST', TRUE: 'PRAVDA', TRUNC: 'USEKNOUT', UNICHAR: 'UNICHAR', diff --git a/hyperformula/src/i18n/languages/daDK.ts b/hyperformula/src/i18n/languages/daDK.ts index 5f33989f94..86ac6eee50 100644 --- a/hyperformula/src/i18n/languages/daDK.ts +++ b/hyperformula/src/i18n/languages/daDK.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'IDAG', TRANSPOSE: 'TRANSPONER', TRIM: 'FJERN.OVERFLØDIGE.BLANKE', + TRIMRANGE: 'TRIMOMRÅDE', TRUE: 'TRUE', TRUNC: 'AFKORT', UNICHAR: 'UNICHAR', diff --git a/hyperformula/src/i18n/languages/deDE.ts b/hyperformula/src/i18n/languages/deDE.ts index 74303a762c..4d08a74dca 100644 --- a/hyperformula/src/i18n/languages/deDE.ts +++ b/hyperformula/src/i18n/languages/deDE.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'HEUTE', TRANSPOSE: 'MTRANS', TRIM: 'GLÄTTEN', + TRIMRANGE: 'ABSCHNBEREICH', TRUE: 'WAHR', TRUNC: 'KÜRZEN', UNICHAR: 'UNIZEICHEN', diff --git a/hyperformula/src/i18n/languages/enGB.ts b/hyperformula/src/i18n/languages/enGB.ts index 43b4205100..78de70b809 100644 --- a/hyperformula/src/i18n/languages/enGB.ts +++ b/hyperformula/src/i18n/languages/enGB.ts @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'TODAY', TRANSPOSE: 'TRANSPOSE', TRIM: 'TRIM', + TRIMRANGE: 'TRIMRANGE', TRUE: 'TRUE', TRUNC: 'TRUNC', UNICHAR: 'UNICHAR', diff --git a/hyperformula/src/i18n/languages/esES.ts b/hyperformula/src/i18n/languages/esES.ts index 7bbf669a3f..db4f3da7e6 100644 --- a/hyperformula/src/i18n/languages/esES.ts +++ b/hyperformula/src/i18n/languages/esES.ts @@ -239,6 +239,7 @@ export const dictionary: RawTranslationPackage = { TODAY: 'HOY', TRANSPOSE: 'TRANSPONER', TRIM: 'ESPACIOS', + TRIMRANGE: 'RECORTAR.RANGO', TRUE: 'VERDADERO', TRUNC: 'TRUNCAR', UNICHAR: 'UNICHAR', diff --git a/hyperformula/src/i18n/languages/fiFI.ts b/hyperformula/src/i18n/languages/fiFI.ts index 79c31be60a..6eff376beb 100644 --- a/hyperformula/src/i18n/languages/fiFI.ts +++ b/hyperformula/src/i18n/languages/fiFI.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'TÄMÄ.PÄIVÄ', TRANSPOSE: 'TRANSPONOI', TRIM: 'POISTA.VÄLIT', + TRIMRANGE: 'RAJAAALUE', TRUE: 'TOSI', TRUNC: 'KATKAISE', UNICHAR: 'UNICODEMERKKI', diff --git a/hyperformula/src/i18n/languages/frFR.ts b/hyperformula/src/i18n/languages/frFR.ts index 4995c04005..1f79cc9b5a 100644 --- a/hyperformula/src/i18n/languages/frFR.ts +++ b/hyperformula/src/i18n/languages/frFR.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'AUJOURDHUI', TRANSPOSE: 'TRANSPOSE', TRIM: 'SUPPRESPACE', + TRIMRANGE: 'SUPPR.PLAGE', TRUE: 'VRAI', TRUNC: 'TRONQUE', UNICHAR: 'UNICAR', diff --git a/hyperformula/src/i18n/languages/huHU.ts b/hyperformula/src/i18n/languages/huHU.ts index 0cee154cf6..541a13f704 100644 --- a/hyperformula/src/i18n/languages/huHU.ts +++ b/hyperformula/src/i18n/languages/huHU.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'MA', TRANSPOSE: 'TRANSZPONÁLÁS', TRIM: 'KIMETSZ', + TRIMRANGE: 'KIMETSZÉSITARTOMÁNY', TRUE: 'IGAZ', TRUNC: 'CSONK', UNICHAR: 'UNIKARAKTER', diff --git a/hyperformula/src/i18n/languages/idID.ts b/hyperformula/src/i18n/languages/idID.ts index aa39395e3b..d967f57da4 100644 --- a/hyperformula/src/i18n/languages/idID.ts +++ b/hyperformula/src/i18n/languages/idID.ts @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'HARI.INI', TRANSPOSE: 'TRANSPOS', TRIM: 'HAPUS.SPASI', + TRIMRANGE: 'TRIMRANGE', TRUE: 'BENAR', TRUNC: 'POTONG', UNICHAR: 'UNIKARAKTER', diff --git a/hyperformula/src/i18n/languages/itIT.ts b/hyperformula/src/i18n/languages/itIT.ts index d2882ea358..bac1abd64e 100644 --- a/hyperformula/src/i18n/languages/itIT.ts +++ b/hyperformula/src/i18n/languages/itIT.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'OGGI', TRANSPOSE: 'MATR.TRASPOSTA', TRIM: 'ANNULLA.SPAZI', + TRIMRANGE: 'ESCLUDI.VUOTE', TRUE: 'VERO', TRUNC: 'TRONCA', UNICHAR: 'CARATT.UNI', diff --git a/hyperformula/src/i18n/languages/nbNO.ts b/hyperformula/src/i18n/languages/nbNO.ts index f975466ed6..75c59a1995 100644 --- a/hyperformula/src/i18n/languages/nbNO.ts +++ b/hyperformula/src/i18n/languages/nbNO.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'IDAG', TRANSPOSE: 'TRANSPONER', TRIM: 'TRIMME', + TRIMRANGE: 'TRIMMEOMRÅDE', TRUE: 'SANN', TRUNC: 'AVKORT', UNICHAR: 'UNICODETEGN', diff --git a/hyperformula/src/i18n/languages/nlNL.ts b/hyperformula/src/i18n/languages/nlNL.ts index 8f50ea2650..f2db79ee21 100644 --- a/hyperformula/src/i18n/languages/nlNL.ts +++ b/hyperformula/src/i18n/languages/nlNL.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'VANDAAG', TRANSPOSE: 'TRANSPONEREN', TRIM: 'SPATIES.WISSEN', + TRIMRANGE: 'TRIMBEREIK', TRUE: 'WAAR', TRUNC: 'GEHEEL', UNICHAR: 'UNITEKEN', diff --git a/hyperformula/src/i18n/languages/plPL.ts b/hyperformula/src/i18n/languages/plPL.ts index 7c286370b1..b13cd1f60f 100644 --- a/hyperformula/src/i18n/languages/plPL.ts +++ b/hyperformula/src/i18n/languages/plPL.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'DZIŚ', TRANSPOSE: 'TRANSPONUJ', TRIM: 'USUŃ.ZBĘDNE.ODSTĘPY', + TRIMRANGE: 'PRZYTNIJZAKRES', TRUE: 'PRAWDA', TRUNC: 'LICZBA.CAŁK', UNICHAR: 'ZNAK.UNICODE', diff --git a/hyperformula/src/i18n/languages/ptPT.ts b/hyperformula/src/i18n/languages/ptPT.ts index e0412c4772..17a2dd4a82 100644 --- a/hyperformula/src/i18n/languages/ptPT.ts +++ b/hyperformula/src/i18n/languages/ptPT.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'HOJE', TRANSPOSE: 'TRANSPOR', TRIM: 'ARRUMAR', + TRIMRANGE: 'APARARINTERVALO', TRUE: 'VERDADEIRO', TRUNC: 'TRUNCAR', UNICHAR: 'CARACTUNI', diff --git a/hyperformula/src/i18n/languages/ruRU.ts b/hyperformula/src/i18n/languages/ruRU.ts index 9e774e14eb..4da13f847e 100644 --- a/hyperformula/src/i18n/languages/ruRU.ts +++ b/hyperformula/src/i18n/languages/ruRU.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'СЕГОДНЯ', TRANSPOSE: 'ТРАНСП', TRIM: 'СЖПРОБЕЛЫ', + TRIMRANGE: 'УРЕЗДИАПАЗОН', TRUE: 'ИСТИНА', TRUNC: 'ОТБР', UNICHAR: 'ЮНИСИМВ', diff --git a/hyperformula/src/i18n/languages/svSE.ts b/hyperformula/src/i18n/languages/svSE.ts index 2cbb3baf1c..f7043eff0a 100644 --- a/hyperformula/src/i18n/languages/svSE.ts +++ b/hyperformula/src/i18n/languages/svSE.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'IDAG', TRANSPOSE: 'TRANSPONERA', TRIM: 'RENSA', + TRIMRANGE: 'KLIPPOMRÅDE', TRUE: 'SANT', TRUNC: 'AVKORTA', UNICHAR: 'UNICHAR', diff --git a/hyperformula/src/i18n/languages/trTR.ts b/hyperformula/src/i18n/languages/trTR.ts index 8bbe3b4a75..53fe0c0959 100644 --- a/hyperformula/src/i18n/languages/trTR.ts +++ b/hyperformula/src/i18n/languages/trTR.ts @@ -239,6 +239,7 @@ const dictionary: RawTranslationPackage = { TODAY: 'BUGÜN', TRANSPOSE: 'DEVRİK_DÖNÜŞÜM', TRIM: 'KIRP', + TRIMRANGE: 'KIRPARALIĞI', TRUE: 'DOĞRU', TRUNC: 'NSAT', UNICHAR: 'UNICODEKARAKTERİ', diff --git a/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index cdf459d09a..f72310db63 100644 --- a/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts @@ -142,6 +142,13 @@ export const LOOKUP_AND_REFERENCE_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=TRANSPOSE(A1:C2)', '=TRANSPOSE(A1:A5)'], }, + TRIMRANGE: { + category: 'Lookup and reference', + shortDescription: 'Returns a range without the blank rows and columns at its edges. The result takes up the size of the range, or of its sheet\'s used area for a whole column or row; the cells the result does not fill stay blank.', + parameters: [{name: 'range', description: 'The range or array to trim. In a range, a cell is blank only when it is empty, so a formula is kept even when its value is blank; in an array, an element is blank when it has no value. When every row or column is trimmed away, the result is a REF error.'}, {name: 'trim_rows', description: 'Which blank rows to remove: 0 none, 1 leading, 2 trailing, 3 both. Any other value is a VALUE error. When omitted or empty, 3.'}, {name: 'trim_cols', description: 'Which blank columns to remove, with the same codes as trim_rows. When omitted or empty, 3.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=TRIMRANGE(A1:C10)', '=TRIMRANGE(A1:C10, 2, 0)', '=TRIMRANGE(A1:C10, , 1)'], + }, UNIQUE: { category: 'Lookup and reference', shortDescription: 'Returns the unique rows or columns of an array.', diff --git a/hyperformula/src/interpreter/plugin/ArrayPlugin.ts b/hyperformula/src/interpreter/plugin/ArrayPlugin.ts index 4255e15e98..c8b2ae11f9 100644 --- a/hyperformula/src/interpreter/plugin/ArrayPlugin.ts +++ b/hyperformula/src/interpreter/plugin/ArrayPlugin.ts @@ -5,18 +5,19 @@ import {AbsoluteCellRange} from '../../AbsoluteCellRange' import {ArraySize} from '../../ArraySize' -import {CellError, ErrorType} from '../../Cell' +import {CellError, ErrorType, SimpleCellAddress} from '../../Cell' +import {ArrayFormulaVertex, EmptyCellVertex} from '../../DependencyGraph' import {ErrorMessage} from '../../error-message' import {Ast, AstNodeType, ProcedureAst} from '../../parser' import {coerceScalarToBoolean} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' -import {getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {EmptyValue, getRawValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' import {SimpleRangeValue} from '../../SimpleRangeValue' import {BooleanPlugin} from './BooleanPlugin' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' /** - * Classifies a TAKE or DROP count expression whose value may be known before evaluation. + * Classifies a count or code expression of TAKE, DROP or TRIMRANGE whose value may be known before evaluation. * * @internal */ @@ -33,10 +34,29 @@ type LiteralCount = */ type TakeLiteralDimension = LiteralCount | {kind: 'unbounded'} +/** + * The rows and columns of a TRIMRANGE source that hold content, relative to the source. + * + * @internal + */ +interface ContentBounds { + top: number, + bottom: number, + left: number, + right: number, +} + +/** + * TRIMRANGE trim codes: 0 trims nothing, 1 leading blank lines, 2 trailing blank lines, 3 both. + */ +const TRIM_LEADING = 1 +const TRIM_TRAILING = 2 +const TRIM_BOTH = 3 + export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { /** - * Evaluates the dependency-free subset of TAKE and DROP count expressions - * used for static result-size prediction. + * Evaluates the dependency-free subset of TAKE and DROP count expressions, + * and of TRIMRANGE code expressions, used for static result-size prediction. * * @param {Ast | undefined} argument - The count expression to inspect before evaluation. * @returns {LiteralCount} The constant value, an invalid-literal marker, or an unresolved marker. @@ -150,7 +170,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } /** - * Resolves a direct TAKE or DROP source reference without evaluating its values. + * Resolves a direct TAKE, DROP or TRIMRANGE source reference without evaluating its values. * The range supplies materialized dimensions only; spill placement never * depends on its sheet. * @@ -215,7 +235,7 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche } /** - * Measures a TAKE or DROP source whose predicted size is unbounded. A direct + * Measures a TAKE, DROP or TRIMRANGE source whose predicted size is unbounded. A direct * whole-column or whole-row reference is measured by its effective size; a * computed array falls back to the configured sheet limits. * @@ -312,6 +332,19 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche ], vectorizationForbidden: true, }, + 'TRIMRANGE': { + method: 'trimrange', + sizeOfResultArrayMethod: 'trimrangeArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + // Coerced in the method: an empty text is #VALUE!, and both codes are coerced before either is range-checked. + // SCALAR, so that an error in either code reaches the method and the codes are checked in order. + {argumentType: FunctionArgumentType.SCALAR, optionalArg: true, defaultValue: TRIM_BOTH, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.SCALAR, optionalArg: true, defaultValue: TRIM_BOTH, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, 'VSTACK': { method: 'vstack', sizeOfResultArrayMethod: 'vstackArraySize', @@ -591,23 +624,347 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const width = columnDimension.kind === 'value' ? Math.min(sourceSize.width, columnDimension.value, this.config.maxColumns) : sourceSize.width - const startsBelowFirstRow = rowDimension.kind === 'unbounded' && !Number.isFinite(height) && state.formulaAddress.row !== 0 - const startsRightOfFirstColumn = columnDimension.kind === 'unbounded' && !Number.isFinite(width) && state.formulaAddress.col !== 0 - const effectiveSourceSize = this.effectiveSourceSize(ast.args[0], state) - const effectiveHeight = !Number.isFinite(height) ? effectiveSourceSize.height : height - const effectiveWidth = !Number.isFinite(width) ? effectiveSourceSize.width : width + + return this.sizeAtSheetEdge( + ast.args[0], state, width, height, columnDimension.kind === 'unbounded', rowDimension.kind === 'unbounded', + ) + } + + /** + * Finishes the predicted size of a result that may span a whole column or + * row. A spreadsheet result of that kind reaches the sheet edge, so it fits + * only when it starts in the first row (column); elsewhere its unbounded + * dimension is kept, so that array-space validation reports a spill error. + * In the first row (column), or for a dimension the rule does not apply to, + * an unbounded dimension is replaced by the source's effective size. + * + * @param {Ast} source - The source expression, measured when a dimension is unbounded. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @param {number} width - The predicted width, possibly unbounded. + * @param {number} height - The predicted height, possibly unbounded. + * @param {boolean} widthReachesSheetEdge - Whether an unbounded width must start in the first column. + * @param {boolean} heightReachesSheetEdge - Whether an unbounded height must start in the first row. + * @returns {ArraySize} The predicted size, an unbounded one that will not fit, or an invalid size when empty. + * @internal + */ + private sizeAtSheetEdge( + source: Ast, + state: InterpreterState, + width: number, + height: number, + widthReachesSheetEdge: boolean, + heightReachesSheetEdge: boolean, + ): ArraySize { + const startsBelowFirstRow = heightReachesSheetEdge && !Number.isFinite(height) && state.formulaAddress.row !== 0 + const startsRightOfFirstColumn = widthReachesSheetEdge && !Number.isFinite(width) && state.formulaAddress.col !== 0 if (startsBelowFirstRow || startsRightOfFirstColumn) { return new ArraySize(width, height) } + const effectiveSourceSize = this.effectiveSourceSize(source, state) + const effectiveHeight = Number.isFinite(height) ? height : effectiveSourceSize.height + const effectiveWidth = Number.isFinite(width) ? width : effectiveSourceSize.width + if (effectiveHeight < 1 || effectiveWidth < 1) { return ArraySize.error() } + // A fresh size: a reference's size would make the formula a single cell. return new ArraySize(effectiveWidth, effectiveHeight) } + /** + * Corresponds to TRIMRANGE(range, [trim_rows], [trim_cols]). + * + * Removes the blank rows and columns at the edges of the range and returns + * the rest as values. Each code is 0 (trim nothing), 1 (leading blank lines), + * 2 (trailing blank lines) or 3 (both, the default); any other value is a + * #VALUE! error. A cell of a range is blank when it is empty: a formula is + * content even when its value is blank. A source with nothing left to keep + * returns a #REF! error. + * + * @param {ProcedureAst} ast - The parsed TRIMRANGE call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} The trimmed source values or a spreadsheet error. + */ + public trimrange(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + // An empty range argument would otherwise evaluate to a blank cell and be trimmed away. + if (ast.args[0]?.type === AstNodeType.EMPTY) { + return new CellError(ErrorType.VALUE, ErrorMessage.EmptyArg) + } + + return this.runFunction(ast.args, state, this.metadata('TRIMRANGE'), + (range: SimpleRangeValue, trimRows: InternalScalarValue, trimColumns: InternalScalarValue) => { + // A spreadsheet lifts an array of codes into an array of results; a function returning an array cannot be + // lifted here, so a code larger than one cell is an error. + if (this.isMultiCellArgument(ast.args[1], state) || this.isMultiCellArgument(ast.args[2], state)) { + return new CellError(ErrorType.VALUE, ErrorMessage.ScalarExpected) + } + + const rowMode = this.coerceTrimCode(trimRows) + if (rowMode instanceof CellError) { + return rowMode + } + const columnMode = this.coerceTrimCode(trimColumns) + if (columnMode instanceof CellError) { + return columnMode + } + if (!this.isTrimMode(rowMode) || !this.isTrimMode(columnMode)) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + const height = range.height() + const width = range.width() + const bounds = this.contentBounds(range, range.range ?? this.directSourceCell(ast.args[0], state)) + + if (bounds === undefined) { + return rowMode === 0 && columnMode === 0 + ? this.sliceSourceArray(range, 0, 0, height, width) + : new CellError(ErrorType.REF, ErrorMessage.EmptyRange) + } + + const top = (rowMode & TRIM_LEADING) !== 0 ? bounds.top : 0 + const bottom = (rowMode & TRIM_TRAILING) !== 0 ? bounds.bottom : height - 1 + const left = (columnMode & TRIM_LEADING) !== 0 ? bounds.left : 0 + const right = (columnMode & TRIM_TRAILING) !== 0 ? bounds.right : width - 1 + + return this.sliceSourceArray(range, top, left, bottom - top + 1, right - left + 1) + } + ) + } + + /** + * Calculates the spilled array size of TRIMRANGE. Which rows and columns are + * blank is known only after evaluation, so the untrimmed source is reserved: + * the result fills its top-left corner and the rest stays blank. A + * whole-column or whole-row source follows the same sheet-edge rule as TAKE + * (see `sizeAtSheetEdge`). A trim code that is certainly + * invalid is reported as an invalid size, so the formula is a single error + * cell instead of reserving space. + * + * @param {ProcedureAst} ast - The parsed TRIMRANGE call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + public trimrangeArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1 || ast.args.length > 3) { + return ArraySize.error() + } + + if ( + !this.isPossibleTrimCode(ast.args[1]) + || !this.isPossibleTrimCode(ast.args[2]) + || this.isMultiCellArgument(ast.args[1], state) + || this.isMultiCellArgument(ast.args[2], state) + ) { + return ArraySize.error() + } + + const metadata = this.metadata('TRIMRANGE') + const sourceSize = this.arraySizeForAst( + ast.args[0], + new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), + ) + + if (sourceSize.isScalar() && !sourceSize.isRef) { + return ArraySize.scalar() + } + + return this.sizeAtSheetEdge(ast.args[0], state, sourceSize.width, sourceSize.height, true, true) + } + + /** + * Coerces a TRIMRANGE trim code to a number. An error is returned as it is, + * and an empty text is a #VALUE! error rather than 0; numbers, numeric text, + * booleans and blank cells coerce as numbers. + * + * @param {InternalScalarValue} value - The code as passed to the function. + * @returns {number | CellError} The code as a number, not yet range-checked, or an error. + */ + private coerceTrimCode(value: InternalScalarValue): number | CellError { + if (value instanceof CellError) { + return value + } + if (value === '') { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberCoercion) + } + const coercedValue = this.arithmeticHelper.coerceScalarToNumberOrError(value) + return coercedValue instanceof CellError ? coercedValue : getRawValue(coercedValue) + } + + /** + * Checks that a number is a TRIMRANGE trim code: an integer from 0 to 3. + * Fractions are rejected, not truncated. + * + * @param {number} value - The coerced code. + * @returns {boolean} Whether the code is valid. + */ + private isTrimMode(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= TRIM_BOTH + } + + /** + * Checks a TRIMRANGE trim code before evaluation. Omitted, empty and + * unresolved codes may be valid; a literal is valid only when it is a trim + * code, and an empty text never is. + * + * @param {Ast | undefined} argument - The code expression to inspect. + * @returns {boolean} False when the code is certainly invalid. + */ + private isPossibleTrimCode(argument: Ast | undefined): boolean { + if (argument === undefined || argument.type === AstNodeType.EMPTY) { + return true + } + if (argument.type === AstNodeType.STRING && argument.value === '') { + return false + } + + const code = this.parseLiteralCount(argument) + if (code.kind === 'invalid') { + return false + } + return code.kind === 'unresolved' || this.isTrimMode(code.value) + } + + /** + * Checks whether an argument is predicted to be larger than one cell: an + * array constant, a multi-cell range or an array-returning function. + * + * @param {Ast | undefined} argument - The argument expression to inspect. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {boolean} True when the argument is predicted to have more than one cell. + */ + private isMultiCellArgument(argument: Ast | undefined, state: InterpreterState): boolean { + if (argument === undefined || argument.type === AstNodeType.EMPTY) { + return false + } + const size = this.arraySizeForAst(argument, new InterpreterState(state.formulaAddress, true)) + return size.width > 1 || size.height > 1 + } + + /** + * Resolves a TRIMRANGE source written as a single cell reference to a + * one-cell range, so that the cell is judged by its content like any cell of + * a range. A single cell reference evaluates to its value, which carries no + * range. + * + * @param {Ast | undefined} argument - The source expression to inspect. + * @param {InterpreterState} state - The formula state used to resolve relative addresses. + * @returns {AbsoluteCellRange | undefined} The one-cell range, or `undefined` for any other source. + */ + private directSourceCell(argument: Ast | undefined, state: InterpreterState): AbsoluteCellRange | undefined { + if (argument?.type === AstNodeType.PARENTHESIS) { + return this.directSourceCell(argument.expression, state) + } + if (argument?.type !== AstNodeType.CELL_REFERENCE) { + return undefined + } + return AbsoluteCellRange.spanFrom(argument.reference.toSimpleCellAddress(state.formulaAddress), 1, 1) + } + + /** + * Finds the rows and columns of a TRIMRANGE source that hold content. A + * range is read cell by cell, only inside the part of it that lies within + * its sheet's current height and width, since nothing beyond holds content. + * A computed array is read element by element, a blank element being an + * empty value. Both are scanned inward from the edges. + * + * @param {SimpleRangeValue} source - The evaluated source. + * @param {AbsoluteCellRange | undefined} sourceRange - The cells the source was read from, or `undefined` for a computed array. + * @returns {ContentBounds | undefined} The bounds relative to the source, or `undefined` when every cell is blank. + */ + private contentBounds(source: SimpleRangeValue, sourceRange: AbsoluteCellRange | undefined): ContentBounds | undefined { + if (sourceRange === undefined) { + const data = source.data + return this.scanContentBounds(data.length, source.width(), (row, column) => data[row][column] !== EmptyValue) + } + + const address: SimpleCellAddress = {sheet: sourceRange.sheet, row: 0, col: 0} + const height = Math.min(sourceRange.end.row, this.dependencyGraph.getSheetHeight(sourceRange.sheet) - 1) - sourceRange.start.row + 1 + const width = Math.min(sourceRange.end.col, this.dependencyGraph.getSheetWidth(sourceRange.sheet) - 1) - sourceRange.start.col + 1 + return this.scanContentBounds(height, width, (row, column) => { + address.row = sourceRange.start.row + row + address.col = sourceRange.start.col + column + return this.isContentCell(address) + }) + } + + /** + * Scans a block inward from each edge for the first row and column that + * hold content: rows from the top and the bottom, then columns from the left + * and the right within those rows. The cost is the blank border plus one + * line per edge, not the whole block, unless the block is blank. + * + * @param {number} height - The number of rows to scan; zero or less means none. + * @param {number} width - The number of columns to scan; zero or less means none. + * @param {(row: number, column: number) => boolean} hasContent - Whether the cell at a block position holds content. + * @returns {ContentBounds | undefined} The bounds within the block, or `undefined` when every cell is blank. + */ + private scanContentBounds(height: number, width: number, hasContent: (row: number, column: number) => boolean): ContentBounds | undefined { + const rowHasContent = (row: number) => { + for (let column = 0; column < width; column++) { + if (hasContent(row, column)) { + return true + } + } + return false + } + const columnHasContent = (column: number, top: number, bottom: number) => { + for (let row = top; row <= bottom; row++) { + if (hasContent(row, column)) { + return true + } + } + return false + } + + let top = 0 + while (top < height && !rowHasContent(top)) { + top++ + } + if (top >= height) { + return undefined + } + let bottom = height - 1 + while (bottom > top && !rowHasContent(bottom)) { + bottom-- + } + let left = 0 + while (!columnHasContent(left, top, bottom)) { + left++ + } + let right = width - 1 + while (right > left && !columnHasContent(right, top, bottom)) { + right-- + } + + return {top, bottom, left, right} + } + + /** + * Checks whether a cell holds content for TRIMRANGE. An empty cell is blank; + * any formula is content, even one whose value is blank, including the cell + * that holds an array formula; any other cell of a spilled array is content + * when its value is not blank, so the blank padding of another array result + * counts as blank. + * + * @param {SimpleCellAddress} address - The cell to inspect. + * @returns {boolean} True when the cell holds content. + */ + private isContentCell(address: SimpleCellAddress): boolean { + const vertex = this.dependencyGraph.getCell(address) + if (vertex === undefined || vertex instanceof EmptyCellVertex) { + return false + } + if (vertex instanceof ArrayFormulaVertex) { + const formulaAddress = vertex.getAddress(this.dependencyGraph.lazilyTransformingAstService) + const holdsFormula = formulaAddress.sheet === address.sheet && formulaAddress.row === address.row && formulaAddress.col === address.col + return holdsFormula || this.dependencyGraph.getCellValue(address) !== EmptyValue + } + return true + } + /** * Corresponds to VSTACK(array1, [array2], ...) * diff --git a/hyperformula/src/license/functionCapabilities.ts b/hyperformula/src/license/functionCapabilities.ts index 3f1317968d..5cc76f6834 100644 --- a/hyperformula/src/license/functionCapabilities.ts +++ b/hyperformula/src/license/functionCapabilities.ts @@ -95,7 +95,7 @@ const UNGROUPED_FUNCTIONS = [ 'PHI', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', 'STEYX', 'SUMX2MY2', 'SUMX2PY2', 'SYD', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', - 'TAKE', 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', + 'TAKE', 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'TRIMRANGE', 'UNICODE', 'VARA', 'VARPA', 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', ] From c9973b8dbcfb2ae4d961de7c739a03d036165a97 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Sat, 24 Oct 2026 23:37:26 +0100 Subject: [PATCH 37/37] docs: link the TRIMRANGE pull request in the changelog 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 a1d18b53cc..11cbf80716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829) - Added the `TAKE` dynamic-array function. [#1722](https://github.com/handsontable/hyperformula/pull/1722) - Added the `DROP` dynamic-array function. [#1796](https://github.com/handsontable/hyperformula/pull/1796) -- Added the `TRIMRANGE` dynamic-array function. +- Added the `TRIMRANGE` dynamic-array function. [#1842](https://github.com/handsontable/hyperformula/pull/1842) ### Fixed