diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd51..0507a8208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed percentage scaling in `TEXT`: `TEXT(0.0123,"0.00%")` now returns `1.23%` instead of `0.01%`. Quoted or escaped percent signs remain literal. Remove manual multiplication by 100 used to work around the previous behavior. [#1776](https://github.com/handsontable/hyperformula/pull/1776) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/compatibility-with-microsoft-excel.md b/docs/guide/compatibility-with-microsoft-excel.md index c0e86520b..0ccc6cad9 100644 --- a/docs/guide/compatibility-with-microsoft-excel.md +++ b/docs/guide/compatibility-with-microsoft-excel.md @@ -170,6 +170,8 @@ Options related to date and time formats: ### `TEXT` function formats +HyperFormula's built-in number formatter supports percentage formats such as `0%` and `0.00%`. Each unquoted, unescaped `%` multiplies the value by 100 before rounding. For example, `=TEXT(0.0123,"0.00%")` returns `1.23%`. A quoted or backslash-escaped percent sign in a numeric format is displayed literally without scaling the value. + Excel's `TEXT` function supports a wide range of date, time, and currency formats. To cover the full range in HyperFormula, supply both [`stringifyDateTime()`](../api/interfaces/configparams.md#stringifydatetime) (for dates and durations) and [`stringifyCurrency()`](../api/interfaces/configparams.md#stringifycurrency) (for currency formats — locale-aware grouping, non-`$` symbols, accounting two-section patterns). See [Currency handling](currency-handling.md) for an `Intl.NumberFormat`-based example. ## Full configuration diff --git a/src/format/format.ts b/src/format/format.ts index 52e119254..bd614007f 100644 --- a/src/format/format.ts +++ b/src/format/format.ts @@ -3,9 +3,11 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {CellError, ErrorType} from '../Cell' import {Config} from '../Config' import {TIME_FORMAT_SECONDS_ITEM_REGEXP} from '../DateTimeDefault' import {DateTimeHelper, numberToSimpleTime, SimpleDateTime, SimpleTime} from '../DateTimeHelper' +import {ErrorMessage} from '../error-message' import {RawScalarValue} from '../interpreter/InterpreterValue' import {Maybe} from '../Maybe' import {FormatToken, parseForDateTimeFormat, parseForNumberFormat, TokenType} from './parser' @@ -75,12 +77,24 @@ function countChars(text: string, char: string) { return text.split(char).length - 1 } +/** + * Renders a numeric mask after scaling once for each active percent operator. + * Scaling precedes rounding and leaves custom formatter callbacks unaffected. + */ function numberFormat(tokens: FormatToken[], value: number): RawScalarValue { + for (const token of tokens) { + if (token.type === TokenType.PERCENT) { + value *= 100 + if (!Number.isFinite(value)) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + } + } let result = '' for (let i = 0; i < tokens.length; ++i) { const token = tokens[i] - if (token.type === TokenType.FREE_TEXT) { + if (token.type === TokenType.FREE_TEXT || token.type === TokenType.PERCENT) { result += token.value continue } diff --git a/src/format/parser.ts b/src/format/parser.ts index 3583b205c..d584e519c 100644 --- a/src/format/parser.ts +++ b/src/format/parser.ts @@ -6,11 +6,12 @@ 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 +const numberFormatRegex = /(\\.|"[^"]*"|[#0]+(\.[#0]*)?|%)/g export enum TokenType { FORMAT = 'FORMAT', FREE_TEXT = 'FREE_TEXT', + PERCENT = 'PERCENT', } export interface FormatToken { @@ -52,15 +53,26 @@ function matchDateFormat(str: string): RegExpExecArray[] { return tokens } +/** + * Finds one numeric mask and its surrounding literals and percent operators. + * Keeping quotes and escapes intact prevents literal percent signs from scaling + * the value. Later numeric masks remain free text, as in the existing formatter. + */ function matchNumberFormat(str: string): RegExpExecArray[] { numberFormatRegex.lastIndex = 0 - const numberFormatToken = numberFormatRegex.exec(str) + const tokens: RegExpExecArray[] = [] + let foundNumber = false + let token - if (numberFormatToken !== null) { - return [numberFormatToken] - } else { - return [] + while ((token = numberFormatRegex.exec(str)) !== null) { + const isNumber = token[0].startsWith('#') || token[0].startsWith('0') + if (!isNumber || !foundNumber) { + tokens.push(token) + } + foundNumber = foundNumber || isNumber } + + return foundNumber ? tokens : [] } function createTokens(regexTokens: RegExpExecArray[], str: string) { @@ -106,6 +118,11 @@ export function parseForDateTimeFormat(str: string): Maybe { } } +/** + * Tokenizes a numeric format, distinguishing percent operators from literals. + * Percent escapes and quoted literals are decoded only on the number path; + * date tokens and unrelated backslash escapes retain their representation. + */ export function parseForNumberFormat(str: string): Maybe { const numberFormatTokens = matchNumberFormat(str) if (numberFormatTokens.every((elem) => isEscapeToken(elem))) { @@ -113,7 +130,18 @@ export function parseForNumberFormat(str: string): Maybe { } else { return { type: FormatExpressionType.NUMBER, - tokens: createTokens(numberFormatTokens, str), + tokens: createTokens(numberFormatTokens, str).map((token) => { + if (token.value === '\\%' || token.value === '\\\\') { + return formatToken(TokenType.FREE_TEXT, token.value.substring(1)) + } + if (token.type === TokenType.FORMAT && token.value.startsWith('"')) { + return formatToken(TokenType.FREE_TEXT, token.value.slice(1, -1)) + } + if (token.type === TokenType.FORMAT && token.value === '%') { + return formatToken(TokenType.PERCENT, token.value) + } + return token + }), } } }