diff --git a/CHANGELOG.md b/CHANGELOG.md index f118c1d792..1721ae418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added `maxPendingLazyTransformations` configuration option to control memory usage by limiting accumulated transformations before cleanup. [#1629](https://github.com/handsontable/hyperformula/issues/1629) - Added a new function: TEXTJOIN. [#1640](https://github.com/handsontable/hyperformula/pull/1640) - Added a new function: SEQUENCE. [#1645](https://github.com/handsontable/hyperformula/pull/1645) +- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) ### Fixed diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index b1c998e601..3b568ad544 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -525,7 +525,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SPLIT | Divides the provided text using the space character as a separator and returns the substring at the zero-based position specified by the second argument.
`SPLIT("Lorem ipsum", 0) -> "Lorem"`
`SPLIT("Lorem ipsum", 1) -> "ipsum"` | SPLIT(Text, Index) | | SUBSTITUTE | Returns string where occurrences of Old_text are replaced by New_text. Replaces only specific occurrence if last parameter is provided. | SUBSTITUTE(Text, Old_text, New_text, [Occurrence]) | | T | Returns text if given value is text, empty string otherwise. | T(Value) | -| TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) option. | TEXT(Number, Format) | +| TEXT | Converts a number into text according to a given format.
By default, accepts the same formats that can be passed to the [`dateFormats`](../api/interfaces/configparams.md#dateformats) option, but can be further customized with the [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) and [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) options. | TEXT(Number, Format) | | TEXTJOIN | Joins text from multiple strings and/or ranges with a delimiter. Supports array/range delimiters that cycle through gaps. When ignore_empty is TRUE, empty strings are skipped. Returns #VALUE! if result exceeds 32,767 characters. | TEXTJOIN(Delimiter, Ignore_empty, Text1, [Text2, ...]) | | TRIM | Strips extra spaces from text. | TRIM("Text") | | UNICHAR | Returns the character created by using provided code point. | UNICHAR(Number) | diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 752d50170e..afb8ee23a6 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,6 +96,172 @@ const data = [["31st Jan 00", "2nd Jun 01", "=B1-A1"]]; And now, HyperFormula recognizes these values as valid dates and can operate on them. +## Currency integration + +By default, the `TEXT` function renders only the simplest currency-looking formats — `"$0.00"`, `"$0"`, or `"$#.00"` (no thousands separator). Common Excel patterns such as `"$#,##0.00"` (with comma grouping), `"[$€-2] #,##0.00"` (EUR with German grouping), `"[$zł-415] #,##0.00"` (PLN), or accounting two-section formats like `"$#,##0.00;($#,##0.00)"` are **not** rendered correctly by the built-in number formatter; provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback to handle them. + +HyperFormula itself ships with **no currency data** and **no currency library dependency**. You choose how to format: native `Intl.NumberFormat`, a third-party library, or a hand-rolled lookup table. + +The callback contract: + +```ts +stringifyCurrency: (value: number, currencyFormat: string) => string | undefined +``` + +The function receives the raw number and the format string passed to `TEXT`. Return a formatted string to override the built-in formatter, or `undefined` to fall through to it. + +### Minimal example + +```javascript +// Recognize "$..."-prefixed formats and ignore the rest: +const stringifyCurrency = (value, fmt) => + fmt.startsWith('$') ? `$${value.toFixed(2)}` : undefined + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "$#,##0.00")'], +], { stringifyCurrency }) + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "$1234.50" +``` + +This callback handles `$`-prefixed formats and falls through (returns `undefined`) for everything else. Dates, durations, and unrecognized formats continue through HyperFormula's existing dispatch chain. + +### Default behavior + +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input. For non-currency formats (`mm/dd/yyyy`, `hh:mm`, etc.) the built-in dispatch chain handles the format string and preserves the existing `TEXT` behavior bit-for-bit. For currency-looking formats the built-in number formatter is intentionally limited: + +| Format | `TEXT(1234.5, ...)` without callback | With docs adapter callback | Excel | +|---|---|---|---| +| `"$0.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | +| `"$#,##0.00"` | `"$1235,##0.00"` (broken) | `"$1,234.50"` | `"$1,234.50"` | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (broken) | `"1.234,50 €"` | `"1.234,50 €"` | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (broken) | `"($1,234.50)"` | `"($1,234.50)"` | + +**Recommendation:** for any application that surfaces currency to end users, configure `stringifyCurrency` — either with the `Intl.NumberFormat` adapter below (zero dependencies) or with a library of your choice. Leaving it unset is appropriate only when the formula corpus does not include currency-shaped TEXT formats. + +### Error behavior + +If your callback throws, HyperFormula propagates the exception. Wrap your formatter in `try/catch` if it can fail, and return `undefined` as the opt-out signal for unsupported formats — throwing is reserved for unexpected errors. + +### Example: `Intl.NumberFormat` adapter (zero dependencies) + +This adapter handles a representative subset of Excel currency format strings using native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Extend the `LCID_TO_LOCALE` map to cover more locales — see the [MS-LCID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) specification for canonical identifiers. + +```javascript +// Minimal Excel-format-string → Intl.NumberFormat adapter. +// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. + +const LCID_TO_LOCALE = { + '-409': { locale: 'en-US', currency: 'USD' }, // USD + '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) + '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY + '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN + '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP +} + +const CURRENCY_RULES = [ + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency + { + pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, + build: (match) => { + const lcid = '-' + match[2] + const fractionDigits = (match[3] || '.').length - 1 + const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } + return new Intl.NumberFormat(entry.locale, { + style: 'currency', + currency: entry.currency, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + }, + }, + // $#,##0.00 — USD shorthand + { + pattern: /^\$#,##0(\.0+)?$/, + build: (match) => new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: (match[1] || '.').length - 1, + maximumFractionDigits: (match[1] || '.').length - 1, + }), + }, +] + +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses +function tryAccountingFormat(value, format) { + const sections = format.split(';') + if (sections.length !== 2) return undefined + const isNegative = value < 0 + const section = sections[isNegative ? 1 : 0] + const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) + const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) + if (!parenMatch && !plainMatch) return undefined + const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 + const nf = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + const formatted = nf.format(Math.abs(value)) + return isNegative && parenMatch ? `(${formatted})` : formatted +} + +export const customStringifyCurrency = (value, currencyFormat) => { + if (typeof currencyFormat !== 'string') return undefined + const accounting = tryAccountingFormat(value, currencyFormat) + if (accounting !== undefined) return accounting + + for (const rule of CURRENCY_RULES) { + const match = rule.pattern.exec(currencyFormat) + if (match) return rule.build(match).format(value) + } + // Not a recognized currency format — let HyperFormula fall through + // to the built-in number formatter. + return undefined +} +``` + +Then plug it into your [configuration options](configuration-options.md): + +```javascript +const options = { + stringifyCurrency: customStringifyCurrency, +} + +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "[$€-2] #,##0.00")'], + [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], + [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], +], options) +``` + +```javascript +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" +``` + +::: tip +The output values above contain non-breaking spaces (U+00A0 or U+202F depending on locale and ICU/CLDR version) as locale-appropriate separators. The comments show them as regular spaces for readability. When comparing programmatically, normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: + +### When to swap in a library + +The adapter above covers a small but representative subset of Excel currency format strings (LCID-tagged, USD shorthand, accounting two-section) in under one page of code, with a fall-through path for everything else. If you need: + +- Arbitrary Excel-style format strings beyond this subset, +- Precision-safe arithmetic on currency values (e.g. cents as integers), +- ISO 4217 currency metadata for dozens of currencies, + +consider wrapping [`Dinero.js` v2](https://v2.dinerojs.com/) or your own format library inside the callback. The contract is the same: `(value: number, currencyFormat: string) => string | undefined`. Return `undefined` for any format string you don't want to handle and HyperFormula will fall back to its built-in number formatter. + +### Related configuration + +- [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol) — governs how HyperFormula **parses** currency literals in input (e.g. `"$100"` → `100`). It is **independent** of `stringifyCurrency`, which governs TEXT output. +- [`stringifyDateTime`](../api/interfaces/configparams.md#stringifydatetime) / [`stringifyDuration`](../api/interfaces/configparams.md#stringifyduration) — sister callbacks for date and duration formatting. + ## Demo ::: example #example1 --html 1 --css 2 --js 3 --ts 4 diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 72753b9b26..334307e7bf 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,3 +38,4 @@ you can't compare the arguments in a formula like this: * The INDEX function doesn't support returning whole rows or columns of the source range – it always returns the contents of a single cell. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. +* The TEXT function does not accept embedded double-quote literals in the format string (e.g., `=TEXT(A1, "#,##0.00 ""zł""")` fails at parse time). Use the LCID-tagged form (`[$zł-415] #,##0.00`) or supply a custom [`stringifyCurrency`](configuration-options.md#stringifycurrency) callback that handles such formats outside the parser. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 9dac88022f..35f34d885d 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -28,7 +28,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | Applying a scalar value to a function taking range | COLUMNS(A1) | `CellRangeExpected` error. | Treats the element as length-1 range. Returns 1 for the example. | Same as Google Sheets. | | Coercion of explicit arguments | VARP(2, 3, 4, TRUE(), FALSE(), "1",) | 1.9592, based on the behavior of Microsoft Excel. | GoogleSheets implementation is not consistent with the standard (see also `VAR.S`, `STDEV.P`, and `STDEV.S` function.) | 1.9592 | | 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,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`).

No currency formatting inside the TEXT function. | A wide variety of options for string formatting is supported. | Same as Google Sheets. | +| Formatting inside the TEXT function | TEXT(A1,"dd-mm-yy")

TEXT(A1,"###.###”) | Not all formatting options are supported,
e.g., only some date formatting options: (`hh`, `mm`, `ss`, `am`, `pm`, `a`, `p`, `dd`, `yy`, and `yyyy`).

Currency formatting is opt-in via the [`stringifyCurrency`](date-and-time-handling.md#currency-integration) callback; without it, currency format strings fall through to the built-in number formatter.

Embedded double-quote literals (e.g. `#,##0.00 "zł"`) are not accepted by the parser; use the LCID-tagged form (`[$zł-415] #,##0.00`) instead. | 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 | | 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** | diff --git a/src/Config.ts b/src/Config.ts index d47323384f..8e8a924eee 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -15,7 +15,7 @@ import {defaultParseToDateTime} from './DateTimeDefault' import {DateTime, instanceOfSimpleDate, SimpleDate, SimpleDateTime, SimpleTime} from './DateTimeHelper' import {AlwaysDense, ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ConfigValueEmpty, ExpectedValueOfTypeError} from './errors' -import {defaultStringifyDateTime, defaultStringifyDuration} from './format/format' +import {defaultStringifyCurrency, defaultStringifyDateTime, defaultStringifyDuration} from './format/format' import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licenseKeyValidator' import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' @@ -59,6 +59,7 @@ export class Config implements ConfigParams, ParserConfig { smartRounding: true, stringifyDateTime: defaultStringifyDateTime, stringifyDuration: defaultStringifyDuration, + stringifyCurrency: defaultStringifyCurrency, timeFormats: ['hh:mm', 'hh:mm:ss.sss'], thousandSeparator: '', undoLimit: 20, @@ -120,6 +121,8 @@ export class Config implements ConfigParams, ParserConfig { /** @inheritDoc */ public readonly stringifyDuration: (time: SimpleTime, formatArg: string) => Maybe /** @inheritDoc */ + public readonly stringifyCurrency: (value: number, currencyFormat: string) => Maybe + /** @inheritDoc */ public readonly precisionEpsilon: number /** @inheritDoc */ public readonly precisionRounding: number @@ -195,6 +198,7 @@ export class Config implements ConfigParams, ParserConfig { precisionRounding, stringifyDateTime, stringifyDuration, + stringifyCurrency, smartRounding, timeFormats, thousandSeparator, @@ -243,6 +247,7 @@ export class Config implements ConfigParams, ParserConfig { this.parseDateTime = configValueFromParam(parseDateTime, 'function', 'parseDateTime') this.stringifyDateTime = configValueFromParam(stringifyDateTime, 'function', 'stringifyDateTime') this.stringifyDuration = configValueFromParam(stringifyDuration, 'function', 'stringifyDuration') + this.stringifyCurrency = configValueFromParam(stringifyCurrency, 'function', 'stringifyCurrency') this.translationPackage = HyperFormula.getLanguage(this.language) this.errorMapping = this.translationPackage.buildErrorMapping() this.nullDate = configValueFromParamCheck(nullDate, instanceOfSimpleDate, 'IDate', 'nullDate') diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index ad7344a3b1..93c244e9df 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -310,6 +310,21 @@ export interface ConfigParams { * @category Date and Time */ stringifyDuration: (time: SimpleTime, timeFormat: string) => Maybe, + /** + * Sets a function that converts numeric values into currency-formatted strings. + * + * The function receives the raw value and the format string passed to `TEXT` + * and should return a string or `undefined`. Returning `undefined` lets the + * formatter fall through to the built-in number formatter, so a callback that + * recognizes only some format strings can safely opt out of the rest. + * + * For more information, see the [Date and time handling guide](/guide/date-and-time-handling.md#currency-integration). + * + * @default defaultStringifyCurrency + * + * @category Date and Time + */ + stringifyCurrency: (value: number, currencyFormat: string) => Maybe, /** * When set to `false`, no rounding happens, and numbers are equal if and only if they are of truly identical value. * diff --git a/src/format/format.ts b/src/format/format.ts index e605209f5d..58b4c941bf 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -11,6 +11,20 @@ import {Maybe} from '../Maybe' import {FormatToken, parseForDateTimeFormat, parseForNumberFormat, TokenType} from './parser' export function format(value: number, formatArg: string, config: Config, dateHelper: DateTimeHelper): RawScalarValue { + // Currency callback runs first so a user-supplied stringifyCurrency can + // intercept LCID-tagged or bare-letter currency formats before the + // date/time parser greedily consumes characters like 'D', 'M', 'S', 'Y' + // (e.g. '[$USD-409] #,##0.00' would otherwise become '[$US9-409] #,##0.00'). + // The default callback returns undefined for every input. For non-currency + // formats (dates, durations, $#,##0.00, etc.) this preserves the existing + // dispatch path bit-for-bit. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`) + // the LCID guards in defaultStringifyDateTime/Duration also short-circuit, + // so the value falls through to parseForNumberFormat — a deliberate change + // versus pre-HF-24 behavior, where the date parser would mangle the symbol. + const tryCurrency = config.stringifyCurrency(value, formatArg) + if (tryCurrency !== undefined) { + return tryCurrency + } const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), formatArg) // default points to defaultStringifyDateTime() if (tryDateTime !== undefined) { return tryDateTime @@ -81,6 +95,14 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { } export function defaultStringifyDuration(time: SimpleTime, formatArg: string): Maybe { + // Same LCID-tagged currency guard as defaultStringifyDateTime — Excel + // currency tags `[$SYMBOL-LCID]` contain duration-token letters + // (H in CHF/HUF, m in AMD/HMD) that parseForDateTimeFormat would + // otherwise interpret as time tokens. See defaultStringifyDateTime + // for the symbol-vs-locale-modifier rationale. + if (/\[\$[^\-\]]+-/.test(formatArg)) { + return undefined + } const expression = parseForDateTimeFormat(formatArg) if (expression === undefined) { return undefined @@ -143,6 +165,20 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M } export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { + // Skip date/time interpretation for Excel currency formats tagged with + // `[$SYMBOL-LCID]` (non-empty SYMBOL portion). parseForDateTimeFormat + // would otherwise greedily consume characters like D, M, S, Y, H inside + // the currency code (e.g. 'USD' contains D, 'CHF' contains H), mangling + // the output when a user-supplied stringifyCurrency callback opts out by + // returning undefined. + // + // The guard intentionally requires at least one character between `[$` + // and the `-` to distinguish currency tags (`[$USD-409]`, `[$€-2]`) from + // Excel's locale-only modifier (`[$-409]`, `[$-F800]`), which is valid + // on date/time formats and must continue to flow through this function. + if (/\[\$[^\-\]]+-/.test(formatArg)) { + return undefined + } const expression = parseForDateTimeFormat(formatArg) if (expression === undefined) { return undefined @@ -229,3 +265,20 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st return result } + +/** + * Default implementation of the `stringifyCurrency` config option. + * + * Returning `undefined` instructs the formatter to fall through to the + * built-in number formatter, preserving HyperFormula's zero-dependency + * default behavior. Replace this default by setting the + * [`stringifyCurrency`](../../api/interfaces/configparams.md#stringifycurrency) + * config option. + * + * @param _value - the numeric value to format (unused in default). + * @param _formatArg - the format string passed to `TEXT` (unused in default). + * @returns `undefined` — caller should fall through to the built-in formatter. + */ +export function defaultStringifyCurrency(_value: number, _formatArg: string): Maybe { + return undefined +}