From 41617fb5b84d83bede5dc863628f4ef8e4c8e91e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:38:57 +0000 Subject: [PATCH 01/21] Feature: HF-24 introduce defaultStringifyCurrency --- src/format/format.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index e605209f5d..da0922cda1 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -229,3 +229,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 {number} _value - the numeric value to format (unused in default). + * @param {string} _formatArg - the format string passed to `TEXT` (unused in default). + * @returns {Maybe} `undefined` — caller should fall through to the built-in formatter. + */ +export function defaultStringifyCurrency(_value: number, _formatArg: string): Maybe { + return undefined +} From 6e9c3a184430af31d79155b8a409dced7db28f4e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:52:44 +0000 Subject: [PATCH 02/21] Feature: HF-24 declare stringifyCurrency on ConfigParams interface --- src/ConfigParams.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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. * From 1a7564e3033ce2d8a042092970da14347c2d9dc3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 19:58:34 +0000 Subject: [PATCH 03/21] Feature: HF-24 wire stringifyCurrency through Config class --- src/Config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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') From 3417a74457646a20fd63e4fd794ed7767d66652d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:08:54 +0000 Subject: [PATCH 04/21] Feature: HF-24 dispatch stringifyCurrency in format() --- src/format/format.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index da0922cda1..5bcce5abb2 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) From d92e53f366eb0e6743fa33de47c17a9d12992a07 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:41:51 +0000 Subject: [PATCH 05/21] Docs: HF-24 add Currency integration section to date-and-time guide --- docs/guide/date-and-time-handling.md | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 752d50170e..d0ca1da15a 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -96,6 +96,138 @@ 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ł, €) + { + 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) + +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 From 19eaa41d4ebe1f95190f985a40f1d0d2161b59e6 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 20:42:48 +0000 Subject: [PATCH 06/21] Docs: HF-24 changelog entry for stringifyCurrency --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21858277a3..20f679adb8 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. [#1572](https://github.com/handsontable/hyperformula/issues/1572) ### Fixed From 09babfd35a400cdcc776d90bcf3c97cd45cad17b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 27 Apr 2026 21:22:13 +0000 Subject: [PATCH 07/21] Fix: HF-24 correct CHANGELOG issue ref and clarify docs adapter - Replace wrong #1572 with correct #1145 (TEXT currency formats issue) - Add parser limitation note to trailing-quote adapter rule - Add NBSP note above console.log output example block --- CHANGELOG.md | 2 +- docs/guide/date-and-time-handling.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f679adb8..2e87127a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +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. [#1572](https://github.com/handsontable/hyperformula/issues/1572) +- 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 d0ca1da15a..a19b17e463 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -144,7 +144,10 @@ const CURRENCY_RULES = [ maximumFractionDigits: (match[1] || '.').length - 1, }), }, - // #,##0.00 "SYM" — trailing quoted symbol (e.g. zł, €) + // #,##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) => { @@ -207,7 +210,11 @@ const hf = HyperFormula.buildFromArray([ [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)" From 02134b55c348f46ca5c745907cef253fedb7b28a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 29 Apr 2026 10:21:12 +0000 Subject: [PATCH 08/21] Docs: HF-24 strip {type} JSDoc tags from defaultStringifyCurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code review — TypeScript signature already declares parameter and return types, so {type} brackets in JSDoc are redundant noise and inconsistent with the sibling exported functions in this file (defaultStringifyDuration, defaultStringifyDateTime have no JSDoc type tags). --- src/format/format.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index 5bcce5abb2..9c1a9906b4 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -243,9 +243,9 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st * [`stringifyCurrency`](../../api/interfaces/configparams.md#stringifycurrency) * config option. * - * @param {number} _value - the numeric value to format (unused in default). - * @param {string} _formatArg - the format string passed to `TEXT` (unused in default). - * @returns {Maybe} `undefined` — caller should fall through to the built-in formatter. + * @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 From 299e0bbfefd1b3ff19684574205d9733060437b1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 8 May 2026 11:57:47 +0000 Subject: [PATCH 09/21] Docs: HF-24 align stringifyCurrency JSDoc @category with sibling stringify callbacks --- src/ConfigParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConfigParams.ts b/src/ConfigParams.ts index a4d94b155f..93c244e9df 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -322,7 +322,7 @@ export interface ConfigParams { * * @default defaultStringifyCurrency * - * @category Number + * @category Date and Time */ stringifyCurrency: (value: number, currencyFormat: string) => Maybe, /** From e4a5cf122906e6795761d9f054c1560dfdebe416 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 9 May 2026 02:17:02 +0000 Subject: [PATCH 10/21] Chore: HF-24 retrigger codecov upload From a180a8dcbcdf795f7e4aa877cb7fbbac7eabd124 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 03:40:22 +0000 Subject: [PATCH 11/21] Docs: HF-24 align currency-integration text with PR body (drop count claim, mention U+202F NBSP) --- docs/guide/date-and-time-handling.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index a19b17e463..4f7c7cb96a 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -212,7 +212,7 @@ const hf = HyperFormula.buildFromArray([ ], 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. +Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. ```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" @@ -222,7 +222,7 @@ 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: +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), From b81d4afac46803accfd98c9bffe9f2ee163a0ab1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 03:51:10 +0000 Subject: [PATCH 12/21] Docs: HF-24 final cross-doc polish (TEXT xref, currency callback diff entry, embedded-quote nuance, adapter guard) --- docs/guide/built-in-functions.md | 2 +- docs/guide/date-and-time-handling.md | 1 + docs/guide/known-limitations.md | 1 + docs/guide/list-of-differences.md | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index e1ad668212..529dfd0f0b 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -508,7 +508,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/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 4f7c7cb96a..d4d35851ec 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -185,6 +185,7 @@ function tryAccountingFormat(value, format) { } export const customStringifyCurrency = (value, currencyFormat) => { + if (typeof currencyFormat !== 'string') return undefined const accounting = tryAccountingFormat(value, currencyFormat) if (accounting !== undefined) return accounting diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 72753b9b26..334307e7bf 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 the LCID-tagged form (`[$zł-415] #,##0.00`) or supply a custom [`stringifyCurrency`](configuration-options.md#stringifycurrency) callback that handles such formats outside the parser. diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 9dac88022f..35f34d885d 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`).

