diff --git a/CHANGELOG.md b/CHANGELOG.md index 323c746b2d..11cbf80716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - 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. [#1842](https://github.com/handsontable/hyperformula/pull/1842) ### Fixed diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 660fda0f98..b17705f7e5 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** | @@ -117,6 +119,18 @@ 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 | +| 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/error-message.ts b/hyperformula/src/error-message.ts index d42040901c..e4eb42e8f7 100644 --- a/hyperformula/src/error-message.ts +++ b/hyperformula/src/error-message.ts @@ -68,6 +68,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/hyperformula/src/i18n/languages/csCZ.ts b/hyperformula/src/i18n/languages/csCZ.ts index fea286083c..151283aeaf 100644 --- a/hyperformula/src/i18n/languages/csCZ.ts +++ b/hyperformula/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', @@ -237,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 9264551249..86ac6eee50 100644 --- a/hyperformula/src/i18n/languages/daDK.ts +++ b/hyperformula/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', @@ -237,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 4d3d39319b..4d08a74dca 100644 --- a/hyperformula/src/i18n/languages/deDE.ts +++ b/hyperformula/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', @@ -237,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 d271b732cd..78de70b809 100644 --- a/hyperformula/src/i18n/languages/enGB.ts +++ b/hyperformula/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', @@ -239,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 9c6b817b98..db4f3da7e6 100644 --- a/hyperformula/src/i18n/languages/esES.ts +++ b/hyperformula/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', @@ -237,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 99ff05708f..6eff376beb 100644 --- a/hyperformula/src/i18n/languages/fiFI.ts +++ b/hyperformula/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', @@ -237,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 f7b9be45f7..1f79cc9b5a 100644 --- a/hyperformula/src/i18n/languages/frFR.ts +++ b/hyperformula/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', @@ -237,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 e40219553a..541a13f704 100644 --- a/hyperformula/src/i18n/languages/huHU.ts +++ b/hyperformula/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', @@ -237,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 719cb11db6..d967f57da4 100644 --- a/hyperformula/src/i18n/languages/idID.ts +++ b/hyperformula/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', @@ -239,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 1a494628f8..bac1abd64e 100644 --- a/hyperformula/src/i18n/languages/itIT.ts +++ b/hyperformula/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', @@ -237,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 ff15415a98..75c59a1995 100644 --- a/hyperformula/src/i18n/languages/nbNO.ts +++ b/hyperformula/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', @@ -237,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 c491e2f44b..f2db79ee21 100644 --- a/hyperformula/src/i18n/languages/nlNL.ts +++ b/hyperformula/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', @@ -237,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 6fa4f016b4..b13cd1f60f 100644 --- a/hyperformula/src/i18n/languages/plPL.ts +++ b/hyperformula/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', @@ -237,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 6c1e68c328..17a2dd4a82 100644 --- a/hyperformula/src/i18n/languages/ptPT.ts +++ b/hyperformula/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', @@ -237,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 6b35c1ce2f..4da13f847e 100644 --- a/hyperformula/src/i18n/languages/ruRU.ts +++ b/hyperformula/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: 'РАВНОКЧЕК', @@ -237,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 5f87580087..f7043eff0a 100644 --- a/hyperformula/src/i18n/languages/svSE.ts +++ b/hyperformula/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', @@ -237,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 93c2176d37..53fe0c0959 100644 --- a/hyperformula/src/i18n/languages/trTR.ts +++ b/hyperformula/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Ş', @@ -237,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/Interpreter.ts b/hyperformula/src/interpreter/Interpreter.ts index 6444332537..73115a4bcc 100644 --- a/hyperformula/src/interpreter/Interpreter.ts +++ b/hyperformula/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, @@ -65,6 +66,18 @@ export class Interpreter { this.functionCallLicenseGate = new FunctionCallLicenseGate(config, functionRegistry) } + /** + * 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/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts b/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts index ef6b848d7f..f72310db63 100644 --- a/hyperformula/src/interpreter/functionMetadata/categories/lookup-and-reference.ts +++ b/hyperformula/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,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.', @@ -128,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 27b47096e0..c8b2ae11f9 100644 --- a/hyperformula/src/interpreter/plugin/ArrayPlugin.ts +++ b/hyperformula/src/interpreter/plugin/ArrayPlugin.ts @@ -3,17 +3,284 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +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 {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 count or code expression of TAKE, DROP or TRIMRANGE 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 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, + * 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. + * @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 or TRIMRANGE 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 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 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. + * + * @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 +310,41 @@ 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, + }, + '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', @@ -166,6 +468,503 @@ 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 + + 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 a26733ec5f..5cc76f6834 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', @@ -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', 'TRIMRANGE', 'UNICODE', 'VARA', 'VARPA', 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', ]