Skip to content

fix(format): support thousands grouping and format sections in numberFormat/TEXT (HF-287) - #1716

Open
marcin-kordas-hoc wants to merge 6 commits into
developfrom
feat/hf-287-numberformat-sections
Open

fix(format): support thousands grouping and format sections in numberFormat/TEXT (HF-287)#1716
marcin-kordas-hoc wants to merge 6 commits into
developfrom
feat/hf-287-numberformat-sections

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What & why

TEXT / numberFormat only understood a single simple mask ([#0]+(\.[#0]*)?); complex masks emitted garbage — e.g. TEXT(1234.5,"#,##0.00")"1235,##0.00". It ignored thousands grouping (#,##0), format sections (;), and HF's configured decimalSeparator/thousandSeparator.

How (Option A — extend the formatter in place; no grammar rewrite, no public API, no i18n)

  • src/format/parser.ts: widen numberFormatRegex to admit , into the flat character class ([#0,]+(\.[#0]*)?) — no nested quantifier (keeps the DEV-2120 ReDoS discipline); white-box shape test via exported NUMBER_FORMAT_REGEX_SOURCE.
  • src/format/format.ts: section split (positive;negative;zero, quote/escape-aware), grouping with config.thousandSeparator, decimal via config.decimalSeparator, sign on abs, color-tag strip applied AFTER the stringifyCurrency callback (doesn't disturb that extension point), parse-failure falls back to the cleaned format string.
  • Docs (compatibility-with-microsoft-excel.md) reworded as nuances; CHANGELOG under Fixed.

Contract note — stringifyDateTime / stringifyDuration

The color-tag strip sits between the stringifyCurrency callback and date/time dispatch, so those two callbacks now receive the format string without color tags: a custom stringifyDateTime sees dd-mm-yyyy, not [Red]dd-mm-yyyy. stringifyCurrency still receives the raw string.

That placement is what fixes [Red]dd-mm-yyyy: the d in Red used to be read as a day token, so the mask rendered [Re1]01-01-1900. Stripping only inside the number path would leave that bug in place. Noted in the CHANGELOG entry.

Verified against Excel (HF-287 repro table)

The $#,##0.00 case was originally reported in #1138 (=TEXT(1234.567,"$#,##0.00")$1235,##0.00 instead of $1,234.57), which was closed in favour of #1145. HF-24 shipped that issue's alternative — the stringifyCurrency callback; this PR delivers its primary ask, so the CHANGELOG entry points at #1145.

format value Excel now
#,##0.00 1234.5 1,234.50 ✅ (thousand ,)
#,##0.00;-#,##0.00 -1234.5 -1,234.50
#,##0.00 "zł" 1234.5 1 234.50 zł ✅ (thousand )
$#,##0.00;-$#,##0.00 -1234.5 -$1,234.50
000.00 -5 -005.00

Tests — handsontable/hyperformula-tests#27

All HF-287 coverage lives in the private suite; this PR adds no test files.

  • unit/format/format-number-sections.spec.ts — 58 single-assertion cases: grouping against a configured thousandSeparator, sign-selected sections, config decimal separator, color-tag stripping, literal-only sections, backslash escapes, degradation, regression locks on the simple masks, engine-level TEXT, and the white-box ReDoS shape assertion.
  • unit/interpreter/function-text.spec.ts — one existing assertion flips (correct-behavior change): TEXT(12.45,"$###,##0.00") was '$12,##0.00' (garbage), now '$12.45'. Without the paired update, unit-tests CI fails on the old expectation.

Local run against this head: 8 suites / 233 tests green across the new spec plus unit/format/, function-text, function-textjoin, config, update-config and smoke; tsc -p tsconfig.test.json clean; eslint 0 errors on the touched files.

A before/after sweep over 45 masks (base d860eef3e vs this head) shows changes only where the old output was garbage. Two cases worth naming: TEXT(5,"0,000") was 5,000 and is now 0005 on default config (the old output only looked grouped — it was the mask tail leaking), and [Red]dd-mm-yyyy was [Re1]01-01-1900.

Scoped OUT (per ADR — not regressions)

percent 0.00%, scaler arithmetic (degrades visibly), ? placeholders, scientific E+, @/4th text section, [condition] comparators, and placeholder chars inside quoted literals. Also: =TEXT(x,"… ""zł""") as a literal formula still returns #ERROR! — HF's formula parser rejects embedded "" (unrelated to the formatter, which does handle the quoted literal when the format arrives via config/programmatically).


Note

Medium Risk
Touches core TEXT formatting used widely in spreadsheets; behavior changes for previously broken masks and custom stringifyDateTime/stringifyDuration now receive color-stripped format strings.

Overview
Fixes TEXT / built-in number formatting so masks like #,##0.00 and multi-section patterns (0.00;(0.00)) no longer leak unparsed mask text into the output.

The number path now splits on ; (quote/escape-aware), picks positive/negative/zero sections, groups thousands via config.thousandSeparator, and uses config.decimalSeparator instead of a hardcoded dot. [Red]-style color tags are stripped after stringifyCurrency but before date/time callbacks (so [Red]dd-mm-yyyy formats as a date). Parse failures render literal-only sections instead of returning the whole mask string.

parser.ts widens the number regex to admit grouping commas (flat pattern, ReDoS-safe) and skips comma-only runs without #/0. CHANGELOG and Excel compatibility docs document the fix, separator behavior, and remaining unsupported mask features.

Reviewed by Cursor Bugbot for commit 0e91ba4. Bugbot is set up for automated code reviews on this repo. Configure here.

@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for hyperformula-dev-docs ready!

Name Link
🔨 Latest commit 0e91ba4
🔍 Latest deploy log https://app.netlify.com/projects/hyperformula-dev-docs/deploys/6a68c9cef1542c0008c52f67
😎 Deploy Preview https://deploy-preview-1716--hyperformula-dev-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@qunabu

qunabu commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

bugbot run

@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the feat/hf-287-numberformat-sections branch from 98bb963 to 8a64987 Compare July 24, 2026 12:32
Comment thread src/format/parser.ts
Comment thread src/format/format.ts Outdated
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Performance comparison of head (0e91ba4) vs base (ad142ba)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  383.42 |  388.33 | +1.28%
                                      Sheet B |   114.5 |  114.89 | +0.34%
                                      Sheet T |  102.24 |  103.53 | +1.26%
                                Column ranges |  512.05 |  488.95 | -4.51%
                                Sorted lookup | 16206.1 | 15077.2 | -6.97%
Sheet A:  change value, add/remove row/column |    9.71 |   10.53 | +8.44%
 Sheet B: change value, add/remove row/column |   97.73 |    97.8 | +0.07%
                   Column ranges - add column |   119.8 |  125.17 | +4.48%
                Column ranges - without batch |  384.06 |  403.08 | +4.95%
                        Column ranges - batch |   98.89 |  102.64 | +3.79%

@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the feat/hf-287-numberformat-sections branch from 8a64987 to b92d6c0 Compare July 24, 2026 12:54
…Format/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>
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the feat/hf-287-numberformat-sections branch from b92d6c0 to fa890f6 Compare July 24, 2026 18:11
@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fa890f6. Configure here.

@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as ready for review July 25, 2026 10:54
marcin-kordas-hoc and others added 4 commits July 28, 2026 14:37
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) <noreply@anthropic.com>
…cks (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) <noreply@anthropic.com>
Comment thread src/format/format.ts
}

return result
return negative ? '-' + result : result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed zero after rounding

Medium Severity

numberFormat records the raw sign and always prepends - when the input is negative, even if toFixed rounds the magnitude to zero. That yields outputs like -0 / -0.00 for values such as -0.4 with mask 0, whereas Excel’s TEXT and the previous formatter emit an unsigned zero.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit db2e830. Configure here.

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) <noreply@anthropic.com>
@qunabu

qunabu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0e91ba4. Configure here.

Comment thread src/format/format.ts
*/
function numberFormat(tokens: FormatToken[], value: number, config: Config): RawScalarValue {
const negative = value < 0
const absValue = Math.abs(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escapes leak into number output

Low Severity

matchNumberFormat skips escape tokens so it can reach a #/0 run, but those escapes remain in the source slice copied into FREE_TEXT. numberFormat then appends that text verbatim, so the backslash appears in the result. renderLiteralSection already resolves escapes for placeholder-free sections, so placeholder-bearing masks that use \ (for example an escaped space) stay wrong.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e91ba4. Configure here.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.23%. Comparing base (ad142ba) to head (0e91ba4).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1716      +/-   ##
===========================================
+ Coverage    97.22%   97.23%   +0.01%     
===========================================
  Files          178      178              
  Lines        15612    15677      +65     
  Branches      3430     3441      +11     
===========================================
+ Hits         15178    15243      +65     
  Misses         426      426              
  Partials         8        8              
Files with missing lines Coverage Δ
src/format/format.ts 99.54% <100.00%> (+0.19%) ⬆️
src/format/parser.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants