Skip to content

Commit fa890f6

Browse files
fix(format): support thousands grouping and format sections in numberFormat/TEXT (HF-287)
The TEXT number formatter understood only a single simple mask (`[#0]+(\.[#0]*)?`), so complex masks leaked their unparsed tail into the output (e.g. `TEXT(1234.5,"#,##0.00")` -> `1235,##0.00`) and it ignored the instance's configured separators. Extend the existing formatter in place (Option A): - parser.ts: widen the number-format regex to a FLAT class `[#0,]+(\.[#0]*)?` that admits the grouping comma (no nested quantifier — DEV-2120 ReDoS discipline). Export its source for a white-box shape test. - format.ts: strip presentational color tags (`[Red]`, ...) after the currency callback and before date/time dispatch; split the mask into sign-selected sections (positive;negative;zero) honoring quotes/escapes; thread Config so the decimal glyph uses `decimalSeparator` and grouping uses `thousandSeparator` (empty on default config -> no visible glyph). Sign is extracted on `abs`, fixing the pre-existing `padLeft('-5',3)` bug (`TEXT(-5,"000.00")` -> `-005.00`). Trailing scaler commas degrade to a visible literal rather than silently mis-scaling. Parse failures fall back to the cleaned format string. No public API change, no i18n, no grammar rewrite. Percent scaling, scaler arithmetic, `?` placeholders, scientific notation and `[condition]` comparators remain out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23a7437 commit fa890f6

5 files changed

Lines changed: 568 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1818

1919
### Fixed
2020

