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/.vuepress/config.js b/docs/.vuepress/config.js index 2726f697a3..d807bd79ef 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -255,6 +255,7 @@ module.exports = { ['/guide/i18n-features', 'Internationalization features'], ['/guide/localizing-functions', 'Localizing functions'], ['/guide/date-and-time-handling', 'Date and time handling'], + ['/guide/currency-handling', 'Currency handling'], ] }, { 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/compatibility-with-google-sheets.md b/docs/guide/compatibility-with-google-sheets.md index 5e98ea3b4a..507cc997a4 100644 --- a/docs/guide/compatibility-with-google-sheets.md +++ b/docs/guide/compatibility-with-google-sheets.md @@ -87,6 +87,10 @@ Options related to date and time formats: - [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) - [`stringifyDuration()`](../api/interfaces/configparams.md#stringifyduration) +### `TEXT` function formats + +Google Sheets' `TEXT` function supports a wide range of date, time, and currency formats. To cover the full range in HyperFormula, supply both [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency formats — locale-aware grouping, non-`$` symbols, accounting two-section patterns). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example. + ## Full configuration This configuration aligns HyperFormula with the default behavior of Google Sheets (set to locale `en-US`), as closely as possible at this development stage (version `{{ $page.version }}`). diff --git a/docs/guide/compatibility-with-microsoft-excel.md b/docs/guide/compatibility-with-microsoft-excel.md index fade7ed966..9afd28a3a0 100644 --- a/docs/guide/compatibility-with-microsoft-excel.md +++ b/docs/guide/compatibility-with-microsoft-excel.md @@ -156,6 +156,10 @@ Options related to date and time formats: - [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) - [`stringifyDuration()`](../api/interfaces/configparams.md#stringifyduration) +### `TEXT` function formats + +Excel's `TEXT` function supports a wide range of date, time, and currency formats. To cover the full range in HyperFormula, supply both [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency formats — locale-aware grouping, non-`$` symbols, accounting two-section patterns). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example. + ## Full configuration This configuration aligns HyperFormula with the default behavior of Microsoft Excel (set to locale `en-US`), as closely as possible at this development stage (version `{{ $page.version }}`). diff --git a/docs/guide/currency-handling.md b/docs/guide/currency-handling.md new file mode 100644 index 0000000000..ab34b57f20 --- /dev/null +++ b/docs/guide/currency-handling.md @@ -0,0 +1,190 @@ +# Currency handling + +The `TEXT` function renders numbers as strings, and HyperFormula handles the most common currency-shaped formats out of the box. For richer locale-aware rendering — locale-specific decimal separators, non-`$` symbols, accounting two-section patterns — plug in a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback and pick the formatter that fits your application (native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat), a third-party library, or a hand-rolled lookup). + +HyperFormula ships with no currency data and no currency-library dependency — you stay in control of locale, symbol placement, and grouping. + +## Default behavior + +By default (no `stringifyCurrency` configured) HyperFormula's built-in number formatter handles simple `$`-prefixed formats — `"$0.00"`, `"$0"`, and `"$#.00"`: + +```javascript +const hf = HyperFormula.buildFromArray([ + [1234.5, '=TEXT(A1, "$0.00")'], + [1234.5, '=TEXT(A2, "$#.00")'], +]); + +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" +``` + +Configure `stringifyCurrency` when your formula corpus uses any of: + +- thousands grouping (`"$#,##0.00"`), +- non-`$` symbols (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), +- locale-specific decimal separators (e.g. the Polish `"1234,50 zł"` pattern, which the built-in formatter cannot produce because it always emits `.` as the decimal), +- accounting two-section formats (`"$#,##0.00;($#,##0.00)"`). + +## Custom currency formatting + +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. + +### Reference table + +Side-by-side comparison of the default formatter, the docs adapter from the section below, and Excel: + +| 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"` (no grouping) | `"$1,234.50"` | `"$1,234.50"` | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (no grouping) | `"1.234,50 €"` | `"1.234,50 €"` | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (no grouping) | `"($1,234.50)"` | `"($1,234.50)"` | + +### 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. +::: + +### What is an LCID tag? + +Excel can mark a currency format with a [Microsoft Locale Identifier](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid) (LCID) so the symbol carries locale context. The syntax is `[$SYMBOL-LCID]` followed by the number template — for example `[$zł-415] #,##0.00` means *"Polish złoty, hex LCID `415` = `pl-PL`"*, and `[$€-2] #,##0.00` means *"euro, generic"*. The adapter above parses the LCID to pick the matching `Intl.NumberFormat` locale and ISO 4217 currency code. + +### 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. Combine with `stringifyCurrency` when your formulas mix date/time and currency formats. diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 752d50170e..cb0ee3b0a2 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,6 +96,8 @@ const data = [["31st Jan 00", "2nd Jun 01", "=B1-A1"]]; And now, HyperFormula recognizes these values as valid dates and can operate on them. +For currency formatting in the `TEXT` function (locale-aware grouping, non-`$` symbols, accounting patterns), see the [Currency handling](currency-handling.md) guide. + ## 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..eefe998b04 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 Excel's LCID-tagged form — `[$SYMBOL-LCID]` where LCID is a hex [Microsoft Locale ID](https://learn.microsoft.com/openspecs/windows_protocols/ms-lcid), e.g. `[$zł-415] #,##0.00` for Polish złoty — or supply a custom [`stringifyCurrency`](currency-handling.md) callback that handles such formats outside the parser. For locale-specific patterns like the Polish `"1234,50 zł"` (decimal comma), the callback is required because the built-in number formatter always emits `.` as the decimal separator. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 9dac88022f..a34d11531d 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`). Plug in [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) for full coverage. | 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..68d36d81f0 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 [Currency handling guide](/guide/currency-handling.md). + * + * @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 +}