From fa890f66e10ea4ad9b7ce07d50c8ab19c3cc4248 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 24 Jul 2026 12:27:55 +0000 Subject: [PATCH 1/4] fix(format): support thousands grouping and format sections in numberFormat/TEXT (HF-287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + .../compatibility-with-microsoft-excel.md | 5 +- src/format/format.ts | 251 ++++++++++++++- src/format/parser.ts | 41 ++- test/hf-287-numberformat.spec.ts | 291 ++++++++++++++++++ 5 files changed, 568 insertions(+), 21 deletions(-) create mode 100644 test/hf-287-numberformat.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af942bd4b1..2f50c40aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- 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) - 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) - 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) diff --git a/docs/guide/compatibility-with-microsoft-excel.md b/docs/guide/compatibility-with-microsoft-excel.md index 9afd28a3a0..caffcfe3ad 100644 --- a/docs/guide/compatibility-with-microsoft-excel.md +++ b/docs/guide/compatibility-with-microsoft-excel.md @@ -158,7 +158,10 @@ Options related to date and time formats: ### `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. +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: + +- 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. +- 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. ## Full configuration diff --git a/src/format/format.ts b/src/format/format.ts index 52e1192547..5dc6006f55 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -40,19 +40,202 @@ export function format(value: number, formatArg: string, config: Config, dateHel if (tryCurrency !== undefined) { return tryCurrency } - const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), formatArg) // default points to defaultStringifyDateTime() + // Strip presentational color tags AFTER the (user-pluggable) currency callback + // — which may inspect the raw formatArg — and BEFORE date/time dispatch. Doing + // it here (not inside the number path) means a colored date like + // `[Red]YYYY-MM-DD` loses only its color tag and still renders as a date, and + // a color tag such as `[Red]` (which contains a `d`) can no longer be + // hijacked by the date/time parser. See stripColorTags. + const cleanedFormatArg = stripColorTags(formatArg) + const tryDateTime = config.stringifyDateTime(dateHelper.numberToSimpleDateTime(value), cleanedFormatArg) // default points to defaultStringifyDateTime() if (tryDateTime !== undefined) { return tryDateTime } - const tryDuration = config.stringifyDuration(numberToSimpleTime(value), formatArg) + const tryDuration = config.stringifyDuration(numberToSimpleTime(value), cleanedFormatArg) if (tryDuration !== undefined) { return tryDuration } - const expression = parseForNumberFormat(formatArg) - if (expression !== undefined) { - return numberFormat(expression.tokens, value) + return formatNumberWithSections(cleanedFormatArg, value, config) +} + +const COLOR_TAG_REGEX = /\[(black|blue|cyan|green|magenta|red|white|yellow|color\s?(?:[1-9]|[1-4]\d|5[0-6]))\]/gi + +/** + * Removes Excel color tags (`[Red]`, `[Blue]`, …, `[Color56]`) from a format + * string. HyperFormula's `TEXT` output is a plain string with no color channel, + * so presentational color tags are semantically meaningless and are simply + * dropped. + * + * The whitelist is deliberately color-NAME-specific (flat alternation, no + * nested quantifier) so it cannot clobber other bracketed tokens: duration + * tags `[hh]`/`[mm]`, currency/locale tags `[$USD-409]`/`[$-409]`, and + * condition tags `[>=100]` are all left untouched. + * + * @param formatArg the raw format string + * @returns the format string with recognized color tags removed + */ +function stripColorTags(formatArg: string): string { + return formatArg.replace(COLOR_TAG_REGEX, '') +} + +/** + * Splits an Excel number-format string into its sign-selected sections on + * unescaped `;`, honoring `\;` escapes and `"…"` quoted literals so a semicolon + * inside a quoted literal does not split. Excel uses up to four sections + * (`positive;negative;zero;text`); only the first three are ever selected for a + * numeric value, so no cap is enforced here — surplus sections are simply never + * read. + * + * @param formatStr the (color-stripped) format string + * @returns the list of raw section strings, in order + */ +function splitIntoSections(formatStr: string): string[] { + const sections: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < formatStr.length; i++) { + const ch = formatStr[i] + + if (ch === '\\') { + // Keep the backslash and the escaped character together, verbatim. + current += ch + if (i + 1 < formatStr.length) { + current += formatStr[i + 1] + i++ + } + continue + } + if (ch === '"') { + inQuotes = !inQuotes + current += ch + continue + } + if (ch === ';' && !inQuotes) { + sections.push(current) + current = '' + continue + } + current += ch + } + sections.push(current) + + return sections +} + +/** + * Strips the delimiting double-quotes from quoted literals so `"zł"` renders as + * `zł`. NUANCE (documented, out of HF-287 scope): quotes are removed globally, + * so digit/placeholder characters *inside* quotes are NOT protected from the + * number tokenizer — a rare Excel case that this incremental formatter does not + * cover. + * + * @param section a single format section + * @returns the section with double-quote delimiters removed + */ +function stripQuotes(section: string): string { + return section.replace(/"/g, '') +} + +/** + * Selects the format section for a value by its RAW sign (before rounding) and + * returns the value to feed the formatter: + * + * - `> 0` → positive section (section 0), formatted signed (non-negative). + * - `< 0` → negative section (section 1) when present, formatted on `abs` (the + * section's own literals, e.g. `(0.00)`, carry the sign); when only one + * section exists the signed value is passed so the formatter re-adds `-`. + * - `= 0` → zero section (section 2) when present, else the positive section. + * + * Excel-canonical fallbacks: 1 section → all values; 2 sections → `[pos+zero ; + * neg]`; 3 sections → `[pos ; neg ; zero]`; a missing zero section falls back to + * positive. + * + * @param sections the split format sections + * @param value the numeric value being formatted + * @returns the chosen section string and the (sign-adjusted) value to format + */ +function pickSection(sections: string[], value: number): { sectionStr: string, valueForFormat: number } { + if (value < 0 && sections.length >= 2) { + // Explicit negative section: its literals carry the sign, so format abs. + return {sectionStr: sections[1], valueForFormat: Math.abs(value)} + } + if (value === 0 && sections.length >= 3) { + return {sectionStr: sections[2], valueForFormat: 0} + } + // Positive, zero-without-a-zero-section, or single-section negative (the + // signed value flows through so numberFormat re-adds the leading `-`). + return {sectionStr: sections[0], valueForFormat: value} +} + +/** + * Number path of the dispatcher: split the format into sign-selected sections, + * pick the section for the value, tokenize it and render. + * + * When the SELECTED section carries no `#`/`0` placeholder it is pure literal + * text (e.g. `"neg"`, an empty section, or a bare `Foo`): render THAT section's + * literal characters — never the whole format string — so `0.00;"neg"` on a + * negative renders `neg` (not `0.00;"neg"`) and an empty section renders `''`. + * A single literal section preserves `format(2, 'Foo')` → `'Foo'`. Never throws. + * + * @param formatArg the color-stripped format string + * @param value the numeric value being formatted + * @param config the live HyperFormula config (separators) + * @returns the formatted string + */ +function formatNumberWithSections(formatArg: string, value: number, config: Config): RawScalarValue { + const sections = splitIntoSections(formatArg) + const {sectionStr, valueForFormat} = pickSection(sections, value) + const expression = parseForNumberFormat(stripQuotes(sectionStr)) + if (expression === undefined) { + return renderLiteralSection(sectionStr) + } + return numberFormat(expression.tokens, valueForFormat, config) +} + +/** + * Renders a placeholder-less format section as literal text: double-quote + * delimiters are removed (`"z"` → `z`) and backslash escapes are resolved + * (`\-` → `-`). Used when the section selected for a value's sign carries no + * `#`/`0` placeholder, so the value is never spliced in — only the section's + * own literal characters are emitted (an empty section renders `''`). Mirrors + * Excel, where a literal-only section shows just its text. + * + * @param section the raw (unstripped) format section + * @returns the section's literal text + */ +function renderLiteralSection(section: string): string { + let result = '' + for (let i = 0; i < section.length; i++) { + const ch = section[i] + if (ch === '\\' && i + 1 < section.length) { + result += section[i + 1] + i++ + } else if (ch !== '"') { + result += ch + } } - return formatArg + return result +} + +/** + * Inserts a grouping separator every three digits from the right of a run of + * digits (e.g. `1234567` → `1,234,567`). The caller guarantees `digits` is a + * pure-digit string and `separator` is non-empty. + * + * @param digits a pure-digit integer string + * @param separator the grouping glyph (from `config.thousandSeparator`) + * @returns the grouped digit string + */ +function insertGrouping(digits: string, separator: string): string { + let result = '' + for (let i = 0; i < digits.length; i++) { + if (i > 0 && (digits.length - i) % 3 === 0) { + result += separator + } + result += digits[i] + } + return result } export function padLeft(number: number | string, size: number) { @@ -75,7 +258,32 @@ function countChars(text: string, char: string) { return text.split(char).length - 1 } -function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { +/** + * Renders a single sign-selected section's tokens against a value. + * + * The sign is extracted up front: the value is formatted on its magnitude + * (`Math.abs`) and a leading `-` is prepended to the whole result iff the value + * is negative. Callers pass `abs` for an explicit negative section (whose own + * literals carry the sign) and the signed value for a single-section mask (so + * the `-` is re-added here) — see `pickSection`. + * + * Per integer-format token: + * - a *trailing* comma run (Excel's scaler, OUT of HF-287 scope) is peeled off + * and re-emitted as a literal so the output is recognizably un-scaled rather + * than silently mis-scaled; + * - grouping is requested when an *interior* comma exists (`#,##0`), and the + * grouping glyph is `config.thousandSeparator` (empty on default config → no + * visible glyph); + * - the decimal glyph is `config.decimalSeparator` (was a hardcoded `.`). + * + * @param tokens the tokenized number-format section + * @param value the sign-adjusted numeric value to render + * @param config the live config (grouping / decimal separators) + * @returns the rendered section string + */ +function numberFormat(tokens: FormatToken[], value: number, config: Config): RawScalarValue { + const negative = value < 0 + const absValue = Math.abs(value) let result = '' for (let i = 0; i < tokens.length; ++i) { @@ -86,27 +294,42 @@ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { } const tokenParts = token.value.split('.') - const integerFormat = tokenParts[0] + const rawIntegerFormat = tokenParts[0] const decimalFormat = tokenParts[1] || '' - const separator = tokenParts[1] ? '.' : '' + const separator = tokenParts[1] ? config.decimalSeparator : '' + + /* peel off a trailing comma run (Excel scaler — kept as a visible literal) */ + const trailingScalerMatch = /,+$/.exec(rawIntegerFormat) + const trailingScaler = trailingScalerMatch ? trailingScalerMatch[0] : '' + const coreIntegerFormat = rawIntegerFormat.slice(0, rawIntegerFormat.length - trailingScaler.length) + + /* grouping requested iff a comma sits between two digit placeholders */ + const grouping = /[#0],[#0]/.test(coreIntegerFormat) + const integerSkeleton = coreIntegerFormat.replace(/,/g, '') /* get fixed-point number without trailing zeros */ - const valueParts = Number(value.toFixed(decimalFormat.length)).toString().split('.') + const valueParts = Number(absValue.toFixed(decimalFormat.length)).toString().split('.') let integerPart = valueParts[0] || '' let decimalPart = valueParts[1] || '' - if (integerFormat.length > integerPart.length) { - const padSizeInteger = countChars(integerFormat.substr(0, integerFormat.length - integerPart.length), '0') + if (integerSkeleton.length > integerPart.length) { + const padSizeInteger = countChars(integerSkeleton.substr(0, integerSkeleton.length - integerPart.length), '0') integerPart = padLeft(integerPart, padSizeInteger + integerPart.length) } + /* group only after padding, only with a configured glyph, only on pure digits + * (Number#toString emits scientific notation e.g. 1e+21 for huge magnitudes) */ + if (grouping && config.thousandSeparator !== '' && /^\d+$/.test(integerPart)) { + integerPart = insertGrouping(integerPart, config.thousandSeparator) + } + const padSizeDecimal = countChars(decimalFormat.substr(decimalPart.length, decimalFormat.length - decimalPart.length), '0') decimalPart = padRight(decimalPart, padSizeDecimal + decimalPart.length) - result += integerPart + separator + decimalPart + result += integerPart + trailingScaler + separator + decimalPart } - return result + return negative ? '-' + result : result } /** diff --git a/src/format/parser.ts b/src/format/parser.ts index 3583b205c6..cb4680a4eb 100644 --- a/src/format/parser.ts +++ b/src/format/parser.ts @@ -6,7 +6,27 @@ import {Maybe} from '../Maybe' 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 -const numberFormatRegex = /(\\.|[#0]+(\.[#0]*)?)/g + +/** + * Number-format tokenizer regex. + * + * The class is intentionally FLAT — `[#0,]+(\.[#0]*)?` — admitting the grouping + * comma alongside the `#`/`0` placeholders. Whether a comma means "group + * thousands", a "trailing scaler", or neither is decided later by string + * inspection in `format.ts`, never by regex structure. + * + * Synchronous catastrophic backtracking is not catchable by jest/jasmine + * wall-clock timeouts (see DEV-2120), so this pattern MUST NOT be rewritten + * into a nested-quantifier form such as `([#0]+,)*[#0]+`. Its shape is pinned + * by a white-box test via {@link NUMBER_FORMAT_REGEX_SOURCE}. + */ +const numberFormatRegex = /(\\.|[#0,]+(\.[#0]*)?)/g + +/** + * The `source` of {@link numberFormatRegex}, exported for the white-box ReDoS + * shape assertion. Not re-exported from `src/index.ts` — test-only internal. + */ +export const NUMBER_FORMAT_REGEX_SOURCE = numberFormatRegex.source export enum TokenType { FORMAT = 'FORMAT', @@ -54,13 +74,22 @@ function matchDateFormat(str: string): RegExpExecArray[] { function matchNumberFormat(str: string): RegExpExecArray[] { numberFormatRegex.lastIndex = 0 - const numberFormatToken = numberFormatRegex.exec(str) - if (numberFormatToken !== null) { - return [numberFormatToken] - } else { - return [] + // A run admitted by the flat class is only a genuine number token if it + // contains at least one `#`/`0` placeholder. A run that is punctuation-only + // (e.g. a lone grouping `,`, now inside the class) or an escape token is NOT + // a number token — Excel treats a placeholder-less segment as a literal — so + // skip it and keep scanning for the first placeholder-bearing run. Without + // this guard a mask such as `,` or `a,b` would splice the value into free + // text (`,` → `5,`); with it, `matchNumberFormat` still returns a single + // token (the first real placeholder run), preserving the tokenizer contract. + let match + while ((match = numberFormatRegex.exec(str)) !== null) { + if (!isEscapeToken(match) && /[#0]/.test(match[0])) { + return [match] + } } + return [] } function createTokens(regexTokens: RegExpExecArray[], str: string) { diff --git a/test/hf-287-numberformat.spec.ts b/test/hf-287-numberformat.spec.ts new file mode 100644 index 0000000000..da940f6dea --- /dev/null +++ b/test/hf-287-numberformat.spec.ts @@ -0,0 +1,291 @@ +/** + * HF-287 — TEXT/numberFormat: thousands grouping, format sections, config + * separators, and color-tag stripping. + * + * Dual-env safe (runs under both Jest and Karma/Jasmine): plain `it()` blocks, + * `expect().toEqual`/`toBe`, no `it.each`, no `toHaveLength`, no `fs`. + * + * Excel oracle values below come from the customer's verified repro table + * (see HF-287 PLAN/ADR). On HyperFormula's DEFAULT config the grouping glyph is + * the empty string, so grouping is only *observable* when the instance is + * configured with a `thousandSeparator` — mirroring HF's config-authoritative + * model. Config fixtures that set a comma decimal/thousand separator must also + * override `functionArgSeparator` (defaults to `,`) or the mutual-distinctness + * conflict check throws at construction. + */ +import {Config} from '../src/Config' +import {DateTimeHelper} from '../src/DateTimeHelper' +import {format} from '../src/format/format' +import {NUMBER_FORMAT_REGEX_SOURCE} from '../src/format/parser' +import {HyperFormula} from '../src' + +const cfgDefault = new Config() +// thousandSeparator ',' collides with the default functionArgSeparator ',', +// so functionArgSeparator must move to ';' (Config conflict check). +const cfgComma = new Config({thousandSeparator: ',', functionArgSeparator: ';'}) +// space grouping, dot decimal. +const cfgSpace = new Config({thousandSeparator: ' ', functionArgSeparator: ';'}) +// space grouping, comma decimal — comma decimal also collides with the default +// comma functionArgSeparator, hence ';'. +const cfgSpaceDecComma = new Config({thousandSeparator: ' ', decimalSeparator: ',', functionArgSeparator: ';'}) + +const helperDefault = new DateTimeHelper(cfgDefault) +const helperComma = new DateTimeHelper(cfgComma) +const helperSpace = new DateTimeHelper(cfgSpace) +const helperSpaceDecComma = new DateTimeHelper(cfgSpaceDecComma) + +describe('HF-287 numberFormat — Excel oracle (verified repro table)', () => { + it('#,##0.00 with 1234.5 -> 1,234.50 (thousandSeparator ",")', () => { + expect(format(1234.5, '#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') + }) + + it('#,##0.00;-#,##0.00 with -1234.5 -> -1,234.50 (thousandSeparator ",")', () => { + expect(format(-1234.5, '#,##0.00;-#,##0.00', cfgComma, helperComma)).toEqual('-1,234.50') + }) + + it('#,##0.00;-#,##0.00 with 1234.5 -> 1,234.50 (positive section)', () => { + expect(format(1234.5, '#,##0.00;-#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') + }) + + it('#,##0.00 "zł" with 1234.5 -> 1 234.50 zł (thousandSeparator " ", quoted literal stripped)', () => { + expect(format(1234.5, '#,##0.00 "zł"', cfgSpace, helperSpace)).toEqual('1 234.50 zł') + }) + + it('$#,##0.00;-$#,##0.00 with -1234.5 -> -$1,234.50', () => { + expect(format(-1234.5, '$#,##0.00;-$#,##0.00', cfgComma, helperComma)).toEqual('-$1,234.50') + }) + + it('000.00 with -5 -> -005.00 (abs padded against placeholders, sign prepended)', () => { + expect(format(-5, '000.00', cfgDefault, helperDefault)).toEqual('-005.00') + }) +}) + +describe('HF-287 numberFormat — thousands grouping', () => { + it('default config emits NO grouping glyph (config-authoritative nuance)', () => { + expect(format(1234.5, '#,##0.00', cfgDefault, helperDefault)).toEqual('1234.50') + expect(format(1234567, '#,##0', cfgDefault, helperDefault)).toEqual('1234567') + }) + + it('groups with the configured comma separator', () => { + expect(format(1234567, '#,##0', cfgComma, helperComma)).toEqual('1,234,567') + expect(format(1234.5, '#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') + }) + + it('groups with a configured space separator and comma decimal', () => { + expect(format(1234.5, '#,##0.00', cfgSpaceDecComma, helperSpaceDecComma)).toEqual('1 234,50') + }) + + it('pads to placeholder width first, then groups (0,000 with 5)', () => { + expect(format(5, '0,000', cfgComma, helperComma)).toEqual('0,005') + expect(format(5, '0,000', cfgDefault, helperDefault)).toEqual('0005') + }) + + it('grouping skips the extracted sign (negative)', () => { + expect(format(-12345, '#,##0', cfgComma, helperComma)).toEqual('-12,345') + }) + + it('trailing scaler commas degrade visibly as a literal (never silent mis-scale)', () => { + // 0,, is Excel "divide by thousands" — OUT of scope; we keep the commas as a + // literal so the output is recognizably un-scaled rather than plausibly-wrong. + expect(format(5000000, '0,,', cfgDefault, helperDefault)).toEqual('5000000,,') + }) + + it('interior grouping + trailing scaler keeps grouping and the trailing literal', () => { + expect(format(1234567, '#,##0,', cfgComma, helperComma)).toEqual('1,234,567,') + }) + + it('does not throw / stays a string for scientific-notation magnitudes', () => { + // A throw would fail the test outright, so a direct call asserts "no throw". + const out = format(1e21, '#,##0', cfgComma, helperComma) + expect(typeof out).toBe('string') + }) +}) + +describe('HF-287 numberFormat — configured decimal separator', () => { + it('uses config.decimalSeparator for the decimal point', () => { + expect(format(12.34, '0.00', cfgSpaceDecComma, helperSpaceDecComma)).toEqual('12,34') + }) + + it('keeps a dot on default config', () => { + expect(format(12.34, '0.00', cfgDefault, helperDefault)).toEqual('12.34') + }) +}) + +describe('HF-287 numberFormat — format sections (positive;negative;zero)', () => { + it('2 sections: negative uses the negative section on abs(value)', () => { + expect(format(-5, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('(5.00)') + }) + + it('2 sections: positive uses the positive section', () => { + expect(format(5, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('5.00') + }) + + it('2 sections: zero uses the positive section', () => { + expect(format(0, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('0.00') + }) + + it('3 sections: zero uses the dedicated zero section', () => { + expect(format(0, '0.00;(0.00);0.0', cfgDefault, helperDefault)).toEqual('0.0') + }) + + it('3 sections: negative uses the negative section', () => { + expect(format(-5, '0.00;(0.00);0.0', cfgDefault, helperDefault)).toEqual('(5.00)') + }) + + it('1 section: negative re-adds the leading minus (fixes padLeft-minus bug)', () => { + // Historically padLeft('-5',3) produced '0-5' -> '0-5.00'. Sign extraction on + // abs makes it '-005.00'. Mirrors A3 TEXT(12.45,'000.000') -> '012.450'. + expect(format(-5, '000.00', cfgDefault, helperDefault)).toEqual('-005.00') + }) + + it('1 section: negative with grouping mask -> -5 (Excel #,##0)', () => { + expect(format(-5, '#,##0', cfgDefault, helperDefault)).toEqual('-5') + expect(format(-5, '#,##0', cfgComma, helperComma)).toEqual('-5') + }) +}) + +describe('HF-287 numberFormat — color-tag stripping (pre-dispatch, name-specific)', () => { + it('strips [Red] from a numeric mask', () => { + expect(format(1234.5, '[Red]#,##0.00', cfgDefault, helperDefault)).toEqual('1234.50') + expect(format(1234.5, '[Red]#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') + }) + + it('a color-stripped date mask still renders as a date', () => { + expect(format(2, '[Red]dd-mm-yyyy', cfgDefault, helperDefault)).toEqual('01-01-1900') + }) + + it('does not touch duration tags [hh]/[mm]', () => { + // 0.1 day == 2h24m0s + expect(format(0.1, '[hh]:mm:ss', cfgDefault, helperDefault)).toEqual('02:24:00') + }) +}) + +describe('HF-287 numberFormat — degradation & fallback (never throw)', () => { + it('parse-failure returns the cleaned formatArg (preserves format(2,"Foo") -> "Foo")', () => { + expect(format(2, 'Foo', cfgDefault, helperDefault)).toEqual('Foo') + }) + + it('malformed masks degrade to a string without throwing', () => { + // A throw would fail the test outright; each direct call asserts "no throw". + expect(typeof format(1, '[Red', cfgDefault, helperDefault)).toBe('string') + expect(typeof format(1, '0.00;', cfgDefault, helperDefault)).toBe('string') + expect(typeof format(1, ';', cfgDefault, helperDefault)).toBe('string') + }) + + it('negative that rounds to zero selects the negative section (raw-sign rule); no throw', () => { + // Section is selected by the value's RAW sign, before rounding. We do not + // freeze an Excel oracle value here (Gate B never got a verified repro), + // only that the raw-sign path is safe and produces a string. + const out = format(-0.001, '0.00;(0.00)', cfgDefault, helperDefault) + expect(typeof out).toBe('string') + }) +}) + +describe('HF-287 numberFormat — existing behavior preserved (regression)', () => { + it('simple masks unchanged on default config', () => { + expect(format(1, '###', cfgDefault, helperDefault)).toEqual('1') + expect(format(12.345, '#.##', cfgDefault, helperDefault)).toEqual('12.35') + expect(format(1, '000', cfgDefault, helperDefault)).toEqual('001') + expect(format(1, '00.00', cfgDefault, helperDefault)).toEqual('01.00') + expect(format(1, '$0.00', cfgDefault, helperDefault)).toEqual('$1.00') + }) +}) + +describe('HF-287 numberFormat — engine level (TEXT)', () => { + it('flips the previously-garbage A9 mask $###,##0.00 -> $12.45 (default config)', () => { + const engine = HyperFormula.buildFromArray([['12.45', '=TEXT(A1, "$###,##0.00")']]) + expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('$12.45') + engine.destroy() + }) + + it('groups with a configured thousandSeparator', () => { + const engine = HyperFormula.buildFromArray( + [['1234.5', '=TEXT(A1; "#,##0.00")']], + {thousandSeparator: ',', functionArgSeparator: ';'} + ) + expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('1,234.50') + engine.destroy() + }) + + it('applies the negative section', () => { + const engine = HyperFormula.buildFromArray([['-5', '=TEXT(A1, "0.00;(0.00)")']]) + expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('(5.00)') + engine.destroy() + }) + + it('strips a color tag', () => { + const engine = HyperFormula.buildFromArray([['1234.5', '=TEXT(A1, "[Red]#,##0.00")']]) + expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('1234.50') + engine.destroy() + }) +}) + +describe('HF-287 numberFormat — placeholder-less masks/sections render as literals', () => { + // FINDING 1 (Bugbot): a mask segment with no `#`/`0` placeholder is a + // literal — the value must NOT be spliced into it. + it('a comma-only mask is a literal, not a number token', () => { + expect(format(5, ',', cfgDefault, helperDefault)).toEqual(',') + }) + + it('a mask with a comma but no placeholder is a literal', () => { + expect(format(1234.5, 'a,b', cfgDefault, helperDefault)).toEqual('a,b') + }) + + it('a plain-text mask keeps working (no regression)', () => { + expect(format(5, 'abc', cfgDefault, helperDefault)).toEqual('abc') + }) + + // FINDING 2 (Bugbot): a SELECTED section with no placeholder renders that + // section's literal text (quotes stripped), never the whole format string. + it('a literal negative section renders just the section text', () => { + expect(format(-5, '0.00;"neg"', cfgDefault, helperDefault)).toEqual('neg') + }) + + it('an empty negative section renders an empty string', () => { + expect(format(-5, '0.00;', cfgDefault, helperDefault)).toEqual('') + }) + + it('an empty zero section renders an empty string', () => { + expect(format(0, '0.00;-0.00;', cfgDefault, helperDefault)).toEqual('') + }) + + it('still formats the positive section for those masks', () => { + expect(format(5, '0.00;"neg"', cfgDefault, helperDefault)).toEqual('5.00') + expect(format(5, '0.00;-0.00;', cfgDefault, helperDefault)).toEqual('5.00') + }) +}) + +describe('HF-287 numberFormat — backslash escape in the section splitter', () => { + // A backslash escapes the next character so it is NOT treated as a section + // separator; the escaped pair is carried verbatim through the splitter. Full + // Excel backslash-unescape at render time is out of scope (the backslash is + // emitted literally) — these pin the deterministic behavior and the no-throw + // trailing-backslash case, and exercise the escape branch of splitIntoSections. + it('keeps an escaped character verbatim without throwing', () => { + expect(format(1234.5, '#,##0\\x', cfgDefault, helperDefault)).toEqual('1235\\x') + }) + + it('tolerates a trailing backslash with no following character', () => { + expect(format(5, '0\\', cfgDefault, helperDefault)).toEqual('5\\') + }) + + it('resolves a backslash escape inside a rendered literal section', () => { + // Negative section is the placeholder-less literal "\x"; renderLiteralSection + // resolves the escape, so -5 renders "x". + expect(format(-5, '0.00;\\x', cfgDefault, helperDefault)).toEqual('x') + }) +}) + +describe('HF-287 numberFormat — ReDoS discipline (white-box regex shape)', () => { + // Synchronous ReDoS is not catchable by jest/jasmine timeouts (see DEV-2120), + // so we assert the regex SHAPE stays a flat character class with no nested + // quantifier such as ([#0]+,)*[#0]+. + it('number format regex is the flat, non-backtracking form', () => { + expect(NUMBER_FORMAT_REGEX_SOURCE).toEqual('(\\\\.|[#0,]+(\\.[#0]*)?)') + }) + + it('number format regex contains no nested quantifier', () => { + // no ")" immediately followed by "*" or "+" (a group being quantified) + expect(/\)[*+]/.test(NUMBER_FORMAT_REGEX_SOURCE)).toBe(false) + }) +}) From c3cfb8f89d3191bf5b6f586024b4b1f7930f185e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 28 Jul 2026 14:10:28 +0000 Subject: [PATCH 2/4] test: move the HF-287 number-format spec to the private test suite The public test/ directory holds smoke tests only; internal test suites live in the hyperformula-tests repository. The spec moves there unchanged in coverage, split into single-assertion cases: handsontable/hyperformula-tests#27. Co-Authored-By: Claude Opus 5 (1M context) --- test/hf-287-numberformat.spec.ts | 291 ------------------------------- 1 file changed, 291 deletions(-) delete mode 100644 test/hf-287-numberformat.spec.ts diff --git a/test/hf-287-numberformat.spec.ts b/test/hf-287-numberformat.spec.ts deleted file mode 100644 index da940f6dea..0000000000 --- a/test/hf-287-numberformat.spec.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * HF-287 — TEXT/numberFormat: thousands grouping, format sections, config - * separators, and color-tag stripping. - * - * Dual-env safe (runs under both Jest and Karma/Jasmine): plain `it()` blocks, - * `expect().toEqual`/`toBe`, no `it.each`, no `toHaveLength`, no `fs`. - * - * Excel oracle values below come from the customer's verified repro table - * (see HF-287 PLAN/ADR). On HyperFormula's DEFAULT config the grouping glyph is - * the empty string, so grouping is only *observable* when the instance is - * configured with a `thousandSeparator` — mirroring HF's config-authoritative - * model. Config fixtures that set a comma decimal/thousand separator must also - * override `functionArgSeparator` (defaults to `,`) or the mutual-distinctness - * conflict check throws at construction. - */ -import {Config} from '../src/Config' -import {DateTimeHelper} from '../src/DateTimeHelper' -import {format} from '../src/format/format' -import {NUMBER_FORMAT_REGEX_SOURCE} from '../src/format/parser' -import {HyperFormula} from '../src' - -const cfgDefault = new Config() -// thousandSeparator ',' collides with the default functionArgSeparator ',', -// so functionArgSeparator must move to ';' (Config conflict check). -const cfgComma = new Config({thousandSeparator: ',', functionArgSeparator: ';'}) -// space grouping, dot decimal. -const cfgSpace = new Config({thousandSeparator: ' ', functionArgSeparator: ';'}) -// space grouping, comma decimal — comma decimal also collides with the default -// comma functionArgSeparator, hence ';'. -const cfgSpaceDecComma = new Config({thousandSeparator: ' ', decimalSeparator: ',', functionArgSeparator: ';'}) - -const helperDefault = new DateTimeHelper(cfgDefault) -const helperComma = new DateTimeHelper(cfgComma) -const helperSpace = new DateTimeHelper(cfgSpace) -const helperSpaceDecComma = new DateTimeHelper(cfgSpaceDecComma) - -describe('HF-287 numberFormat — Excel oracle (verified repro table)', () => { - it('#,##0.00 with 1234.5 -> 1,234.50 (thousandSeparator ",")', () => { - expect(format(1234.5, '#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') - }) - - it('#,##0.00;-#,##0.00 with -1234.5 -> -1,234.50 (thousandSeparator ",")', () => { - expect(format(-1234.5, '#,##0.00;-#,##0.00', cfgComma, helperComma)).toEqual('-1,234.50') - }) - - it('#,##0.00;-#,##0.00 with 1234.5 -> 1,234.50 (positive section)', () => { - expect(format(1234.5, '#,##0.00;-#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') - }) - - it('#,##0.00 "zł" with 1234.5 -> 1 234.50 zł (thousandSeparator " ", quoted literal stripped)', () => { - expect(format(1234.5, '#,##0.00 "zł"', cfgSpace, helperSpace)).toEqual('1 234.50 zł') - }) - - it('$#,##0.00;-$#,##0.00 with -1234.5 -> -$1,234.50', () => { - expect(format(-1234.5, '$#,##0.00;-$#,##0.00', cfgComma, helperComma)).toEqual('-$1,234.50') - }) - - it('000.00 with -5 -> -005.00 (abs padded against placeholders, sign prepended)', () => { - expect(format(-5, '000.00', cfgDefault, helperDefault)).toEqual('-005.00') - }) -}) - -describe('HF-287 numberFormat — thousands grouping', () => { - it('default config emits NO grouping glyph (config-authoritative nuance)', () => { - expect(format(1234.5, '#,##0.00', cfgDefault, helperDefault)).toEqual('1234.50') - expect(format(1234567, '#,##0', cfgDefault, helperDefault)).toEqual('1234567') - }) - - it('groups with the configured comma separator', () => { - expect(format(1234567, '#,##0', cfgComma, helperComma)).toEqual('1,234,567') - expect(format(1234.5, '#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') - }) - - it('groups with a configured space separator and comma decimal', () => { - expect(format(1234.5, '#,##0.00', cfgSpaceDecComma, helperSpaceDecComma)).toEqual('1 234,50') - }) - - it('pads to placeholder width first, then groups (0,000 with 5)', () => { - expect(format(5, '0,000', cfgComma, helperComma)).toEqual('0,005') - expect(format(5, '0,000', cfgDefault, helperDefault)).toEqual('0005') - }) - - it('grouping skips the extracted sign (negative)', () => { - expect(format(-12345, '#,##0', cfgComma, helperComma)).toEqual('-12,345') - }) - - it('trailing scaler commas degrade visibly as a literal (never silent mis-scale)', () => { - // 0,, is Excel "divide by thousands" — OUT of scope; we keep the commas as a - // literal so the output is recognizably un-scaled rather than plausibly-wrong. - expect(format(5000000, '0,,', cfgDefault, helperDefault)).toEqual('5000000,,') - }) - - it('interior grouping + trailing scaler keeps grouping and the trailing literal', () => { - expect(format(1234567, '#,##0,', cfgComma, helperComma)).toEqual('1,234,567,') - }) - - it('does not throw / stays a string for scientific-notation magnitudes', () => { - // A throw would fail the test outright, so a direct call asserts "no throw". - const out = format(1e21, '#,##0', cfgComma, helperComma) - expect(typeof out).toBe('string') - }) -}) - -describe('HF-287 numberFormat — configured decimal separator', () => { - it('uses config.decimalSeparator for the decimal point', () => { - expect(format(12.34, '0.00', cfgSpaceDecComma, helperSpaceDecComma)).toEqual('12,34') - }) - - it('keeps a dot on default config', () => { - expect(format(12.34, '0.00', cfgDefault, helperDefault)).toEqual('12.34') - }) -}) - -describe('HF-287 numberFormat — format sections (positive;negative;zero)', () => { - it('2 sections: negative uses the negative section on abs(value)', () => { - expect(format(-5, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('(5.00)') - }) - - it('2 sections: positive uses the positive section', () => { - expect(format(5, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('5.00') - }) - - it('2 sections: zero uses the positive section', () => { - expect(format(0, '0.00;(0.00)', cfgDefault, helperDefault)).toEqual('0.00') - }) - - it('3 sections: zero uses the dedicated zero section', () => { - expect(format(0, '0.00;(0.00);0.0', cfgDefault, helperDefault)).toEqual('0.0') - }) - - it('3 sections: negative uses the negative section', () => { - expect(format(-5, '0.00;(0.00);0.0', cfgDefault, helperDefault)).toEqual('(5.00)') - }) - - it('1 section: negative re-adds the leading minus (fixes padLeft-minus bug)', () => { - // Historically padLeft('-5',3) produced '0-5' -> '0-5.00'. Sign extraction on - // abs makes it '-005.00'. Mirrors A3 TEXT(12.45,'000.000') -> '012.450'. - expect(format(-5, '000.00', cfgDefault, helperDefault)).toEqual('-005.00') - }) - - it('1 section: negative with grouping mask -> -5 (Excel #,##0)', () => { - expect(format(-5, '#,##0', cfgDefault, helperDefault)).toEqual('-5') - expect(format(-5, '#,##0', cfgComma, helperComma)).toEqual('-5') - }) -}) - -describe('HF-287 numberFormat — color-tag stripping (pre-dispatch, name-specific)', () => { - it('strips [Red] from a numeric mask', () => { - expect(format(1234.5, '[Red]#,##0.00', cfgDefault, helperDefault)).toEqual('1234.50') - expect(format(1234.5, '[Red]#,##0.00', cfgComma, helperComma)).toEqual('1,234.50') - }) - - it('a color-stripped date mask still renders as a date', () => { - expect(format(2, '[Red]dd-mm-yyyy', cfgDefault, helperDefault)).toEqual('01-01-1900') - }) - - it('does not touch duration tags [hh]/[mm]', () => { - // 0.1 day == 2h24m0s - expect(format(0.1, '[hh]:mm:ss', cfgDefault, helperDefault)).toEqual('02:24:00') - }) -}) - -describe('HF-287 numberFormat — degradation & fallback (never throw)', () => { - it('parse-failure returns the cleaned formatArg (preserves format(2,"Foo") -> "Foo")', () => { - expect(format(2, 'Foo', cfgDefault, helperDefault)).toEqual('Foo') - }) - - it('malformed masks degrade to a string without throwing', () => { - // A throw would fail the test outright; each direct call asserts "no throw". - expect(typeof format(1, '[Red', cfgDefault, helperDefault)).toBe('string') - expect(typeof format(1, '0.00;', cfgDefault, helperDefault)).toBe('string') - expect(typeof format(1, ';', cfgDefault, helperDefault)).toBe('string') - }) - - it('negative that rounds to zero selects the negative section (raw-sign rule); no throw', () => { - // Section is selected by the value's RAW sign, before rounding. We do not - // freeze an Excel oracle value here (Gate B never got a verified repro), - // only that the raw-sign path is safe and produces a string. - const out = format(-0.001, '0.00;(0.00)', cfgDefault, helperDefault) - expect(typeof out).toBe('string') - }) -}) - -describe('HF-287 numberFormat — existing behavior preserved (regression)', () => { - it('simple masks unchanged on default config', () => { - expect(format(1, '###', cfgDefault, helperDefault)).toEqual('1') - expect(format(12.345, '#.##', cfgDefault, helperDefault)).toEqual('12.35') - expect(format(1, '000', cfgDefault, helperDefault)).toEqual('001') - expect(format(1, '00.00', cfgDefault, helperDefault)).toEqual('01.00') - expect(format(1, '$0.00', cfgDefault, helperDefault)).toEqual('$1.00') - }) -}) - -describe('HF-287 numberFormat — engine level (TEXT)', () => { - it('flips the previously-garbage A9 mask $###,##0.00 -> $12.45 (default config)', () => { - const engine = HyperFormula.buildFromArray([['12.45', '=TEXT(A1, "$###,##0.00")']]) - expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('$12.45') - engine.destroy() - }) - - it('groups with a configured thousandSeparator', () => { - const engine = HyperFormula.buildFromArray( - [['1234.5', '=TEXT(A1; "#,##0.00")']], - {thousandSeparator: ',', functionArgSeparator: ';'} - ) - expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('1,234.50') - engine.destroy() - }) - - it('applies the negative section', () => { - const engine = HyperFormula.buildFromArray([['-5', '=TEXT(A1, "0.00;(0.00)")']]) - expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('(5.00)') - engine.destroy() - }) - - it('strips a color tag', () => { - const engine = HyperFormula.buildFromArray([['1234.5', '=TEXT(A1, "[Red]#,##0.00")']]) - expect(engine.getCellValue({sheet: 0, col: 1, row: 0})).toEqual('1234.50') - engine.destroy() - }) -}) - -describe('HF-287 numberFormat — placeholder-less masks/sections render as literals', () => { - // FINDING 1 (Bugbot): a mask segment with no `#`/`0` placeholder is a - // literal — the value must NOT be spliced into it. - it('a comma-only mask is a literal, not a number token', () => { - expect(format(5, ',', cfgDefault, helperDefault)).toEqual(',') - }) - - it('a mask with a comma but no placeholder is a literal', () => { - expect(format(1234.5, 'a,b', cfgDefault, helperDefault)).toEqual('a,b') - }) - - it('a plain-text mask keeps working (no regression)', () => { - expect(format(5, 'abc', cfgDefault, helperDefault)).toEqual('abc') - }) - - // FINDING 2 (Bugbot): a SELECTED section with no placeholder renders that - // section's literal text (quotes stripped), never the whole format string. - it('a literal negative section renders just the section text', () => { - expect(format(-5, '0.00;"neg"', cfgDefault, helperDefault)).toEqual('neg') - }) - - it('an empty negative section renders an empty string', () => { - expect(format(-5, '0.00;', cfgDefault, helperDefault)).toEqual('') - }) - - it('an empty zero section renders an empty string', () => { - expect(format(0, '0.00;-0.00;', cfgDefault, helperDefault)).toEqual('') - }) - - it('still formats the positive section for those masks', () => { - expect(format(5, '0.00;"neg"', cfgDefault, helperDefault)).toEqual('5.00') - expect(format(5, '0.00;-0.00;', cfgDefault, helperDefault)).toEqual('5.00') - }) -}) - -describe('HF-287 numberFormat — backslash escape in the section splitter', () => { - // A backslash escapes the next character so it is NOT treated as a section - // separator; the escaped pair is carried verbatim through the splitter. Full - // Excel backslash-unescape at render time is out of scope (the backslash is - // emitted literally) — these pin the deterministic behavior and the no-throw - // trailing-backslash case, and exercise the escape branch of splitIntoSections. - it('keeps an escaped character verbatim without throwing', () => { - expect(format(1234.5, '#,##0\\x', cfgDefault, helperDefault)).toEqual('1235\\x') - }) - - it('tolerates a trailing backslash with no following character', () => { - expect(format(5, '0\\', cfgDefault, helperDefault)).toEqual('5\\') - }) - - it('resolves a backslash escape inside a rendered literal section', () => { - // Negative section is the placeholder-less literal "\x"; renderLiteralSection - // resolves the escape, so -5 renders "x". - expect(format(-5, '0.00;\\x', cfgDefault, helperDefault)).toEqual('x') - }) -}) - -describe('HF-287 numberFormat — ReDoS discipline (white-box regex shape)', () => { - // Synchronous ReDoS is not catchable by jest/jasmine timeouts (see DEV-2120), - // so we assert the regex SHAPE stays a flat character class with no nested - // quantifier such as ([#0]+,)*[#0]+. - it('number format regex is the flat, non-backtracking form', () => { - expect(NUMBER_FORMAT_REGEX_SOURCE).toEqual('(\\\\.|[#0,]+(\\.[#0]*)?)') - }) - - it('number format regex contains no nested quantifier', () => { - // no ")" immediately followed by "*" or "+" (a group being quantified) - expect(/\)[*+]/.test(NUMBER_FORMAT_REGEX_SOURCE)).toBe(false) - }) -}) From 9d7004557fce7cefb8aedbe9c17d072a590d3c10 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 28 Jul 2026 14:10:28 +0000 Subject: [PATCH 3/4] docs(changelog): note color-tag stripping before the stringify callbacks (HF-287) Color tags are removed from the format string before it reaches the stringifyDateTime and stringifyDuration callbacks, so a custom callback no longer sees them. That is client-visible and belongs in the entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f50c40aff..0247e13967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- 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) +- 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]`, which are now removed from the format string before it reaches the `stringifyDateTime` and `stringifyDuration` callbacks. [#1716](https://github.com/handsontable/hyperformula/pull/1716) - 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) - 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) From 0e91ba4691280e51e36e3177235c8955d0c11e39 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 28 Jul 2026 15:24:51 +0000 Subject: [PATCH 4/4] docs(changelog): point the HF-287 entry at issue #1145 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file cites the reporting issue in 93 entries and a PR in 12, and HF-24's stringifyCurrency entry in this same release already points at #1145. That issue asked for two things — support for the $#,##0.00 format, or a stringifyCurrency config option. HF-24 shipped the alternative; this change delivers the primary ask, so the entry references the issue rather than the pull request. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d6f868fc..a51d2bc73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- 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]`, which are now removed from the format string before it reaches the `stringifyDateTime` and `stringifyDuration` callbacks. [#1716](https://github.com/handsontable/hyperformula/pull/1716) +- 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]`, which are now removed from the format string before it reaches the `stringifyDateTime` and `stringifyDuration` callbacks. [#1145](https://github.com/handsontable/hyperformula/issues/1145) - 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) - 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) - Fixed the page freezing when entering a long string of digits containing a non-digit character near the end (e.g. `012...789a` or `012...789 123`) into a cell. [#1520](https://github.com/handsontable/hyperformula/issues/1520)