diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e1da14..44e071642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698) - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) ## [3.3.0] - 2026-05-20 diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 5798f0404..de895e2df 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -65,6 +65,8 @@ Total number of functions: **{{ $page.functionsCount }}** | FILTER | Filters an array, based on multiple conditions (boolean arrays). | FILTER(SourceArray, BoolArray1, BoolArray2, ...BoolArrayN) | | ARRAY_CONSTRAIN | Truncates an array to given dimensions. | ARRAY_CONSTRAIN(Array, Height, Width) | | SEQUENCE | Returns an array of sequential numbers. | SEQUENCE(Rows, [Cols], [Start], [Step]) | +| VSTACK | Stacks arrays vertically into a single array. | VSTACK(Array1, [Array2], ...[ArrayN]) | +| HSTACK | Stacks arrays horizontally into a single array. | HSTACK(Array1, [Array2], ...[ArrayN]) | ### Date and time diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 71ce56b14..8a79738b0 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 ea5a1e4ce..90cd9281d 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 24a8ec03b..1657fd943 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESSE', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index e878f897a..bd5d92213 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADDRESS', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index d36d14fd0..7287ada3a 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -19,6 +19,8 @@ export const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 5735278d8..ea34d1c32 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 044c70715..43a0a2d31 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 3a5119222..7a2872096 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'CÍM', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index b07d9aad8..4f2cc4b77 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ALAMAT', 'ARRAY_CONSTRAIN': 'BATASAN.MATRIKS', ARRAYFORMULA: 'RUMUS.MATRIKS', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 2716d3471..cab8c97d5 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 80d9948f6..0d8028ab7 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 57d6fddb9..bc0ac0e2b 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 251a35bba..dfe6c40b5 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 3195612f0..fddb84e8a 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + 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 cfbf51e59..9c7625419 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'АДРЕС', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index bce2c4cf8..ecc15d463 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRESS', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index af2c25390..eee1f4dd3 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -19,6 +19,8 @@ const dictionary: RawTranslationPackage = { }, functions: { FILTER: 'FILTER', + VSTACK: 'VSTACK', + HSTACK: 'HSTACK', ADDRESS: 'ADRES', 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', diff --git a/src/interpreter/plugin/ArrayPlugin.ts b/src/interpreter/plugin/ArrayPlugin.ts index d9915f7f4..27b47096e 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -42,7 +42,25 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche {argumentType: FunctionArgumentType.RANGE}, ], repeatLastArgs: 1, - } + }, + 'VSTACK': { + method: 'vstack', + sizeOfResultArrayMethod: 'vstackArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + ], + repeatLastArgs: 1, + }, + 'HSTACK': { + method: 'hstack', + sizeOfResultArrayMethod: 'hstackArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + ], + repeatLastArgs: 1, + }, } public arrayformula(ast: ProcedureAst, state: InterpreterState): InterpreterValue { @@ -147,4 +165,133 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche const height = Math.max(...(subChecks).map(val => val.height)) return new ArraySize(width, height) } + + /** + * Corresponds to VSTACK(array1, [array2], ...) + * + * Stacks the input arrays vertically, one on top of another, into a single array. + * The result has as many rows as the inputs combined and as many columns as the + * widest input. Cells of narrower inputs are padded on the right with the #N/A + * error, matching the behaviour of Excel and Google Sheets. + * + * @param ast + * @param state + */ + public vstack(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('VSTACK'), (...ranges: SimpleRangeValue[]) => { + const width = Math.max(...ranges.map(range => range.width())) + const result: InternalScalarValue[][] = [] + + for (const range of ranges) { + for (const row of range.data) { + result.push(this.padRowToWidth(row, width)) + } + } + + return SimpleRangeValue.onlyValues(result) + }) + } + + /** + * Calculates the spilled array size of VSTACK: the width is the widest input + * and the height is the sum of all input heights. + * + * @param ast + * @param state + */ + public vstackArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1) { + return ArraySize.error() + } + + const subChecks = this.stackSubChecks(ast, state, 'VSTACK') + const width = Math.max(...subChecks.map(size => size.width)) + const height = subChecks.reduce((total, size) => total + size.height, 0) + return new ArraySize(width, height) + } + + /** + * Corresponds to HSTACK(array1, [array2], ...) + * + * Stacks the input arrays horizontally, side by side, into a single array. + * The result has as many columns as the inputs combined and as many rows as the + * tallest input. Cells of shorter inputs are padded at the bottom with the #N/A + * error, matching the behaviour of Excel and Google Sheets. + * + * @param ast + * @param state + */ + public hstack(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('HSTACK'), (...ranges: SimpleRangeValue[]) => { + const height = Math.max(...ranges.map(range => range.height())) + const result: InternalScalarValue[][] = [...Array(height).keys()].map(() => []) + + for (const range of ranges) { + const data = range.data + const width = range.width() + for (let row = 0; row < height; row++) { + const sourceRow = row < data.length ? data[row] : undefined + for (let col = 0; col < width; col++) { + // Pad both missing rows (sourceRow === undefined) and short rows + // (col beyond the row's length) with #N/A, exactly as VSTACK does. + result[row].push(sourceRow !== undefined && col < sourceRow.length + ? sourceRow[col] + : new CellError(ErrorType.NA, ErrorMessage.ValueNotFound)) + } + } + } + + return SimpleRangeValue.onlyValues(result) + }) + } + + /** + * Calculates the spilled array size of HSTACK: the width is the sum of all + * input widths and the height is the tallest input. + * + * @param ast + * @param state + */ + public hstackArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1) { + return ArraySize.error() + } + + const subChecks = this.stackSubChecks(ast, state, 'HSTACK') + const width = subChecks.reduce((total, size) => total + size.width, 0) + const height = Math.max(...subChecks.map(size => size.height)) + return new ArraySize(width, height) + } + + /** + * Resolves the array size of every argument of a stacking function, enabling + * array arithmetic for the arguments when the function's metadata requests it. + * + * @param ast + * @param state + * @param functionName - the stacking function whose metadata drives the array-arithmetic flag + */ + private stackSubChecks(ast: ProcedureAst, state: InterpreterState, functionName: 'VSTACK' | 'HSTACK'): ArraySize[] { + const metadata = this.metadata(functionName) + return ast.args.map((arg) => this.arraySizeForAst(arg, new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)))) + } + + /** + * Returns a copy of the given row resized to exactly `width` cells: longer + * rows are truncated and shorter rows are padded on the right with #N/A. Used + * by VSTACK to align every stacked row to the widest input. + * + * @param row - the source row to resize + * @param width - the target number of cells + */ + private padRowToWidth(row: InternalScalarValue[], width: number): InternalScalarValue[] { + if (row.length >= width) { + return row.slice(0, width) + } + const padded = row.slice() + while (padded.length < width) { + padded.push(new CellError(ErrorType.NA, ErrorMessage.ValueNotFound)) + } + return padded + } }