diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..b05ffe9ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- 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 `TOROW` and `TOCOL` dynamic-array functions. [#1828](https://github.com/handsontable/hyperformula/pull/1828) + ### Fixed - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 91b161d94a..e67d5fa4c8 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -61,6 +61,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. +### TOROW and TOCOL functions + +* When formulas on two sheets flatten each other's whole columns below the first row and skip blank cells, for example `=TOCOL(Sheet2!A:A, 1)` in cell B5 of Sheet1 and `=TOCOL(Sheet1!A:A, 1)` in cell B5 of Sheet2, the space reserved for each result counts toward the size of the other sheet. Both sheets get taller than their data, and taller again each time one of these formulas is entered again. The results stay correct. TOROW behaves the same way with whole rows right of the first column. + ### 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/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index a109f82616..4916580b83 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** | @@ -116,6 +118,28 @@ 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 | +| 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. | +| DROP | =ISREF(DROP(Data!A1:C3, 1)) | FALSE (returns values, not a reference) | No such function. | TRUE | +| TOCOL | =TOCOL(A1:A3, 1)
where A1:A3 are empty | N/A | (measure) | CALC | +| TOCOL | =TOCOL(A1:A5, 1)
where A1:A5 = 1, (empty), 3, (empty), 5 | 1, 3, 5, then two blank cells: the source size (5x1) is reserved, because the skipped elements are known only after the array's size is set. | (measure) | 1, 3, 5 | +| TOCOL | =TOCOL(A1:C3, 0, {0,1})
where A1:C3 = 1, 2, 3; 4, 5, 6; 7, 8, 9 | Uses the first value of the array: 1 to 9 in one column, read by row. | (measure) | Returns a 1x2 array: 1, 1. | +| TOCOL | =ROWS(TOCOL(Data!A:A)) | The source sheet's current height. `=TOCOL(Data!A:A)` spills from row 1 only and returns SPILL below row 1. | (measure) | 1048576 | +| TOCOL | =TOCOL(Data!A:B) | SPILL | (measure) | NUM | +| TOCOL | =TOCOL(A:A, 1)
entered in D5, where A1:A3 = 1, 2, 3 | SPILL. On the formula's own sheet, a whole column spills from row 1 only, and two or more whole columns return SPILL in any row, whatever the `ignore` value, because the result would make the sheet taller. | (measure) | 1, 2, 3 | +| TOCOL | =TOCOL(A:A+0, 1)
entered in D5, where A1:A3 = 1, 2, 3 | SPILL. A whole column computed by a formula, such as `A:A+0`, `Data!A:A+0` or `DROP(Data!A:A, 1)`, is sized by the `maxRows` setting. On any sheet, it spills from row 1 only, and two or more columns return SPILL in any row, whatever the `ignore` value. | (measure) | (measure) | +| TOROW | =TOROW(A1:A3, 1)
where A1:A3 are empty | N/A | (measure) | CALC | +| TOROW | =TOROW(A1:A5, 1)
where A1:A5 = 1, (empty), 3, (empty), 5 | 1, 3, 5, then two blank cells: the source size (1x5) is reserved, because the skipped elements are known only after the array's size is set. | (measure) | 1, 3, 5 | +| TOROW | =TOROW(A1:C3, 0, {0,1})
where A1:C3 = 1, 2, 3; 4, 5, 6; 7, 8, 9 | Uses the first value of the array: 1 to 9 in one row, read by row. | (measure) | Returns a 1x2 array: 1, 1. | +| TOROW | =COLUMNS(TOROW(Data!1:1)) | The source sheet's current width. `=TOROW(Data!1:1)` spills from column A only and returns SPILL right of column A. | (measure) | 16384 | +| TOROW | =COLUMNS(TOROW(Data!A:A)) | The source sheet's current height. | (measure) | 1048576 | +| TOROW | =COLUMNS(TOROW(Data!A:B)) | Twice the source sheet's current height. | (measure) | NUM | +| TOROW | =TOROW(1:1, 1)
entered in E5, where A1:C1 = 1, 2, 3 | SPILL. On the formula's own sheet, a whole row spills from column A only, and two or more whole rows return SPILL in any column, whatever the `ignore` value, because the result would make the sheet wider. | (measure) | (measure) | +| TOROW | =TOROW(1:1+0, 1)
entered in E5, where A1:C1 = 1, 2, 3 | SPILL. A whole row computed by a formula, such as `1:1+0`, `Data!1:1+0` or `DROP(Data!1:1, , 1)`, is sized by the `maxColumns` setting. On any sheet, it spills from column A only, and two or more rows return SPILL in any column, whatever the `ignore` value. | (measure) | (measure) | | 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/error-message.ts b/src/error-message.ts index 4b80ee06fa..cf37678918 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -67,6 +67,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/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index fea286083c..c6d7a36a42 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ODPIS.NELIN', T: 'T', + TAKE: 'VZÍT', TAN: 'TG', TANH: 'TGH', TBILLEQ: 'TBILLEQ', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEXTJOIN', TIME: 'ČAS', TIMEVALUE: 'ČASHODN', + TOCOL: 'DO.SLOUPCE', TODAY: 'DNES', + TOROW: 'DO.ŘÁDKU', TRANSPOSE: 'TRANSPOZICE', TRIM: 'PROČISTIT', TRUE: 'PRAVDA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 9264551249..8e0df0af1d 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAFSKRIVNING', T: 'T', + TAKE: 'TAG', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'STATSOBLIGATION', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVÆRDI', + TOCOL: 'TILKOLONNE', TODAY: 'IDAG', + TOROW: 'TILRÆKKE', TRANSPOSE: 'TRANSPONER', TRIM: 'FJERN.OVERFLØDIGE.BLANKE', TRUE: 'TRUE', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 4d3d39319b..78720a03dc 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'DIA', T: 'T', + TAKE: 'ÜBERNEHMEN', TAN: 'TAN', TANH: 'TANHYP', TBILLEQ: 'TBILLÄQUIV', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEXTVERKETTEN', TIME: 'ZEIT', TIMEVALUE: 'ZEITWERT', + TOCOL: 'ZUSPALTE', TODAY: 'HEUTE', + TOROW: 'ZUZEILE', TRANSPOSE: 'MTRANS', TRIM: 'GLÄTTEN', TRUE: 'WAHR', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732cd..834567d1a9 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', @@ -227,6 +228,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'SWITCH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEQ', @@ -236,7 +238,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEXTJOIN', TIME: 'TIME', TIMEVALUE: 'TIMEVALUE', + TOCOL: 'TOCOL', TODAY: 'TODAY', + TOROW: 'TOROW', TRANSPOSE: 'TRANSPOSE', TRIM: 'TRIM', TRUE: 'TRUE', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 9c6b817b98..a9400065f3 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', @@ -225,6 +226,7 @@ export const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'TOMAR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'LETRA.DE.TEST.EQV.A.BONO', @@ -234,7 +236,9 @@ export const dictionary: RawTranslationPackage = { TEXTJOIN: 'UNIRCADENAS', TIME: 'NSHORA', TIMEVALUE: 'HORANUMERO', + TOCOL: 'ENCOL', TODAY: 'HOY', + TOROW: 'ENFILA', TRANSPOSE: 'TRANSPONER', TRIM: 'ESPACIOS', TRUE: 'VERDADERO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 99ff05708f..5fc2b1d424 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'VUOSIPOISTO', T: 'T', + TAKE: 'OTA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OBLIG.TUOTTOPROS', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEKSTI.YHDISTÄ', TIME: 'AIKA', TIMEVALUE: 'AIKA_ARVO', + TOCOL: 'SARAKKEESEEN', TODAY: 'TÄMÄ.PÄIVÄ', + TOROW: 'RIVIIN', TRANSPOSE: 'TRANSPONOI', TRIM: 'POISTA.VÄLIT', TRUE: 'TOSI', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index f7b9be45f7..b4f577e4a1 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'PRENDRE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TAUX.ESCOMPTE.R', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'JOINDRE.TEXTE', TIME: 'TEMPS', TIMEVALUE: 'TEMPSVAL', + TOCOL: 'DANSCOL', TODAY: 'AUJOURDHUI', + TOROW: 'DANSLIGNE', TRANSPOSE: 'TRANSPOSE', TRIM: 'SUPPRESPACE', TRUE: 'VRAI', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e40219553a..e178f73e9c 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÉSZÖ', T: 'T', + TAKE: 'ÁTHELYEZ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'KJEGY.EGYENÉRT', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'SZÖVEGÖSSZEFŰZÉS', TIME: 'IDŐ', TIMEVALUE: 'IDŐÉRTÉK', + TOCOL: 'OSZLOPHOZ', TODAY: 'MA', + TOROW: 'SORHOZ', TRANSPOSE: 'TRANSZPONÁLÁS', TRIM: 'KIMETSZ', TRUE: 'IGAZ', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db6..a81aa5048b 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', @@ -227,6 +228,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'BERALIH', SYD: 'SYD', T: 'T', + TAKE: 'TAKE', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILL.SAMA', @@ -236,7 +238,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEXTJOIN', TIME: 'WAKTU', TIMEVALUE: 'NILAI.WAKTU', + TOCOL: 'TOCOL', TODAY: 'HARI.INI', + TOROW: 'TOROW', TRANSPOSE: 'TRANSPOS', TRIM: 'HAPUS.SPASI', TRUE: 'BENAR', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1a494628f8..83f7f8d1c5 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'AMMORT.ANNUO', T: 'T', + TAKE: 'INCLUDI', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'BOT.EQUIV', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'UNISCI.TESTO', TIME: 'ORARIO', TIMEVALUE: 'ORARIO.VALORE', + TOCOL: 'A.COL', TODAY: 'OGGI', + TOROW: 'A.RIGA', TRANSPOSE: 'MATR.TRASPOSTA', TRIM: 'ANNULLA.SPAZI', TRUE: 'VERO', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index ff15415a98..7648a0648f 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVS', T: 'T', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'TBILLEKV', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEKST.KOMBINER', TIME: 'TID', TIMEVALUE: 'TIDSVERDI', + TOCOL: 'TILKOL', TODAY: 'IDAG', + TOROW: 'TILRAD', TRANSPOSE: 'TRANSPONER', TRIM: 'TRIMME', TRUE: 'SANN', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index c491e2f44b..fff64d7237 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SYD', T: 'T', + TAKE: 'NEMEN', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SCHATK.OBL', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEKST.KOPPELEN', TIME: 'TIJD', TIMEVALUE: 'TIJDWAARDE', + TOCOL: 'NAAR.KOLOM', TODAY: 'VANDAAG', + TOROW: 'NAAR.RIJ', TRANSPOSE: 'TRANSPONEREN', TRIM: 'SPATIES.WISSEN', TRUE: 'WAAR', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 6fa4f016b4..3e017d6dd1 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Ś', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: 'PRZEŁĄCZ', SYD: 'SYD', T: 'T', + TAKE: 'WYCINEK', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'RENT.EKW.BS', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'POŁĄCZ.TEKSTY', TIME: 'CZAS', TIMEVALUE: 'CZAS.WARTOŚĆ', + TOCOL: 'DO.KOLUMNY', TODAY: 'DZIŚ', + TOROW: 'DO.WIERSZA', TRANSPOSE: 'TRANSPONUJ', TRIM: 'USUŃ.ZBĘDNE.ODSTĘPY', TRUE: 'PRAWDA', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 6c1e68c328..4281125d3b 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'SDA', T: 'T', + TAKE: 'INCLUIR', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'OTN', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'UNIRTEXTO', TIME: 'TEMPO', TIMEVALUE: 'VALOR.TEMPO', + TOCOL: 'PARACOL', TODAY: 'HOJE', + TOROW: 'PARALIN', TRANSPOSE: 'TRANSPOR', TRIM: 'ARRUMAR', TRUE: 'VERDADEIRO', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 6b35c1ce2f..b815f4d2b6 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: 'КОНМЕСЯЦА', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'АСЧ', T: 'Т', + TAKE: 'ВЗЯТЬ', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'РАВНОКЧЕК', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'ОБЪЕДИНИТЬ', TIME: 'ВРЕМЯ', TIMEVALUE: 'ВРЕМЗНАЧ', + TOCOL: 'ПОСТОЛБЦ', TODAY: 'СЕГОДНЯ', + TOROW: 'ПОСТРОК', TRANSPOSE: 'ТРАНСП', TRIM: 'СЖПРОБЕЛЫ', TRUE: 'ИСТИНА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f87580087..beed1374ee 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'ÅRSAVSKR', T: 'T', + TAKE: 'TA', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'SSVXEKV', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'TEXTJOIN', TIME: 'KLOCKSLAG', TIMEVALUE: 'TIDVÄRDE', + TOCOL: 'TILLKOL', TODAY: 'IDAG', + TOROW: 'TILLRAD', TRANSPOSE: 'TRANSPONERA', TRIM: 'RENSA', TRUE: 'SANT', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 93c2176d37..afbaa65ff9 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', @@ -225,6 +226,7 @@ const dictionary: RawTranslationPackage = { SWITCH: '', SYD: 'YAT', T: 'T', + TAKE: 'AL', TAN: 'TAN', TANH: 'TANH', TBILLEQ: 'HTAHEŞ', @@ -234,7 +236,9 @@ const dictionary: RawTranslationPackage = { TEXTJOIN: 'METİNBİRLEŞTİR', TIME: 'ZAMAN', TIMEVALUE: 'ZAMANSAYISI', + TOCOL: 'SÜTUNA', TODAY: 'BUGÜN', + TOROW: 'SATIRA', TRANSPOSE: 'DEVRİK_DÖNÜŞÜM', TRIM: 'KIRP', TRUE: 'DOĞRU', 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/functionMetadata/categories/lookup-and-reference.ts b/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index ef6b848d7f..4d746fd51a 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).', @@ -121,6 +128,27 @@ 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)'], + }, + TOCOL: { + category: 'Lookup and reference', + shortDescription: 'Returns the elements of an array in a single column.', + parameters: [{name: 'array', description: 'The array or range to return as a column.'}, {name: 'ignore', description: 'Which elements to leave out: 0 (default) keeps every element, 1 skips blank cells, 2 skips errors, 3 skips blank cells and errors. Truncated to an integer; any other value returns #VALUE!. When elements are skipped, the result still takes one cell per element of array, and the unused cells at its end stay blank.'}, {name: 'scan_by_column', description: 'FALSE (default) reads array row by row, TRUE reads it column by column.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=TOCOL(A1:C3)', '=TOCOL(A1:C3, 1)', '=TOCOL(A1:C3, 0, TRUE())'], + }, + TOROW: { + category: 'Lookup and reference', + shortDescription: 'Returns the elements of an array in a single row.', + parameters: [{name: 'array', description: 'The array or range to return as a row.'}, {name: 'ignore', description: 'Which elements to leave out: 0 (default) keeps every element, 1 skips blank cells, 2 skips errors, 3 skips blank cells and errors. Truncated to an integer; any other value returns #VALUE!. When elements are skipped, the result still takes one cell per element of array, and the unused cells at its end stay blank.'}, {name: 'scan_by_column', description: 'FALSE (default) reads array row by row, TRUE reads it column by column.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=TOROW(A1:C3)', '=TOROW(A1:C3, 1)', '=TOROW(A1:C3, 0, TRUE())'], + }, 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..edc11cf14a 100644 --- a/src/interpreter/plugin/ArrayPlugin.ts +++ b/src/interpreter/plugin/ArrayPlugin.ts @@ -3,17 +3,282 @@ * 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' -import {AstNodeType, ProcedureAst} from '../../parser' +import {Ast, AstNodeType, ProcedureAst} from '../../parser' import {coerceScalarToBoolean} from '../ArithmeticHelper' import {InterpreterState} from '../InterpreterState' -import {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, or a TOROW or TOCOL `ignore` value, whose + * value may be known before evaluation. + * + * @internal + */ +type LiteralCount = + | {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 = LiteralCount | {kind: 'unbounded'} + +/** + * The functions that flatten an array into a single row or column. + * + * @internal + */ +type FlattenFunction = 'TOROW' | 'TOCOL' + +/** TOROW and TOCOL `ignore` value that keeps every element. */ +const IGNORE_NOTHING = 0 +/** TOROW and TOCOL `ignore` value that skips blank cells. */ +const IGNORE_BLANKS = 1 +/** TOROW and TOCOL `ignore` value that skips errors. */ +const IGNORE_ERRORS = 2 +/** TOROW and TOCOL `ignore` value that skips blank cells and errors. */ +const IGNORE_BLANKS_AND_ERRORS = 3 + export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypecheck { + /** + * Evaluates the dependency-free subset of TAKE and DROP count expressions, + * and of TOROW and TOCOL `ignore` 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. + * @internal + */ + private parseLiteralCount(argument: Ast | undefined): LiteralCount { + if (argument?.type === AstNodeType.NUMBER) { + return {kind: 'value', value: argument.value} + } + + if (argument?.type === AstNodeType.STRING) { + const coercedValue = this.arithmeticHelper.coerceToMaybeNumber(argument.value) + if (coercedValue === undefined) { + return {kind: 'invalid'} + } + 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.parseLiteralCount(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.parseLiteralCount(argument.expression) + } + + if (argument?.type === AstNodeType.PERCENT_OP) { + const dimension = this.parseLiteralCount(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 + && (argument.procedureName === 'TRUE' || argument.procedureName === 'FALSE') + && this.interpreter.isFunctionImplementedBy(argument.procedureName, BooleanPlugin) + ) { + return {kind: 'value', value: argument.procedureName === 'TRUE' ? 1 : 0} + } + + 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.parseLiteralCount(argument.left) + const right = this.parseLiteralCount(argument.right) + if (left.kind === 'invalid' || right.kind === 'invalid') { + return {kind: 'invalid'} + } + if (left.kind === 'value' && right.kind === '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'} + } + } + + 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 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.parseLiteralCount(argument) + return dimension.kind === 'value' + ? {kind: 'value', value: Math.abs(Math.trunc(dimension.value))} + : dimension + } + + /** + * Resolves a direct TAKE, DROP, TOROW or TOCOL source reference without + * evaluating its values. The range supplies materialized dimensions, and for + * TOROW and TOCOL also 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 directSourceRange(argument: Ast, state: InterpreterState): AbsoluteCellRange | undefined { + if (argument.type === AstNodeType.PARENTHESIS) { + 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) + } + 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. + * 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. + * @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) && anchor > requestedDrop) { + return Number.POSITIVE_INFINITY + } + + const boundedDimension = Number.isFinite(sourceDimension) ? sourceDimension : effectiveDimension + return boundedDimension - Math.min(requestedDrop, boundedDimension) + } + + /** + * Measures a TAKE, DROP, TOROW or TOCOL 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', @@ -43,6 +308,50 @@ 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', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: Number.POSITIVE_INFINITY, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, + 'TOROW': { + method: 'torow', + sizeOfResultArrayMethod: 'torowArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: IGNORE_NOTHING, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: false, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, + 'TOCOL': { + method: 'tocol', + sizeOfResultArrayMethod: 'tocolArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: IGNORE_NOTHING, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: false, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, 'VSTACK': { method: 'vstack', sizeOfResultArrayMethod: 'vstackArraySize', @@ -166,6 +475,438 @@ export class ArrayPlugin extends FunctionPlugin implements FunctionPluginTypeche return new ArraySize(width, height) } + /** + * Corresponds to TAKE(array, rows, [columns]). + * + * 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 #N/A error. + * + * @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 + // 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() + const sourceWidth = range.width() + const requestedRows = Math.trunc(rows) + const requestedColumns = Math.trunc(columns) + + if (requestedRows === 0 || requestedColumns === 0) { + return new CellError(ErrorType.NA, ErrorMessage.ZeroRowOrColumnCount) + } + + if (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 + + 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 startRow = rows > 0 ? rowsToDrop : 0 + const startColumn = columns > 0 ? columnsToDrop : 0 + + 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. + * 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 + * 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. + */ + 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)), + ) + + const rowDimension = this.parseTakeLiteralDimension(ast.args[1]) + const columnDimension = this.parseTakeLiteralDimension(ast.args[2]) + + const hasZeroDimension = (rowDimension.kind === 'value' && rowDimension.value === 0) + || (columnDimension.kind === 'value' && columnDimension.value === 0) + + if (rowDimension.kind === 'invalid' || columnDimension.kind === 'invalid' || hasZeroDimension) { + return ArraySize.error() + } + + 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 = 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 + + if (startsBelowFirstRow || startsRightOfFirstColumn) { + return new ArraySize(width, height) + } + + if (effectiveHeight < 1 || effectiveWidth < 1) { + return ArraySize.error() + } + + return new ArraySize(effectiveWidth, effectiveHeight) + } + + /** + * Corresponds to TOROW(array, [ignore], [scan_by_column]). + * + * Returns the elements of the source array in a single row. See `flatten` + * for the arguments and errors. + * + * @param {ProcedureAst} ast - The parsed TOROW call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} A one-row array or a spreadsheet error. + */ + public torow(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.flatten(ast, state, 'TOROW') + } + + /** + * Corresponds to TOCOL(array, [ignore], [scan_by_column]). + * + * Returns the elements of the source array in a single column. See + * `flatten` for the arguments and errors. + * + * @param {ProcedureAst} ast - The parsed TOCOL call. + * @param {InterpreterState} state - The current formula evaluation state. + * @returns {InterpreterValue} A one-column array or a spreadsheet error. + */ + public tocol(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.flatten(ast, state, 'TOCOL') + } + + /** + * Calculates the spilled array size of TOROW. See `flattenArraySize`. + * + * @param {ProcedureAst} ast - The parsed TOROW call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + public torowArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + return this.flattenArraySize(ast, state, 'TOROW') + } + + /** + * Calculates the spilled array size of TOCOL. See `flattenArraySize`. + * + * @param {ProcedureAst} ast - The parsed TOCOL call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + public tocolArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + return this.flattenArraySize(ast, state, 'TOCOL') + } + + /** + * Flattens the source array into a single row (TOROW) or column (TOCOL). + * + * The source is read row by row, or column by column when `scan_by_column` + * is true. `ignore` selects the elements left out: 0 keeps every element, + * 1 skips blank cells, 2 skips errors, 3 skips both; it is truncated toward + * zero. Empty text is not blank and is kept. An empty `array` argument and + * an `ignore` value outside 0 to 3 return #VALUE!, and a result with no + * elements returns #N/A. + * + * `ignore` and `scan_by_column` are coerced here rather than by the + * argument metadata: both reject empty text with #VALUE!, and an error in + * `scan_by_column` must not take precedence over an invalid `ignore`. + * + * @param {ProcedureAst} ast - The parsed TOROW or TOCOL call. + * @param {InterpreterState} state - The current formula evaluation state. + * @param {FlattenFunction} functionName - The function whose metadata and orientation apply. + * @returns {InterpreterValue} The flattened elements or a spreadsheet error. + */ + private flatten(ast: ProcedureAst, state: InterpreterState, functionName: FlattenFunction): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata(functionName), + (source: SimpleRangeValue, ignoreArgument: InternalScalarValue, scanByColumnArgument: InternalScalarValue) => { + if (ast.args[0].type === AstNodeType.EMPTY) { + return new CellError(ErrorType.VALUE, ErrorMessage.EmptyArg) + } + + const ignore = this.coerceFlattenIgnore(ignoreArgument) + if (ignore instanceof CellError) { + return ignore + } + + const scanByColumn = this.coerceFlattenScanByColumn(scanByColumnArgument) + if (scanByColumn instanceof CellError) { + return scanByColumn + } + + if (ignore < IGNORE_NOTHING) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueSmall) + } + if (ignore > IGNORE_BLANKS_AND_ERRORS) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + const elements = this.collectFlattenElements(source, ignore, scanByColumn) + if (elements.length === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArray) + } + + return SimpleRangeValue.onlyValues(functionName === 'TOCOL' ? elements.map(element => [element]) : [elements]) + } + ) + } + + /** + * Coerces the TOROW or TOCOL `ignore` argument to a number truncated + * toward zero. Numeric text, logical values and blank cells convert as for + * any number; empty text returns #VALUE!. + * + * @param {InternalScalarValue} value - The `ignore` argument. + * @returns {number | CellError} The truncated number or an error. + */ + private coerceFlattenIgnore(value: InternalScalarValue): number | CellError { + if (value === '') { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberCoercion) + } + + const coerced = this.arithmeticHelper.coerceScalarToNumberOrError(value) + return coerced instanceof CellError ? coerced : Math.trunc(getRawValue(coerced)) + } + + /** + * Coerces the TOROW or TOCOL `scan_by_column` argument to a logical value. + * Numbers, blank cells and the texts "TRUE" and "FALSE" convert as for any + * logical value; empty and other text return #VALUE!. + * + * @param {InternalScalarValue} value - The `scan_by_column` argument. + * @returns {boolean | CellError} The logical value or an error. + */ + private coerceFlattenScanByColumn(value: InternalScalarValue): boolean | CellError { + const coerced = value === '' ? undefined : coerceScalarToBoolean(value) + return coerced ?? new CellError(ErrorType.VALUE, ErrorMessage.WrongType) + } + + /** + * Reads the source elements in scan order, leaving out the blank cells and + * errors that `ignore` selects. Runs in time proportional to the number of + * source elements. + * + * @param {SimpleRangeValue} source - The evaluated source array. + * @param {number} ignore - The `ignore` value, from 0 to 3. + * @param {boolean} scanByColumn - Whether to read column by column instead of row by row. + * @returns {InternalScalarValue[]} The kept elements in scan order. + */ + private collectFlattenElements(source: SimpleRangeValue, ignore: number, scanByColumn: boolean): InternalScalarValue[] { + const ignoreBlanks = ignore === IGNORE_BLANKS || ignore === IGNORE_BLANKS_AND_ERRORS + const ignoreErrors = ignore === IGNORE_ERRORS || ignore === IGNORE_BLANKS_AND_ERRORS + const data = source.data + const lineCount = scanByColumn ? source.width() : source.height() + const lineLength = scanByColumn ? source.height() : source.width() + const elements: InternalScalarValue[] = [] + + for (let line = 0; line < lineCount; line++) { + for (let position = 0; position < lineLength; position++) { + const value = scanByColumn ? data[position][line] : data[line][position] + const isIgnored = (ignoreBlanks && value === EmptyValue) || (ignoreErrors && value instanceof CellError) + if (!isIgnored) { + elements.push(value) + } + } + } + + return elements + } + + /** + * Classifies a TOROW or TOCOL `ignore` expression for result-size + * prediction. An omitted or empty `ignore` keeps every element; a literal + * outside 0 to 3 after truncation is invalid. + * + * @param {Ast | undefined} argument - The `ignore` expression. + * @returns {LiteralCount} The truncated value, an invalid-literal marker, or an unresolved marker. + * @internal + */ + private parseFlattenLiteralIgnore(argument: Ast | undefined): LiteralCount { + if (argument === undefined || argument.type === AstNodeType.EMPTY) { + return {kind: 'value', value: IGNORE_NOTHING} + } + + const literal = this.parseLiteralCount(argument) + if (literal.kind !== 'value') { + return literal + } + + const value = Math.trunc(literal.value) + return value < IGNORE_NOTHING || value > IGNORE_BLANKS_AND_ERRORS ? {kind: 'invalid'} : {kind: 'value', value} + } + + /** + * Calculates the spilled array size of TOROW or TOCOL: one row or column + * holding every source element. That is exact when `ignore` is 0 and the + * upper bound otherwise, because `ignore` can only remove elements; the + * cells a shorter result leaves unused stay blank. An invalid literal + * `ignore` is reported as an invalid size, so the formula evaluates to its + * #VALUE! error instead of reserving space. So is a source with no + * elements, such as a whole column of an empty sheet, because an array + * result cannot be reserved with no cells. + * + * A whole-column source of TOCOL (a whole-row source of TOROW) runs along + * the result and is measured by its effective size. While blank cells may + * be kept, such a result reaches the sheet edge, so it fits only in the + * first row (column) and only from a single source column (row). Elsewhere + * the dimension stays unbounded, which makes array-space validation report a + * spill error. A formula below the first row would otherwise make its own + * source taller when it spills, and a wider source would not fit the result. + * The same rule applies whatever `ignore` is when the source is on the + * formula's own sheet: a reservation longer than that sheet makes it grow, + * and every new prediction would then reserve more. A computed source, such + * as `A:A+0`, has no sheet to check and is measured by the configured sheet + * limits, so it follows the same rule. + * + * @param {ProcedureAst} ast - The parsed TOROW or TOCOL call. + * @param {InterpreterState} state - The formula state whose address anchors the spill. + * @param {FlattenFunction} functionName - The function whose metadata and orientation apply. + * @returns {ArraySize} The predicted result dimensions or an invalid size. + */ + private flattenArraySize(ast: ProcedureAst, state: InterpreterState, functionName: FlattenFunction): ArraySize { + if (ast.args.length < 1 || ast.args.length > 3) { + return ArraySize.error() + } + + const ignore = this.parseFlattenLiteralIgnore(ast.args[1]) + if (ignore.kind === 'invalid') { + return ArraySize.error() + } + + const metadata = this.metadata(functionName) + const sourceSize = this.arraySizeForAst( + ast.args[0], + new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)), + ) + const effectiveSourceSize = this.effectiveSourceSize(ast.args[0], state) + const height = Number.isFinite(sourceSize.height) ? sourceSize.height : effectiveSourceSize.height + const width = Number.isFinite(sourceSize.width) ? sourceSize.width : effectiveSourceSize.width + const toColumn = functionName === 'TOCOL' + + const mayKeepBlanks = ignore.kind === 'unresolved' || ignore.value === IGNORE_NOTHING || ignore.value === IGNORE_ERRORS + const sourceRange = this.directSourceRange(ast.args[0], state) + const isComputedOrOnFormulaSheet = sourceRange === undefined || sourceRange.sheet === state.formulaAddress.sheet + const isUnboundedAlongResult = !Number.isFinite(toColumn ? sourceSize.height : sourceSize.width) + if ((mayKeepBlanks || isComputedOrOnFormulaSheet) && isUnboundedAlongResult) { + const sourceLinesAcross = toColumn ? width : height + const anchor = toColumn ? state.formulaAddress.row : state.formulaAddress.col + + if (sourceLinesAcross > 1) { + return new ArraySize(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY) + } + if (anchor !== 0) { + return toColumn ? new ArraySize(1, Number.POSITIVE_INFINITY) : new ArraySize(Number.POSITIVE_INFINITY, 1) + } + } + + const length = height * width + if (length < 1) { + return ArraySize.error() + } + + return toColumn ? new ArraySize(1, length) : new ArraySize(length, 1) + } + /** * Corresponds to VSTACK(array1, [array2], ...) *