21+
- Fixed the `TEXT` function so that number-format masks with thousands grouping (`#,##0`) and positive/negative/zero sections (`0.00;(0.00)`) are formatted correctly instead of leaking the unparsed mask into the output. The built-in number formatter now also honors the configured `decimalSeparator` and `thousandSeparator` and ignores color tags such as `[Red]`. [#1716](https://github.com/handsontable/hyperformula/pull/1716)
2122
- Fixed the behavior of `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697)
2223
- Fixed the `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions to return `0` instead of an empty value when the matched cell in the result range is empty. [#1697](https://github.com/handsontable/hyperformula/pull/1697)
2324

docs/guide/compatibility-with-microsoft-excel.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ Options related to date and time formats:
158158

159159
### `TEXT` function formats
160160

161-
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.
161+
Excel's `TEXT` function supports a wide range of date, time, and number formats. The built-in number formatter handles digit placeholders (`#`, `0`), thousands grouping (`#,##0`), and positive/negative/zero sections (`0.00;(0.00);0.0`). Two nuances follow from HyperFormula's config-authoritative model:
162+
163+
- Separators come from the instance config, not the runtime locale. The decimal point uses [`decimalSeparator`](../api/interfaces/configparams.md#decimalseparator) and the grouping glyph uses [`thousandSeparator`](../api/interfaces/configparams.md#thousandseparator). Because the default `thousandSeparator` is an empty string, a `#,##0` mask emits no visible grouping glyph until you configure one — and configuring `thousandSeparator: ','` also requires moving [`functionArgSeparator`](../api/interfaces/configparams.md#functionargseparator) off its default comma, since the three separators must be mutually distinct.
164+
- The built-in formatter does not implement percent scaling (`0.00%`), scaler commas (`0,,`), the `?` placeholder, scientific notation (`0.00E+00`), or `[condition]` comparators. For locale-aware grouping, non-`$` currency symbols, and accounting two-section patterns, supply [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example.
162165

163166
## Full configuration
164167

src/format/format.ts

Lines changed: 237 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,202 @@ export function format(value: number, formatArg: string, config: Config, dateHel
4040
if (tryCurrency !== undefined) {
4141
return tryCurrency
4242
}
43-
const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), formatArg) // default points to defaultStringifyDateTime()
43+
// Strip presentational color tags AFTER the (user-pluggable) currency callback
44+
// — which may inspect the raw formatArg — and BEFORE date/time dispatch. Doing
45+
// it here (not inside the number path) means a colored date like
46+
// `[Red]YYYY-MM-DD` loses only its color tag and still renders as a date, and
47+
// a color tag such as `[Red]` (which contains a `d`) can no longer be
48+
// hijacked by the date/time parser. See stripColorTags.
49+
const cleanedFormatArg = stripColorTags(formatArg)
50+
const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), cleanedFormatArg) // default points to defaultStringifyDateTime()
4451
if (tryDateTime !== undefined) {
4552
return tryDateTime
4653
}
47-
const tryDuration = config.stringifyDuration(numberToSimpleTime(value), formatArg)
54+
const tryDuration = config.stringifyDuration(numberToSimpleTime(value), cleanedFormatArg)
4855
if (tryDuration !== undefined) {
4956
return tryDuration
5057
}
51-
const expression = parseForNumberFormat(formatArg)
52-
if (expression !== undefined) {
53-
return numberFormat(expression.tokens, value)
58+
return formatNumberWithSections(cleanedFormatArg, value, config)
59+
}
60+
61+
const COLOR_TAG_REGEX = /\[(black|blue|cyan|green|magenta|red|white|yellow|color\s?(?:[1-9]|[1-4]\d|5[0-6]))\]/gi
62+
63+
/**
64+
* Removes Excel color tags (`[Red]`, `[Blue]`, …, `[Color56]`) from a format
65+
* string. HyperFormula's `TEXT` output is a plain string with no color channel,
66+
* so presentational color tags are semantically meaningless and are simply
67+
* dropped.
68+
*
69+
* The whitelist is deliberately color-NAME-specific (flat alternation, no
70+
* nested quantifier) so it cannot clobber other bracketed tokens: duration
71+
* tags `[hh]`/`[mm]`, currency/locale tags `[$USD-409]`/`[$-409]`, and
72+
* condition tags `[>=100]` are all left untouched.
73+
*
74+
* @param formatArg the raw format string
75+
* @returns the format string with recognized color tags removed
76+
*/
77+
function stripColorTags(formatArg: string): string {
78+
return formatArg.replace(COLOR_TAG_REGEX, '')
79+
}
80+
81+
/**
82+
* Splits an Excel number-format string into its sign-selected sections on
83+
* unescaped `;`, honoring `\;` escapes and `"…"` quoted literals so a semicolon
84+
* inside a quoted literal does not split. Excel uses up to four sections
85+
* (`positive;negative;zero;text`); only the first three are ever selected for a
86+
* numeric value, so no cap is enforced here — surplus sections are simply never
87+
* read.
88+
*
89+
* @param formatStr the (color-stripped) format string
90+
* @returns the list of raw section strings, in order
91+
*/
92+
function splitIntoSections(formatStr: string): string[] {
93+
const sections: string[] = []
94+
let current = ''
95+
let inQuotes = false
96+
97+
for (let i = 0; i < formatStr.length; i++) {
98+
const ch = formatStr[i]
99+
100+
if (ch === '\\') {
101+
// Keep the backslash and the escaped character together, verbatim.
102+
current += ch
103+
if (i + 1 < formatStr.length) {
104+
current += formatStr[i + 1]
105+
i++
106+
}
107+
continue
108+
}
109+
if (ch === '"') {
110+
inQuotes = !inQuotes
111+
current += ch
112+
continue
113+
}
114+
if (ch === ';' && !inQuotes) {
115+
sections.push(current)
116+
current = ''
117+
continue
118+
}
119+
current += ch
120+
}
121+
sections.push(current)
122+
123+
return sections
124+
}
125+
126+
/**
127+
* Strips the delimiting double-quotes from quoted literals so `"zł"` renders as
128+
* `zł`. NUANCE (documented, out of HF-287 scope): quotes are removed globally,
129+
* so digit/placeholder characters *inside* quotes are NOT protected from the
130+
* number tokenizer — a rare Excel case that this incremental formatter does not
131+
* cover.
132+
*
133+
* @param section a single format section
134+
* @returns the section with double-quote delimiters removed
135+
*/
136+
function stripQuotes(section: string): string {
137+
return section.replace(/"/g, '')
138+
}
139+
140+
/**
141+
* Selects the format section for a value by its RAW sign (before rounding) and
142+
* returns the value to feed the formatter:
143+
*
144+
* - `> 0` → positive section (section 0), formatted signed (non-negative).
145+
* - `< 0` → negative section (section 1) when present, formatted on `abs` (the
146+
* section's own literals, e.g. `(0.00)`, carry the sign); when only one
147+
* section exists the signed value is passed so the formatter re-adds `-`.
148+
* - `= 0` → zero section (section 2) when present, else the positive section.
149+
*
150+
* Excel-canonical fallbacks: 1 section → all values; 2 sections → `[pos+zero ;
151+
* neg]`; 3 sections → `[pos ; neg ; zero]`; a missing zero section falls back to
152+
* positive.
153+
*
154+
* @param sections the split format sections
155+
* @param value the numeric value being formatted
156+
* @returns the chosen section string and the (sign-adjusted) value to format
157+
*/
158+
function pickSection(sections: string[], value: number): { sectionStr: string, valueForFormat: number } {
159+
if (value < 0 && sections.length >= 2) {
160+
// Explicit negative section: its literals carry the sign, so format abs.
161+
return {sectionStr: sections[1], valueForFormat: Math.abs(value)}
162+
}
163+
if (value === 0 && sections.length >= 3) {
164+
return {sectionStr: sections[2], valueForFormat: 0}
165+
}
166+
// Positive, zero-without-a-zero-section, or single-section negative (the
167+
// signed value flows through so numberFormat re-adds the leading `-`).
168+
return {sectionStr: sections[0], valueForFormat: value}
169+
}
170+
171+
/**
172+
* Number path of the dispatcher: split the format into sign-selected sections,
173+
* pick the section for the value, tokenize it and render.
174+
*
175+
* When the SELECTED section carries no `#`/`0` placeholder it is pure literal
176+
* text (e.g. `"neg"`, an empty section, or a bare `Foo`): render THAT section's
177+
* literal characters — never the whole format string — so `0.00;"neg"` on a
178+
* negative renders `neg` (not `0.00;"neg"`) and an empty section renders `''`.
179+
* A single literal section preserves `format(2, 'Foo')` → `'Foo'`. Never throws.
180+
*
181+
* @param formatArg the color-stripped format string
182+
* @param value the numeric value being formatted
183+
* @param config the live HyperFormula config (separators)
184+
* @returns the formatted string
185+
*/
186+
function formatNumberWithSections(formatArg: string, value: number, config: Config): RawScalarValue {
187+
const sections = splitIntoSections(formatArg)
188+
const {sectionStr, valueForFormat} = pickSection(sections, value)
189+
const expression = parseForNumberFormat(stripQuotes(sectionStr))
190+
if (expression === undefined) {
191+
return renderLiteralSection(sectionStr)
192+
}
193+
return numberFormat(expression.tokens, valueForFormat, config)
194+
}
195+
196+
/**
197+
* Renders a placeholder-less format section as literal text: double-quote
198+
* delimiters are removed (`"z"` → `z`) and backslash escapes are resolved
199+
* (`\-` → `-`). Used when the section selected for a value's sign carries no
200+
* `#`/`0` placeholder, so the value is never spliced in — only the section's
201+
* own literal characters are emitted (an empty section renders `''`). Mirrors
202+
* Excel, where a literal-only section shows just its text.
203+
*
204+
* @param section the raw (unstripped) format section
205+
* @returns the section's literal text
206+
*/
207+
function renderLiteralSection(section: string): string {
208+
let result = ''
209+
for (let i = 0; i < section.length; i++) {
210+
const ch = section[i]
211+
if (ch === '\\' && i + 1 < section.length) {
212+
result += section[i + 1]
213+
i++
214+
} else if (ch !== '"') {
215+
result += ch
216+
}
54217
}
55-
return formatArg
218+
return result
219+
}
220+
221+
/**
222+
* Inserts a grouping separator every three digits from the right of a run of
223+
* digits (e.g. `1234567` → `1,234,567`). The caller guarantees `digits` is a
224+
* pure-digit string and `separator` is non-empty.
225+
*
226+
* @param digits a pure-digit integer string
227+
* @param separator the grouping glyph (from `config.thousandSeparator`)
228+
* @returns the grouped digit string
229+
*/
230+
function insertGrouping(digits: string, separator: string): string {
231+
let result = ''
232+
for (let i = 0; i < digits.length; i++) {
233+
if (i > 0 && (digits.length - i) % 3 === 0) {
234+
result += separator
235+
}
236+
result += digits[i]
237+
}
238+
return result
56239
}
57240

58241
export function padLeft(number: number | string, size: number) {
@@ -75,7 +258,32 @@ function countChars(text: string, char: string) {
75258
return text.split(char).length - 1
76259
}
77260

78-
function numberFormat(tokens: FormatToken[], value: number): RawScalarValue {
261+
/**
262+
* Renders a single sign-selected section's tokens against a value.
263+
*
264+
* The sign is extracted up front: the value is formatted on its magnitude
265+
* (`Math.abs`) and a leading `-` is prepended to the whole result iff the value
266+
* is negative. Callers pass `abs` for an explicit negative section (whose own
267+
* literals carry the sign) and the signed value for a single-section mask (so
268+
* the `-` is re-added here) — see `pickSection`.
269+
*
270+
* Per integer-format token:
271+
* - a *trailing* comma run (Excel's scaler, OUT of HF-287 scope) is peeled off
272+
* and re-emitted as a literal so the output is recognizably un-scaled rather
273+
* than silently mis-scaled;
274+
* - grouping is requested when an *interior* comma exists (`#,##0`), and the
275+
* grouping glyph is `config.thousandSeparator` (empty on default config → no
276+
* visible glyph);
277+
* - the decimal glyph is `config.decimalSeparator` (was a hardcoded `.`).
278+
*
279+
* @param tokens the tokenized number-format section
280+
* @param value the sign-adjusted numeric value to render
281+
* @param config the live config (grouping / decimal separators)
282+
* @returns the rendered section string
283+
*/
284+
function numberFormat(tokens: FormatToken[], value: number, config: Config): RawScalarValue {
285+
const negative = value < 0
286+
const absValue = Math.abs(value)
79287
let result = ''
80288

81289
for (let i = 0; i < tokens.length; ++i) {
@@ -86,27 +294,42 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue {
86294
}
87295

88296
const tokenParts = token.value.split('.')
89-
const integerFormat = tokenParts[0]
297+
const rawIntegerFormat = tokenParts[0]
90298
const decimalFormat = tokenParts[1] || ''
91-
const separator = tokenParts[1] ? '.' : ''
299+
const separator = tokenParts[1] ? config.decimalSeparator : ''
300+
301+
/* peel off a trailing comma run (Excel scaler — kept as a visible literal) */
302+
const trailingScalerMatch = /,+$/.exec(rawIntegerFormat)
303+
const trailingScaler = trailingScalerMatch ? trailingScalerMatch[0] : ''
304+
const coreIntegerFormat = rawIntegerFormat.slice(0, rawIntegerFormat.length - trailingScaler.length)
305+
306+
/* grouping requested iff a comma sits between two digit placeholders */
307+
const grouping = /[#0],[#0]/.test(coreIntegerFormat)
308+
const integerSkeleton = coreIntegerFormat.replace(/,/g, '')
92309

93310
/* get fixed-point number without trailing zeros */
94-
const valueParts = Number(value.toFixed(decimalFormat.length)).toString().split('.')
311+
const valueParts = Number(absValue.toFixed(decimalFormat.length)).toString().split('.')
95312
let integerPart = valueParts[0] || ''
96313
let decimalPart = valueParts[1] || ''
97314

98-
if (integerFormat.length > integerPart.length) {
99-
const padSizeInteger = countChars(integerFormat.substr(0, integerFormat.length - integerPart.length), '0')
315+
if (integerSkeleton.length > integerPart.length) {
316+
const padSizeInteger = countChars(integerSkeleton.substr(0, integerSkeleton.length - integerPart.length), '0')
100317
integerPart = padLeft(integerPart, padSizeInteger + integerPart.length)
101318
}
102319

320+
/* group only after padding, only with a configured glyph, only on pure digits
321+
* (Number#toString emits scientific notation e.g. 1e+21 for huge magnitudes) */
322+
if (grouping && config.thousandSeparator !== '' && /^\d+$/.test(integerPart)) {
323+
integerPart = insertGrouping(integerPart, config.thousandSeparator)
324+
}
325+
103326
const padSizeDecimal = countChars(decimalFormat.substr(decimalPart.length, decimalFormat.length - decimalPart.length), '0')
104327
decimalPart = padRight(decimalPart, padSizeDecimal + decimalPart.length)
105328

106-
result += integerPart + separator + decimalPart
329+
result += integerPart + trailingScaler + separator + decimalPart
107330
}
108331

109-
return result
332+
return negative ? '-' + result : result
110333
}
111334

112335
/**

src/format/parser.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,27 @@
66
import {Maybe} from '../Maybe'
77

88
const dateFormatRegex = /(\\.|dd|DD|d|D|mm|MM|m|M|YYYY|YY|yyyy|yy|HH|hh|H|h|ss(\.(0+|s+))?|s|AM\/PM|am\/pm|A\/P|a\/p|\[mm]|\[MM]|\[hh]|\[HH])/g
9-
const numberFormatRegex = /(\\.|[#0]+(\.[#0]*)?)/g
9+
10+
/**
11+
* Number-format tokenizer regex.
12+
*
13+
* The class is intentionally FLAT — `[#0,]+(\.[#0]*)?` — admitting the grouping
14+
* comma alongside the `#`/`0` placeholders. Whether a comma means "group
15+
* thousands", a "trailing scaler", or neither is decided later by string
16+
* inspection in `format.ts`, never by regex structure.
17+
*
18+
* Synchronous catastrophic backtracking is not catchable by jest/jasmine
19+
* wall-clock timeouts (see DEV-2120), so this pattern MUST NOT be rewritten
20+
* into a nested-quantifier form such as `([#0]+,)*[#0]+`. Its shape is pinned
21+
* by a white-box test via {@link NUMBER_FORMAT_REGEX_SOURCE}.
22+
*/
23+
const numberFormatRegex = /(\\.|[#0,]+(\.[#0]*)?)/g
24+
25+
/**
26+
* The `source` of {@link numberFormatRegex}, exported for the white-box ReDoS
27+
* shape assertion. Not re-exported from `src/index.ts` — test-only internal.
28+
*/
29+
export const NUMBER_FORMAT_REGEX_SOURCE = numberFormatRegex.source
1030

1131
export enum TokenType {
1232
FORMAT = 'FORMAT',
@@ -54,13 +74,22 @@ function matchDateFormat(str: string): RegExpExecArray[] {
5474

5575
function matchNumberFormat(str: string): RegExpExecArray[] {
5676
numberFormatRegex.lastIndex = 0
57-
const numberFormatToken = numberFormatRegex.exec(str)
5877

59-
if (numberFormatToken !== null) {
60-
return [numberFormatToken]
61-
} else {
62-
return []
78+
// A run admitted by the flat class is only a genuine number token if it
79+
// contains at least one `#`/`0` placeholder. A run that is punctuation-only
80+
// (e.g. a lone grouping `,`, now inside the class) or an escape token is NOT
81+
// a number token — Excel treats a placeholder-less segment as a literal — so
82+
// skip it and keep scanning for the first placeholder-bearing run. Without
83+
// this guard a mask such as `,` or `a,b` would splice the value into free
84+
// text (`,` → `5,`); with it, `matchNumberFormat` still returns a single
85+
// token (the first real placeholder run), preserving the tokenizer contract.
86+
let match
87+
while ((match = numberFormatRegex.exec(str)) !== null) {
88+
if (!isEscapeToken(match) && /[#0]/.test(match[0])) {
89+
return [match]
90+
}
6391
}
92+
return []
6493
}
6594

6695
function createTokens(regexTokens: RegExpExecArray[], str: string) {

0 commit comments

Comments
 (0)