diff --git a/CHANGELOG.md b/CHANGELOG.md index 21858277a3..2e87127a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 752d50170e..a19b17e463 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,6 +96,145 @@ 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 recognizes a limited set of currency-looking formats such as `"$#,##0.00"` via the built-in number formatter. When you need richer, locale-aware currency output — for example `"[$€-2] #,##0.00"` (EUR with German grouping) or `"[$zł-415] #,##0.00"` (PLN, locale `pl-PL`) — provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback. + +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 receives the raw number and the Excel format string and returns either a formatted string or `undefined` (to fall through to the built-in formatter). + +### Example: `Intl.NumberFormat` adapter (zero dependencies) + +This example maps a small but representative subset of Excel currency format strings onto the native [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) API. + +```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, + }), + }, + // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €). + // Note: HyperFormula's formula parser does not accept embedded double quotes + // inside TEXT format strings. This rule is illustrative for callback usage + // outside TEXT — to format PLN through TEXT, prefer "[$zł-415] #,##0.00". + { + pattern: /^#,##0(\.0+)?\s+"([^"]+)"$/, + build: (match) => { + const fractionDigits = (match[1] || '.').length - 1 + const symbol = match[2] + const localeBySymbol = { 'zł': 'pl-PL', '€': 'de-DE', '£': 'en-GB', '¥': 'ja-JP' } + const locale = localeBySymbol[symbol] || 'en-US' + const nf = new Intl.NumberFormat(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }) + return { format: (value) => `${nf.format(value)} ${symbol}` } + }, + }, +] + +// 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) => { + 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) +``` + +Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces (U+00A0) as locale-appropriate separators. The comments above show them as regular spaces for readability. Be aware when comparing strings programmatically. + +```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)" +``` + +### When to swap in a library + +The adapter above covers six common Excel format shapes in under one page of code. 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/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..a4d94b155f 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 Number + */ + 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..9c1a9906b4 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -19,6 +19,10 @@ export function format(value: number, formatArg: string, config: Config, dateHel if (tryDuration !== undefined) { return tryDuration } + const tryCurrency = config.stringifyCurrency(value, formatArg) + if (tryCurrency !== undefined) { + return tryCurrency + } const expression = parseForNumberFormat(formatArg) if (expression !== undefined) { return numberFormat(expression.tokens, value) @@ -229,3 +233,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 +}