Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
139 changes: 139 additions & 0 deletions docs/guide/date-and-time-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +121,8 @@ export class Config implements ConfigParams, ParserConfig {
/** @inheritDoc */
public readonly stringifyDuration: (time: SimpleTime, formatArg: string) => Maybe<string>
/** @inheritDoc */
public readonly stringifyCurrency: (value: number, currencyFormat: string) => Maybe<string>
/** @inheritDoc */
public readonly precisionEpsilon: number
/** @inheritDoc */
public readonly precisionRounding: number
Expand Down Expand Up @@ -195,6 +198,7 @@ export class Config implements ConfigParams, ParserConfig {
precisionRounding,
stringifyDateTime,
stringifyDuration,
stringifyCurrency,
smartRounding,
timeFormats,
thousandSeparator,
Expand Down Expand Up @@ -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')
Expand Down
15 changes: 15 additions & 0 deletions src/ConfigParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,21 @@ export interface ConfigParams {
* @category Date and Time
*/
stringifyDuration: (time: SimpleTime, timeFormat: string) => Maybe<string>,
/**
* 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<string>,
/**
* When set to `false`, no rounding happens, and numbers are equal if and only if they are of truly identical value.
*
Expand Down
21 changes: 21 additions & 0 deletions src/format/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string> {
return undefined
}
Loading