Currency formatting is opt-in via the [`stringifyCurrency`](date-and-time-handling.md#currency-integration) callback; without it, currency format strings fall through to the built-in number formatter.

Embedded double-quote literals (e.g. `#,##0.00 "zł"`) are not accepted by the parser; use the LCID-tagged form (`[$zł-415] #,##0.00`) instead. | 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** | From 18aa092e8654d9fe17b752b7b2036e82a3fcc747 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 04:32:42 +0000 Subject: [PATCH 13/21] Docs: HF-24 wrap NBSP note in :::tip callout for discoverability --- docs/guide/date-and-time-handling.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index d4d35851ec..df4be58890 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -213,7 +213,9 @@ const hf = HyperFormula.buildFromArray([ ], options) ``` -Note: the actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: tip +The actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. +::: ```javascript console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })) // "1.234,50 €" From e4fcd52e90d1473ee10c3a7493f22840e5d59151 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 05:45:27 +0000 Subject: [PATCH 14/21] Docs: HF-24 redesign Currency integration for friction elimination - Add typed contract signature block at top - Add Minimal example subsection (3-line callback for fresh-user contract) - Add Default behavior subsection (explains defaultStringifyCurrency) - Add Error behavior subsection (callback exception propagation) - Add MS-LCID specification link in adapter intro - Drop trailing-quote rule from CURRENCY_RULES (not callable from TEXT) - Move NBSP tip below console.log output (was between config and output) --- docs/guide/date-and-time-handling.md | 62 +++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index df4be58890..428b623929 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -100,11 +100,43 @@ And now, HyperFormula recognizes these values as valid dates and can operate on 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). +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 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. + +### Default behavior + +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in number formatter then handles `$`-prefixed formats and other Excel format strings as before. The callback is purely additive; leaving it unset preserves the existing `TEXT` behavior bit-for-bit. + +### 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 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. +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. @@ -144,24 +176,6 @@ const CURRENCY_RULES = [ 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 @@ -213,16 +227,16 @@ const hf = HyperFormula.buildFromArray([ ], options) ``` -::: tip -The actual return values from `Intl.NumberFormat` use non-breaking spaces as locale-appropriate separators — typically U+00A0 (regular NBSP), but modern ICU/CLDR also emit U+202F (narrow NBSP) for some locales, e.g. `pl-PL` digit grouping. The comments above show both as regular spaces for readability. Be aware when comparing strings programmatically; normalize with `.replace(/[  ]/g, ' ')` if you need ASCII-space output. -::: - ```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. +::: + ### 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: From d119b4cea467039ffbc269fc9b3487214213ccd7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 06:58:01 +0000 Subject: [PATCH 15/21] Fix: HF-24 dispatch stringifyCurrency before stringifyDateTime to prevent letter-format hijack The previous order (DateTime -> Duration -> Currency) let parseForDateTimeFormat greedily match characters D, M, S, Y, H inside currency format strings. Formats like '[$USD-409] #,##0.00' or 'USD #,##0.00' were converted to '[$US9-409] #,##0.00' before the user-supplied stringifyCurrency callback could intercept them. Currency dispatch now runs first. The default callback returns undefined for every input, so the existing date/time/duration/number-format chain is preserved bit-for-bit when stringifyCurrency is not set. Found by Codex review (codex-cli 0.130.0, base develop, max effort). --- src/format/format.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index 9c1a9906b4..b78cc00af1 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -11,6 +11,16 @@ 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, preserving the + // existing dispatch path bit-for-bit when stringifyCurrency is not set. + 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 @@ -19,10 +29,6 @@ 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) From d496e300010ed154c1a6d9f81ecbc1e3e05e30b3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 07:32:35 +0000 Subject: [PATCH 16/21] Fix: HF-24 skip date dispatch for LCID-tagged currency formats defaultStringifyDateTime now returns undefined when formatArg contains Excel's LCID-tagged currency notation [$SYMBOL-LCID]. Without this guard, parseForDateTimeFormat greedily consumed D/M/S/Y/H letters inside the currency code, mangling output even when a user-supplied stringifyCurrency callback returned undefined for opt-out. Before: TEXT(100, '[$USD-409] #,##0.00') with partial callback -> '[$US9-409] #,##0.00' (D->9 mangle) After: TEXT(100, '[$USD-409] #,##0.00') with partial callback -> '[$USD-41009] #,##0.00' (USD preserved, falls through to numberFormat) Excel never uses [$...] for date formats, so the guard is unambiguous. Found by Codex re-review (after first dispatch reorder fix d119b4cea). --- src/format/format.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index b78cc00af1..834af0c728 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -153,6 +153,15 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M } export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { + // Skip date/time interpretation for currency formats marked with Excel's + // LCID-tagged notation `[$SYMBOL-LCID]`. 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. Excel format strings never use `[$...]` for dates. + if (/\[\$[^\]]*\]/.test(formatArg)) { + return undefined + } const expression = parseForDateTimeFormat(formatArg) if (expression === undefined) { return undefined From b7c61a5be9cd264df8a6b37ac85ddb4e98a93149 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 07:39:34 +0000 Subject: [PATCH 17/21] Fix: HF-24 narrow LCID guard to currency tags only (preserve [$-LCID] date locale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review identified that the prior LCID guard (introduced in d496e3000) over-matched Excel's locale-only modifier syntax `[$-LCID]` used in date and time formats (e.g. `[$-409]dd/mm/yyyy`), incorrectly skipping date dispatch and falling through to numberFormat. The guard regex now requires a non-empty SYMBOL portion between `[$` and the dash. Currency tags (`[$USD-409]`, `[$€-2]`, `[$zł-415]`) continue to skip date dispatch as intended; locale-only modifiers (`[$-409]`, `[$-F800]`) flow through to parseForDateTimeFormat as before. Also softens the 'bit-for-bit preserved' doc claim: for LCID-tagged currency formats without a callback, output now goes through numberFormat (best-effort) instead of the pre-existing date-parser hijack. Setting stringifyCurrency remains the recommended path. --- docs/guide/date-and-time-handling.md | 2 +- src/format/format.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 428b623929..672aab6ec2 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -128,7 +128,7 @@ This callback handles `$`-prefixed formats and falls through (returns `undefined ### Default behavior -If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in number formatter then handles `$`-prefixed formats and other Excel format strings as before. The callback is purely additive; leaving it unset preserves the existing `TEXT` behavior bit-for-bit. +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in dispatch chain (date, duration, and number formatters) handles the format string. For non-currency formats this preserves the existing `TEXT` behavior. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`), the built-in number formatter produces best-effort output (the LCID tag is treated as literal characters); setting `stringifyCurrency` is the recommended way to get locale-aware output for these formats. ### Error behavior diff --git a/src/format/format.ts b/src/format/format.ts index 834af0c728..1be80a55b4 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -153,13 +153,18 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M } export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: string): Maybe { - // Skip date/time interpretation for currency formats marked with Excel's - // LCID-tagged notation `[$SYMBOL-LCID]`. 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. Excel format strings never use `[$...]` for dates. - if (/\[\$[^\]]*\]/.test(formatArg)) { + // 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) From 80fd34e3eec09941dbdd5ec07eed467b69775e9b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 08:10:35 +0000 Subject: [PATCH 18/21] Fix: HF-24 add LCID guard to defaultStringifyDuration (sibling consistency) Bugbot identified that the LCID-tagged currency guard added to defaultStringifyDateTime (b7c61a5be) was missing from its sibling defaultStringifyDuration. Currency symbols containing duration-token letters (H in CHF/HUF, M in AMD/HMD) were interpreted as time tokens when a stringifyCurrency callback returned undefined. Applies the same regex `/\[\$[^\-\]]+-/` guard in identical position to preserve sibling parity with defaultStringifyDateTime. --- src/format/format.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/format/format.ts b/src/format/format.ts index 1be80a55b4..fa92b0a7bb 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -91,6 +91,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 From 19ce06a632df8487464ac288f13900895eb808cd Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 08:23:40 +0000 Subject: [PATCH 19/21] Docs: HF-24 clarify dispatcher comment after LCID guard introduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot Low: previous comment 'preserving the existing dispatch path bit-for-bit when stringifyCurrency is not set' was inaccurate after the LCID guards landed in defaultStringifyDateTime/Duration. For non-currency formats bit-for-bit holds; for LCID-tagged currency formats output now falls through to numberFormat instead of being mangled by the date parser — a deliberate improvement, not a preservation. Comment reworded to acknowledge this. --- src/format/format.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/format/format.ts b/src/format/format.ts index fa92b0a7bb..58b4c941bf 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -15,8 +15,12 @@ export function format(value: number, formatArg: string, config: Config, dateHel // 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, preserving the - // existing dispatch path bit-for-bit when stringifyCurrency is not set. + // 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 From eab70dd7f8b1515587839491639626248712c713 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 09:04:42 +0000 Subject: [PATCH 20/21] Chore: HF-24 retrigger CI after tests-repo develop merge Public branch merged upstream/develop in d77d5a643 (bringing HF-85 D-function code). Tests-repo branch merged origin/develop in 354b872 (bringing HF-85 D-function tests). CI clones tests-repo by matching branch name, so this empty commit re-runs the full matrix with the updated tests checkout. Should resolve the codecov/project drop (was -1.40% because D-function code shipped without matching tests in the same branch namespace). From 9afc8bdfba69299bca56a3896fdd7f823eaf5128 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 11 May 2026 13:43:37 +0000 Subject: [PATCH 21/21] Docs: HF-24 correct default behavior claims for currency formats The previous wording suggested that the built-in number formatter handles `$#,##0.00` via the default dispatch path. Sandbox audit showed the built-in numberFormat actually fails on any format that includes the comma thousands separator: TEXT(1234.5, "$#,##0.00") -> "$1235,##0.00" (not "$1,234.50") The intro paragraph now lists only the formats that genuinely work without a callback (`$0.00`, `$0`, `$#.00`) and explicitly calls out the broken cases (`$#,##0.00`, LCID-tagged, accounting two-section). The Default behavior subsection gains a side-by-side comparison table (without callback / with adapter / Excel) and a recommendation to set `stringifyCurrency` for any application showing currency to users. Docs-only change. No source or test impact. --- docs/guide/date-and-time-handling.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/guide/date-and-time-handling.md b/docs/guide/date-and-time-handling.md index 672aab6ec2..afb8ee23a6 100644 --- a/docs/guide/date-and-time-handling.md +++ b/docs/guide/date-and-time-handling.md @@ -98,7 +98,7 @@ And now, HyperFormula recognizes these values as valid dates and can operate on ## 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. +By default, the `TEXT` function renders only the simplest currency-looking formats — `"$0.00"`, `"$0"`, or `"$#.00"` (no thousands separator). Common Excel patterns such as `"$#,##0.00"` (with comma grouping), `"[$€-2] #,##0.00"` (EUR with German grouping), `"[$zł-415] #,##0.00"` (PLN), or accounting two-section formats like `"$#,##0.00;($#,##0.00)"` are **not** rendered correctly by the built-in number formatter; provide a [`stringifyCurrency`](../api/interfaces/configparams.md#stringifycurrency) callback to handle them. 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. @@ -128,7 +128,17 @@ This callback handles `$`-prefixed formats and falls through (returns `undefined ### Default behavior -If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input — the built-in dispatch chain (date, duration, and number formatters) handles the format string. For non-currency formats this preserves the existing `TEXT` behavior. For LCID-tagged currency formats (`[$SYMBOL-LCID] ...`), the built-in number formatter produces best-effort output (the LCID tag is treated as literal characters); setting `stringifyCurrency` is the recommended way to get locale-aware output for these formats. +If you don't set `stringifyCurrency`, HyperFormula uses `defaultStringifyCurrency` which returns `undefined` for every input. For non-currency formats (`mm/dd/yyyy`, `hh:mm`, etc.) the built-in dispatch chain handles the format string and preserves the existing `TEXT` behavior bit-for-bit. For currency-looking formats the built-in number formatter is intentionally limited: + +| 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"` (broken) | `"$1,234.50"` | `"$1,234.50"` | +| `"[$€-2] #,##0.00"` | `"[$€-2] 1235,##0.00"` (broken) | `"1.234,50 €"` | `"1.234,50 €"` | +| `"$#,##0.00;($#,##0.00)"` (value `-1234.5`) | `"$-1235,##0.00;($#,##0.00)"` (broken) | `"($1,234.50)"` | `"($1,234.50)"` | + +**Recommendation:** for any application that surfaces currency to end users, configure `stringifyCurrency` — either with the `Intl.NumberFormat` adapter below (zero dependencies) or with a library of your choice. Leaving it unset is appropriate only when the formula corpus does not include currency-shaped TEXT formats. ### Error behavior