Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/compatibility-with-microsoft-excel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/format/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 35 additions & 7 deletions src/format/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -106,14 +118,30 @@ export function parseForDateTimeFormat(str: string): Maybe<FormatExpression> {
}
}

/**
* 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<FormatExpression> {
const numberFormatTokens = matchNumberFormat(str)
if (numberFormatTokens.every((elem) => isEscapeToken(elem))) {
return undefined
} 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
}),
}
}
}
Expand Down
Loading