|
| 1 | +# Currency handling |
| 2 | + |
| 3 | +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). |
| 4 | + |
| 5 | +HyperFormula ships with no currency data and no currency-library dependency — you stay in control of locale, symbol placement, and grouping. |
| 6 | + |
| 7 | +## Default behavior |
| 8 | + |
| 9 | +By default (no `stringifyCurrency` configured) HyperFormula's built-in number formatter handles simple `$`-prefixed formats — `"$0.00"`, `"$0"`, and `"$#.00"`: |
| 10 | + |
| 11 | +```javascript |
| 12 | +const hf = HyperFormula.buildFromArray([ |
| 13 | + [1234.5, '=TEXT(A1, "$0.00")'], |
| 14 | + [1234.5, '=TEXT(A2, "$#.00")'], |
| 15 | +]); |
| 16 | + |
| 17 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" |
| 18 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })); // "$1234.50" |
| 19 | +``` |
| 20 | + |
| 21 | +Configure `stringifyCurrency` when your formula corpus uses any of: |
| 22 | + |
| 23 | +- thousands grouping (`"$#,##0.00"`), |
| 24 | +- non-`$` symbols (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`), |
| 25 | +- 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), |
| 26 | +- accounting two-section formats (`"$#,##0.00;($#,##0.00)"`). |
| 27 | + |
| 28 | +## Custom currency formatting |
| 29 | + |
| 30 | +The callback contract: |
| 31 | + |
| 32 | +```ts |
| 33 | +stringifyCurrency: (value: number, currencyFormat: string) => string | undefined |
| 34 | +``` |
| 35 | + |
| 36 | +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. |
| 37 | + |
| 38 | +### Minimal example |
| 39 | + |
| 40 | +```javascript |
| 41 | +// Recognize "$..."-prefixed formats and ignore the rest: |
| 42 | +const stringifyCurrency = (value, fmt) => |
| 43 | + fmt.startsWith('$') ? `$${value.toFixed(2)}` : undefined; |
| 44 | + |
| 45 | +const hf = HyperFormula.buildFromArray([ |
| 46 | + [1234.5, '=TEXT(A1, "$#,##0.00")'], |
| 47 | +], { stringifyCurrency }); |
| 48 | + |
| 49 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "$1234.50" |
| 50 | +``` |
| 51 | + |
| 52 | +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. |
| 53 | + |
| 54 | +### Reference table |
| 55 | + |
| 56 | +Side-by-side comparison of the default formatter, the docs adapter from the section below, and Excel: |
| 57 | + |
| 58 | +| Format | `TEXT(1234.5, ...)` without callback | With docs adapter callback | Excel | |
| 59 | +|---|---|---|---| |
| 60 | +| `"$0.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | |
| 61 | +| `"$#.00"` | `"$1234.50"` | `"$1234.50"` | `"$1234.50"` | |
| 62 | +| `"$#,##0.00"` | `"$1235,##0.00"` (no grouping) | `"$1,234.50"` | `"$1,234.50"` | |
| 63 | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (no grouping) | `"1.234,50 €"` | `"1.234,50 €"` | |
| 64 | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (no grouping) | `"($1,234.50)"` | `"($1,234.50)"` | |
| 65 | + |
| 66 | +### Error behavior |
| 67 | + |
| 68 | +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. |
| 69 | + |
| 70 | +### Example: `Intl.NumberFormat` adapter (zero dependencies) |
| 71 | + |
| 72 | +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. |
| 73 | + |
| 74 | +```javascript |
| 75 | +// Minimal Excel-format-string → Intl.NumberFormat adapter. |
| 76 | +// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats. |
| 77 | + |
| 78 | +const LCID_TO_LOCALE = { |
| 79 | + '-409': { locale: 'en-US', currency: 'USD' }, // USD |
| 80 | + '-2': { locale: 'de-DE', currency: 'EUR' }, // EUR (generic) |
| 81 | + '-411': { locale: 'ja-JP', currency: 'JPY' }, // JPY |
| 82 | + '-415': { locale: 'pl-PL', currency: 'PLN' }, // PLN |
| 83 | + '-809': { locale: 'en-GB', currency: 'GBP' }, // GBP |
| 84 | +} |
| 85 | + |
| 86 | +const CURRENCY_RULES = [ |
| 87 | + // [$SYMBOL-LCID] #,##0[.00] — Excel's locale-tagged currency |
| 88 | + { |
| 89 | + pattern: /^\[\$([^\-\]]*)-([0-9A-Fa-f]+)\]\s*#,##0(\.0+)?$/, |
| 90 | + build: (match) => { |
| 91 | + const lcid = '-' + match[2] |
| 92 | + const fractionDigits = (match[3] || '.').length - 1 |
| 93 | + const entry = LCID_TO_LOCALE[lcid] || { locale: 'en-US', currency: 'USD' } |
| 94 | + return new Intl.NumberFormat(entry.locale, { |
| 95 | + style: 'currency', |
| 96 | + currency: entry.currency, |
| 97 | + minimumFractionDigits: fractionDigits, |
| 98 | + maximumFractionDigits: fractionDigits, |
| 99 | + }) |
| 100 | + }, |
| 101 | + }, |
| 102 | + // $#,##0.00 — USD shorthand |
| 103 | + { |
| 104 | + pattern: /^\$#,##0(\.0+)?$/, |
| 105 | + build: (match) => new Intl.NumberFormat('en-US', { |
| 106 | + style: 'currency', |
| 107 | + currency: 'USD', |
| 108 | + minimumFractionDigits: (match[1] || '.').length - 1, |
| 109 | + maximumFractionDigits: (match[1] || '.').length - 1, |
| 110 | + }), |
| 111 | + }, |
| 112 | +] |
| 113 | + |
| 114 | +// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses |
| 115 | +function tryAccountingFormat(value, format) { |
| 116 | + const sections = format.split(';') |
| 117 | + if (sections.length !== 2) return undefined |
| 118 | + const isNegative = value < 0 |
| 119 | + const section = sections[isNegative ? 1 : 0] |
| 120 | + const parenMatch = /^\(\$#,##0(\.0+)?\)$/.exec(section) |
| 121 | + const plainMatch = /^\$#,##0(\.0+)?$/.exec(section) |
| 122 | + if (!parenMatch && !plainMatch) return undefined |
| 123 | + const fractionDigits = ((parenMatch || plainMatch)[1] || '.').length - 1 |
| 124 | + const nf = new Intl.NumberFormat('en-US', { |
| 125 | + style: 'currency', |
| 126 | + currency: 'USD', |
| 127 | + minimumFractionDigits: fractionDigits, |
| 128 | + maximumFractionDigits: fractionDigits, |
| 129 | + }) |
| 130 | + const formatted = nf.format(Math.abs(value)) |
| 131 | + return isNegative && parenMatch ? `(${formatted})` : formatted |
| 132 | +} |
| 133 | + |
| 134 | +export const customStringifyCurrency = (value, currencyFormat) => { |
| 135 | + if (typeof currencyFormat !== 'string') return undefined |
| 136 | + const accounting = tryAccountingFormat(value, currencyFormat) |
| 137 | + if (accounting !== undefined) return accounting |
| 138 | + |
| 139 | + for (const rule of CURRENCY_RULES) { |
| 140 | + const match = rule.pattern.exec(currencyFormat) |
| 141 | + if (match) return rule.build(match).format(value) |
| 142 | + } |
| 143 | + // Not a recognized currency format — let HyperFormula fall through |
| 144 | + // to the built-in number formatter. |
| 145 | + return undefined |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +Then plug it into your [configuration options](configuration-options.md): |
| 150 | + |
| 151 | +```javascript |
| 152 | +const options = { |
| 153 | + stringifyCurrency: customStringifyCurrency, |
| 154 | +} |
| 155 | + |
| 156 | +const hf = HyperFormula.buildFromArray([ |
| 157 | + [1234.5, '=TEXT(A1, "[$€-2] #,##0.00")'], |
| 158 | + [12345.5, '=TEXT(A2, "[$zł-415] #,##0.00")'], |
| 159 | + [-1234.5, '=TEXT(A3, "$#,##0.00;($#,##0.00)")'], |
| 160 | +], options) |
| 161 | +``` |
| 162 | + |
| 163 | +```javascript |
| 164 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" |
| 165 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 1 })) // "12 345,50 zł" |
| 166 | +console.log(hf.getCellValue({ sheet: 0, col: 1, row: 2 })) // "($1,234.50)" |
| 167 | +``` |
| 168 | + |
| 169 | +::: tip |
| 170 | +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. |
| 171 | +::: |
| 172 | + |
| 173 | +### What is an LCID tag? |
| 174 | + |
| 175 | +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. |
| 176 | + |
| 177 | +### When to swap in a library |
| 178 | + |
| 179 | +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: |
| 180 | + |
| 181 | +- Arbitrary Excel-style format strings beyond this subset, |
| 182 | +- Precision-safe arithmetic on currency values (e.g. cents as integers), |
| 183 | +- ISO 4217 currency metadata for dozens of currencies, |
| 184 | + |
| 185 | +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. |
| 186 | + |
| 187 | +## Related configuration |
| 188 | + |
| 189 | +- [`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. |
| 190 | +- [`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. |
0 commit comments