diff --git a/.eslintignore b/.eslintignore
index 03546876e2..bd53198caa 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -24,5 +24,6 @@ lib
script
test-jasmine
test-jest
+test-utils/snippets
typedoc
typings
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9486ae9250..c8ab6f4c2d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -44,6 +44,8 @@ jobs:
- name: Install dependencies
run: npm ci
+ # Doc snippets are regenerated before the tests by the `pretest:ci` npm
+ # hook (runs `snippets:extract`); the generated files are not committed.
- name: Run tests
run: npm run test:ci -- --coverage
@@ -82,5 +84,7 @@ jobs:
- name: Install dependencies
run: npm ci
+ # Doc snippets are regenerated before the tests by the `pretest:browser`
+ # npm hook (runs `snippets:extract`); the generated files are not committed.
- name: Run tests
run: npm run test:browser
diff --git a/.gitignore b/.gitignore
index 98181e71c4..e4c1d80892 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@ dev*.html
.DS_Store
/test/hyperformula-tests/
+
+# Doc snippets are regenerated from the docs before every test run
+# (see the `snippets:extract` / `pretest:*` scripts), so they are never committed.
+/test-utils/snippets/*.generated.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 683e1da144..07af73229f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added
- Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674)
+- 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)
+- Added support for LCID-tagged currency format strings (e.g. `[$USD-409] #,##0.00`) in the `TEXT` function. Previously the date-time parser greedily consumed letter tokens inside the currency code (`D` in USD, `H` in CHF/HUF, etc.), producing corrupted output like `[$US9-409]`; such formats now fall through to the number formatter (or a user-supplied `stringifyCurrency` callback), regardless of whether `stringifyCurrency` is configured. [#1665](https://github.com/handsontable/hyperformula/pull/1665)
## [3.3.0] - 2026-05-20
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index d185119bc9..a4a8bd6675 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 5798f0404d..cc535dbcf7 100644
--- a/docs/guide/built-in-functions.md
+++ b/docs/guide/built-in-functions.md
@@ -28,7 +28,7 @@ spreadsheet software. That is because a spreadsheet is probably the most
universal software ever created. We wanted the same flexibility for HyperFormula
but without the constraints of the spreadsheet UI.
-Each of HyperFormula's built-in function names is available in [17 languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions) can be added.
+Each of HyperFormula's built-in function names is available in [17 languages](localizing-functions.md#list-of-supported-languages) and [custom language packs](localizing-functions.md) can be added.
The latest version of HyperFormula has an extensive collection of
**{{ $page.functionsCount }}** functions grouped into categories:
@@ -50,7 +50,7 @@ The latest version of HyperFormula has an extensive collection of
_Some categories such as compatibility and cube are yet to be supported._
::: tip
-You can modify the built-in functions or create your own, by adding a [custom function](custom-functions).
+You can modify the built-in functions or create your own, by adding a [custom function](custom-functions.md).
:::
## List of available functions
@@ -531,7 +531,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..3962ae3055
--- /dev/null
+++ b/docs/guide/currency-handling.md
@@ -0,0 +1,237 @@
+# Currency handling
+
+HyperFormula treats currency through **two independent mechanisms**:
+
+- **Currency input** — recognizing currency literals (e.g. `"100 zł"`) when they appear in cell values, so they become numeric values tagged as currency rather than strings. Controlled by [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol).
+- **Currency output** — rendering numbers as currency strings via the `TEXT` function. Simple `$`-prefixed formats work out of the box; richer locale-aware patterns plug in through [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency).
+
+The two mechanisms are orthogonal — configure both for full coverage. HyperFormula ships with no currency data and no currency-library dependency, so you stay in control of which symbols are recognized and how they render.
+
+## Currency input
+
+By default, HyperFormula recognizes `$` as a currency symbol in cell input. To add more (for example Polish złoty), pass an array of recognized symbols to [`currencySymbol`](../api/interfaces/configparams.md#currencysymbol):
+
+```javascript
+const hf = HyperFormula.buildFromArray(
+ [['100 zł', '=A1 * 1.23']],
+ { currencySymbol: ['$', 'zł'] }
+);
+
+console.log(hf.getCellValue({ sheet: 0, col: 0, row: 0 })); // 100
+console.log(hf.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 })); // 'NUMBER_CURRENCY'
+console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // 123
+```
+
+Notes:
+
+- The symbol can appear as a **prefix** (`"$100"`) or as a **suffix** (`"100 zł"`). Both forms are recognized.
+- Each entry in `currencySymbol` is a literal string — no regular expressions. To support multiple locales, list every symbol you want recognized.
+- Detected literals are exposed as numeric values; the currency tag is available via [`getCellValueDetailedType()`](../api/classes/hyperformula.md#getcellvaluedetailedtype) as `NUMBER_CURRENCY`.
+
+`currencySymbol` controls **only** how HyperFormula parses input. It does not influence what the `TEXT` function returns — that is governed by the format string and the [`stringifyCurrency`](#currency-output) callback described below.
+
+## Currency output
+
+The `TEXT` function renders a number with a format string. HyperFormula's built-in number formatter handles the simplest currency-shaped patterns out of the box; richer patterns need a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback.
+
+### Default behavior
+
+With no `stringifyCurrency` configured, the built-in 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"
+```
+
+A non-`$` symbol used purely as a suffix (no thousands grouping, no decimal-comma) also passes through unchanged:
+
+```javascript
+const hf = HyperFormula.buildFromArray([[1234.5, '=TEXT(A1, "0.00 zł")']]);
+console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "1234.50 zł"
+```
+
+Configure `stringifyCurrency` when your formula corpus needs more advanced currency formats. E.g.:
+
+- thousands grouping (`"$#,##0.00"`),
+- non-`$` symbols with grouping (`"[$€-2] #,##0.00"`, `"[$zł-415] #,##0.00"`),
+- locale-specific decimal separators (e.g. the Polish `"1234,50 zł"` pattern — the built-in formatter 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. For any format the callback opts out of, HyperFormula proceeds to the next handler in the dispatch chain: the default date / duration formatters, then the built-in number formatter, and finally the raw format string if nothing matched.
+
+#### 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.
+ // SYMBOL portion requires at least one character (`+`, not `*`) so that
+ // locale-only modifiers like `[$-409]` (used on date/time formats) are
+ // NOT misclassified as currency by this adapter.
+ {
+ 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.
+// Note: when both sections are plain (e.g. `$#,##0.00;$#,##0.00`), Excel
+// honors the negative section AS-IS without auto-prepending `-` — the
+// format author explicitly opted out of automatic sign. This adapter
+// mirrors that behavior.
+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.
+
+**Excel resolves LCID tags natively** — no extra configuration is required. For example, `[$€-2] #,##0.00` automatically uses European grouping and decimal separators and produces `1.234,50 €`; `[$zł-415] #,##0.00` uses `pl-PL` and produces `1 234,50 zł`. HyperFormula's built-in formatter does not resolve LCID tags; the adapter above replicates that behavior via `Intl.NumberFormat`.
+
+#### 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
+
+- [`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/i18n-features.md b/docs/guide/i18n-features.md
index 14bb7b3a05..f5c08280be 100644
--- a/docs/guide/i18n-features.md
+++ b/docs/guide/i18n-features.md
@@ -48,13 +48,7 @@ thousandSeparator: ',',
## Currency symbol
-To match your users' currency, you can configure multiple currency symbols ([`currencySymbol`](../api/interfaces/configparams.md#currencysymbol)).
-
-The default currency symbol is `$`. To add `USD` as an alternative, set:
-
-```js
-currencySymbol: ['$', 'USD'],
-```
+To match your users' currency, configure recognized currency symbols and (optionally) custom `TEXT` output formatting. Both sides are covered in the dedicated [Currency handling](currency-handling.md) guide.
## String comparison rules
diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md
index 3da1d2eece..6da2b3ae49 100644
--- a/docs/guide/known-limitations.md
+++ b/docs/guide/known-limitations.md
@@ -38,6 +38,7 @@ 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. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback.
### OFFSET function
diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md
index 2ba4e9479a..aa0e684fe1 100644
--- a/docs/guide/list-of-differences.md
+++ b/docs/guide/list-of-differences.md
@@ -34,7 +34,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,"###.###”) | To support all date, time and currency formats, set [`stringifyDateTime`](compatibility-with-microsoft-excel.md#date-and-time-formats) and [`stringifyCurrency`](currency-handling.md) configuration options. | 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/package.json b/package.json
index e2c8d29d17..b3c7fa5598 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"docs:code-examples:generate-js": "bash docs/code-examples-generator.sh",
"docs:code-examples:generate-all-js": "bash docs/code-examples-generator.sh --generateAll",
"docs:code-examples:format-all-ts": "bash docs/code-examples-generator.sh --formatAllTsExamples",
+ "snippets:extract": "node script/extract-doc-snippets.js",
"bundle-all": "cross-env HF_COMPILE=1 npm-run-all clean compile bundle:** verify-bundles",
"bundle:es": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=es env-cmd -f ht.config.js babel lib --out-file-extension .mjs --out-dir es",
"bundle:cjs": "(node script/if-ne-env.js HF_COMPILE=1 || npm run compile) && cross-env-shell BABEL_ENV=commonjs env-cmd -f ht.config.js babel lib --out-dir commonjs",
@@ -77,6 +78,7 @@
"verify:typings": "tsc --noEmit",
"test": "npm-run-all lint test:jest test:browser",
"test:setup-private": "bash test/fetch-tests.sh",
+ "pretest:jest": "npm run snippets:extract",
"test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest",
"test:watch": "npm run test:jest -- --watch",
"test:tmp": "npm run test:jest -- --watch function-irr",
@@ -84,7 +86,9 @@
"test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage",
"test:performance": "npm run benchmark:basic && npm run benchmark:cruds",
"test:compatibility": "bash test/compatibility/test-compatibility.sh",
+ "pretest:ci": "npm run snippets:extract",
"test:ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit",
+ "pretest:browser": "npm run snippets:extract",
"test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start",
"test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start",
"typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts",
diff --git a/script/extract-doc-snippets.js b/script/extract-doc-snippets.js
new file mode 100755
index 0000000000..cbcb8f4ba0
--- /dev/null
+++ b/script/extract-doc-snippets.js
@@ -0,0 +1,257 @@
+#!/usr/bin/env node
+/**
+ * extract-doc-snippets.js — extract documented code snippets so tests can
+ * import the same source-of-truth the docs publish.
+ *
+ * Walks `docs/**\/*.md`. For every snippet block of the form
+ *
+ *
+ * ```
+ * // code …
+ * ```
+ *
+ *
+ * writes the code to `test-utils/snippets/.generated.ts` with a header
+ * banner naming the source file. Tests then `import { … }` from the
+ * generated file instead of re-defining the snippet inline. The generated
+ * files are NOT committed: they are regenerated from the docs before every
+ * test run via the `pretest:*` hooks in package.json, which keeps the docs as
+ * the single source of truth and avoids duplicating the snippet code in the
+ * repository.
+ *
+ * **Generated content vs docs source.** The generated `.ts` is functionally
+ * equivalent to the docs snippet but NOT byte-identical: `stripBlockComments`
+ * removes lines whose entire content is a `//` comment (e.g. editorial
+ * section dividers) before writing. Trailing `// comment` after live code is
+ * preserved. The docs page keeps the educational comments for human readers;
+ * the generated artifact keeps only the runnable surface for the import
+ * consumer.
+ *
+ * The script intentionally has zero npm deps so it runs in the same Node we
+ * use for `compile` without adding to package.json.
+ *
+ * Constraints:
+ * - Snippet bodies must NOT contain nested triple-backtick fences. The
+ * closing fence matcher accepts any `^```\s*$` line, so a nested fenced
+ * block inside a snippet would terminate the outer match early.
+ * - Symlinks under `docs/` are not followed (loop / sandbox-escape guard).
+ * - Recursion depth is capped at MAX_DEPTH to prevent runaway walks.
+ * - File enumeration order is stabilised via sort() so generated content
+ * is byte-identical across platforms (CI-determinism).
+ *
+ * Exit codes:
+ * 0 — snippets extracted successfully (or no markers present)
+ * 1 — any structural error: malformed marker, mismatched markers,
+ * duplicate name, fence opened-but-not-closed, marker mismatch,
+ * or missing docs dir
+ */
+'use strict'
+
+const fs = require('fs')
+const path = require('path')
+
+const REPO_ROOT = path.resolve(__dirname, '..')
+const DOCS_DIR = path.join(REPO_ROOT, 'docs')
+const OUT_DIR = path.join(REPO_ROOT, 'test-utils', 'snippets')
+const MAX_DEPTH = 8
+
+/**
+ * Recursively list every `.md` file under `dir`. Skips symlinks and bails
+ * past MAX_DEPTH so a stray loop under `docs/` can't hang the build.
+ * Entries are sorted at every level for deterministic ordering across
+ * platforms / filesystems.
+ */
+function listMarkdown(dir, depth = 0) {
+ if (depth > MAX_DEPTH) {
+ console.error(`extract-doc-snippets: depth limit (${MAX_DEPTH}) exceeded under ${dir} — refusing to recurse further`)
+ return []
+ }
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
+ .filter((e) => !e.isSymbolicLink()) // never follow symlinks
+ .sort((a, b) => a.name.localeCompare(b.name))
+ const out = []
+ for (const entry of entries) {
+ const p = path.join(dir, entry.name)
+ if (entry.isDirectory()) out.push(...listMarkdown(p, depth + 1))
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p)
+ }
+ return out
+}
+
+/** Throw with a precise diagnostic when a line looks like a snippet marker
+ * but doesn't match the strict grammar (open or close). Catches typos such
+ * as `` (no spaces) which the strict regex skips silently. */
+const malformedSniffRe = /\s*$/
+ const closeRe = /^\s*$/
+ const fenceRe = /^```([a-zA-Z0-9]*)\s*$/
+
+ let i = 0
+ while (i < lines.length) {
+ const line = lines[i]
+ const openMatch = openRe.exec(line)
+ if (!openMatch) {
+ // Distinguish "not a marker at all" from "looks like a malformed marker".
+ if (malformedSniffRe.test(line) && !closeRe.exec(line)) {
+ throw new Error(
+ `${filePath}:${i + 1} — line looks like a snippet marker but doesn't match the grammar. ` +
+ `Required form: \`\` (note the spaces around the marker body). ` +
+ `Got: \`${line.trim()}\``,
+ )
+ }
+ i++
+ continue
+ }
+
+ const name = openMatch[1]
+ const startLine = i + 1
+ let j = i + 1
+ // Skip blank lines between marker and fence.
+ while (j < lines.length && lines[j].trim() === '') j++
+ const fenceOpen = fenceRe.exec(lines[j])
+ if (!fenceOpen) {
+ throw new Error(`${filePath}:${startLine} — snippet:${name} marker not followed by a fenced code block`)
+ }
+ const lang = fenceOpen[1] || 'ts'
+ const codeStart = j + 1
+ let k = codeStart
+ while (k < lines.length && !/^```\s*$/.test(lines[k])) k++
+ if (k >= lines.length) {
+ throw new Error(`${filePath}:${startLine} — snippet:${name} fence opened but never closed`)
+ }
+ // Find closing snippet marker after the fence close.
+ let m = k + 1
+ while (m < lines.length && lines[m].trim() === '') m++
+ const closeMatch = m < lines.length ? closeRe.exec(lines[m]) : null
+ if (!closeMatch) {
+ throw new Error(`${filePath}:${startLine} — snippet:${name} missing closing after fence`)
+ }
+ if (closeMatch[1] !== name) {
+ throw new Error(`${filePath}:${m + 1} — close marker /snippet:${closeMatch[1]} does not match open snippet:${name}`)
+ }
+
+ yield {
+ name,
+ lang,
+ code: lines.slice(codeStart, k).join('\n'),
+ sourceFile: path.relative(REPO_ROOT, filePath),
+ line: startLine,
+ }
+ i = m + 1
+ }
+}
+
+/**
+ * Strip pure-comment lines from the snippet body.
+ *
+ * Editorial `//` comments in the docs snippet (e.g. `// EUR (generic)`,
+ * `// $#,##0.00 — USD shorthand`) are useful in the published page where
+ * a reader is studying the code, but they add noise in the generated test
+ * artifact where a downstream `import` consumer only cares about the
+ * functional code. We strip lines that contain ONLY whitespace + `//
+ * comment` (block-level). Trailing `// comment` after live code is left
+ * alone — that case needs a JS-aware tokenizer to avoid clobbering URL
+ * literals like `'https://…'`, and the noise reduction from block-level
+ * stripping alone is already significant. Collapses runs of resulting
+ * blank lines to a single blank for readability.
+ */
+function stripBlockComments(code) {
+ const lines = code.split('\n')
+ const blockCommentRe = /^\s*\/\/.*$/
+ const kept = lines.filter((ln) => !blockCommentRe.test(ln))
+ // Collapse 2+ consecutive blank lines into 1 — keeps section breaks but
+ // avoids the "every comment was here" gaps the strip would otherwise leave.
+ const out = []
+ let prevBlank = false
+ for (const ln of kept) {
+ const isBlank = ln.trim() === ''
+ if (isBlank && prevBlank) continue
+ out.push(ln)
+ prevBlank = isBlank
+ }
+ return out.join('\n')
+}
+
+/** Render the generated file with a stable header banner. */
+function render({ name, lang, code, sourceFile, line }) {
+ const banner = [
+ '// Auto-generated by script/extract-doc-snippets.js — DO NOT EDIT.',
+ `// Source: ${sourceFile}:${line} (snippet:${name})`,
+ '// Edit the source markdown — this file is regenerated before every test',
+ '// run (the `pretest:*` hooks) and is not committed to the repository.',
+ '',
+ // Docs snippets are written in JS without TypeScript annotations (they
+ // need to copy-paste runnable for the reader). The .ts extension lets
+ // consumers `import { … }` without a tsconfig.allowJs change, but the
+ // body is intentionally untyped — `@ts-nocheck` keeps tsc quiet without
+ // forcing every snippet author to learn TypeScript.
+ '// @ts-nocheck',
+ '',
+ ].join('\n')
+ const body = stripBlockComments(code)
+ return banner + body + (body.endsWith('\n') ? '' : '\n')
+}
+
+/** Remove `*.generated.ts` files in OUT_DIR that aren't in `keep`. */
+function pruneOrphans(keep) {
+ if (!fs.existsSync(OUT_DIR)) return []
+ const removed = []
+ for (const name of fs.readdirSync(OUT_DIR).sort()) {
+ if (!name.endsWith('.generated.ts')) continue
+ if (keep.has(name)) continue
+ fs.unlinkSync(path.join(OUT_DIR, name))
+ removed.push(path.relative(REPO_ROOT, path.join(OUT_DIR, name)))
+ }
+ return removed
+}
+
+function main() {
+ if (!fs.existsSync(DOCS_DIR)) {
+ console.error(`extract-doc-snippets: docs dir not found at ${DOCS_DIR}`)
+ process.exit(1)
+ }
+ fs.mkdirSync(OUT_DIR, { recursive: true })
+
+ const seen = new Map()
+ const written = []
+ const keepBasenames = new Set()
+ for (const file of listMarkdown(DOCS_DIR)) {
+ for (const snippet of extractSnippets(file)) {
+ if (seen.has(snippet.name)) {
+ const prev = seen.get(snippet.name)
+ console.error(`extract-doc-snippets: duplicate snippet name "${snippet.name}"`)
+ console.error(` first: ${prev.sourceFile}:${prev.line}`)
+ console.error(` second: ${snippet.sourceFile}:${snippet.line}`)
+ process.exit(1)
+ }
+ seen.set(snippet.name, snippet)
+ const basename = `${snippet.name}.generated.ts`
+ const outPath = path.join(OUT_DIR, basename)
+ fs.writeFileSync(outPath, render(snippet))
+ written.push(path.relative(REPO_ROOT, outPath))
+ keepBasenames.add(basename)
+ }
+ }
+
+ const removed = pruneOrphans(keepBasenames)
+
+ if (written.length === 0 && removed.length === 0) {
+ console.log('extract-doc-snippets: no blocks found')
+ return
+ }
+ if (written.length > 0) {
+ console.log(`extract-doc-snippets: wrote ${written.length} file(s)`)
+ for (const w of written) console.log(` ${w}`)
+ }
+ if (removed.length > 0) {
+ console.log(`extract-doc-snippets: pruned ${removed.length} orphan(s)`)
+ for (const r of removed) console.log(` - ${r}`)
+ }
+}
+
+main()
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..71aeb0bb7d 100644
--- a/src/ConfigParams.ts
+++ b/src/ConfigParams.ts
@@ -310,6 +310,22 @@ 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`. The formatter calls this for
+ * every format string that reaches it, not only currency-shaped ones — return
+ * `undefined` for any format your callback does not handle and HyperFormula
+ * will fall through to the built-in number formatter.
+ *
+ * For more information, see the [Currency handling guide](/guide/currency-handling.md).
+ *
+ * @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..52e1192547 100644
--- a/src/format/format.ts
+++ b/src/format/format.ts
@@ -10,7 +10,36 @@ import {RawScalarValue} from '../interpreter/InterpreterValue'
import {Maybe} from '../Maybe'
import {FormatToken, parseForDateTimeFormat, parseForNumberFormat, TokenType} from './parser'
+/**
+ * Detects Excel LCID-tagged currency tags (`[$SYMBOL-LCID]` with a non-empty
+ * SYMBOL portion). Shared by `defaultStringifyDateTime` and
+ * `defaultStringifyDuration` so a format string carrying such a tag short-
+ * circuits both date and duration dispatch and falls through to the
+ * number formatter (or the user-supplied `stringifyCurrency` callback).
+ *
+ * The pattern is intentionally unanchored: any occurrence of `[$SYMBOL-`
+ * in the format string triggers the guard. Excel does not mix date/time
+ * tokens with a currency tag in the same format string, so a mid-string
+ * match cannot misclassify a legitimate composite — every observed
+ * format string with a currency tag is currency-only.
+ */
+const LCID_CURRENCY_TAG = /\[\$[^\-\]]+-/
+
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
@@ -80,7 +109,30 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue {
return result
}
+/**
+ * Default `stringifyDuration` callback — formats a duration value against an
+ * Excel-style time format string (e.g. `[hh]:mm:ss`).
+ *
+ * Returns `undefined` for format strings that are not duration formats so the
+ * dispatcher in `format()` can fall through to other handlers.
+ *
+ * **LCID currency-tag guard** — sibling to the same guard in
+ * `defaultStringifyDateTime`; explicitly returns `undefined` for Excel
+ * currency tags `[$SYMBOL-LCID]` because the SYMBOL portion contains
+ * duration-token letters (`H` in CHF/HUF, `m` in AMD/HMD) that
+ * `parseForDateTimeFormat` would otherwise interpret as time tokens and
+ * mangle the output. See `defaultStringifyDateTime` for the full
+ * symbol-vs-locale-modifier rationale and the historical pre-HF-24
+ * behaviour the guard corrects.
+ *
+ * @param time parsed duration value to render
+ * @param formatArg Excel-style format string
+ * @returns formatted string, or `undefined` to defer to the next dispatch step
+ */
export function defaultStringifyDuration(time: SimpleTime, formatArg: string): Maybe {
+ if (LCID_CURRENCY_TAG.test(formatArg)) {
+ return undefined
+ }
const expression = parseForDateTimeFormat(formatArg)
if (expression === undefined) {
return undefined
@@ -142,7 +194,37 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M
return result
}
+/**
+ * Default `stringifyDateTime` callback — formats a date/time value against an
+ * Excel-style format string (e.g. `YYYY-MM-DD HH:mm:ss`).
+ *
+ * Returns `undefined` for format strings that are not date/time formats so the
+ * dispatcher in `format()` can fall through to `parseForNumberFormat` (or to a
+ * user-supplied `stringifyCurrency` callback for currency-tagged formats).
+ *
+ * **LCID currency-tag guard** — explicitly returns `undefined` for Excel
+ * currency tags `[$SYMBOL-LCID]` (non-empty SYMBOL portion). Without the
+ * guard, `parseForDateTimeFormat` greedily consumes letters like `D`/`M`/`S`/`Y`/`H`
+ * inside the currency code (e.g. `D` in USD, `H` in CHF, `M`+`D` in AMD),
+ * mangling the output of an `[$USD-409] #,##0.00` format into
+ * `[$US9-409] #,##0.00` because `D` is read as a day token. The pre-HF-24
+ * behaviour was to mis-format; the guarded return is the deliberate
+ * correction, not a regression. Bit-for-bit compatibility is preserved for
+ * every non-currency format (dates, durations, `$#,##0.00`, etc.).
+ *
+ * The guard pattern (`/\[\$[^\-\]]+-/`) requires ≥1 character between `[$`
+ * and `-` so it distinguishes 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.
+ *
+ * @param dateTime parsed date/time value to render
+ * @param formatArg Excel-style format string
+ * @returns formatted string, or `undefined` to defer to the next dispatch step
+ */
export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe {
+ if (LCID_CURRENCY_TAG.test(formatArg)) {
+ return undefined
+ }
const expression = parseForDateTimeFormat(formatArg)
if (expression === undefined) {
return undefined
@@ -229,3 +311,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
+}
diff --git a/tsconfig.test.json b/tsconfig.test.json
index a40e705170..9c874320bf 100644
--- a/tsconfig.test.json
+++ b/tsconfig.test.json
@@ -7,7 +7,7 @@
"sourceMap": true
},
"extends": "./tsconfig",
- "include": ["src", "test"],
+ "include": ["src", "test", "test-utils"],
/* Exclude files that are specific for jest setup */
"exclude": ["test/_setupFiles/jest"]
}