Skip to content

Fix number patterns rounding unlike Excel and garbling large numbers - #93

Closed
gthb wants to merge 5 commits into
borgar:masterfrom
gthb:excel-fixed-point-rounding
Closed

gthb wants to merge 5 commits into
borgar:masterfrom
gthb:excel-fixed-point-rounding

Conversation

@gthb

@gthb gthb commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

Fix how format() rounds and writes numbers in fixed-point and exponent patterns:

  • A number of 1e21 or more is written out in full: #,##0 of 2^70 was 1,.18,059,162,071,741,13e,+21 and is now 1,180,591,620,717,410,000,000.
  • A number below 1e-6 keeps its digits: 0.0000000000 of 1e-7 was 0.0000000000 and is now 0.0000001000.
  • An exponent pattern of a subnormal number has the right exponent: 0.00E+00 of 1e-320 was 1.00E-321 and is now 1.00E-320.
  • Rounding matches Excel, checked in Excel with TEXT:
Pattern Value Excel Before
0.00 1.005 1.01 1.00
0.00 0.285 0.29 0.28
0 2.5 - 2^-51 3 2
0 10^15 + 5 1000000000000000 1000000000000005
0 246913578024691 / 2 123456789012345 123456789012346
0.00000000000000E+00 3 * 2^51 + 1 6.75539944105574E+15 6.75539944105575E+15

Why

Excel rounds a number for display in two steps:

  1. The double's exact value to 15 significant digits, a tie going toward zero.
  2. That 15-digit decimal (not the double nearest it) to the pattern's last digit, a tie going away from zero.

Before this change, numfmt mismatched that in two ways:

  • it rounded number * 10 ** places with Math.round, which works on the double's binary value
  • it wrote digits with String(), which switches to exponent notation outside 1e-6 to 1e21

How

  • New lib/toDecimal.ts:
    • toDecimal does step 1: toPrecision(15), which rounds a tie away from zero, plus an exact float-arithmetic test for a tie, which then goes the other way.
    • roundDecimal does step 2 on the digit string.
  • Exponent patterns take the significand's digits from the same decimal.
  • Subnormal numbers are rounded from their shortest representation.
  • 0 of Number.MAX_SAFE_INTEGER is now 9007199254740990 (15 significant digits), as 0.0 already wrote it.

In a quick check I see no slowdown, indeed this seems a little faster: formatting 1e6 numbers with #,##0.00 took 6–16% less time than on master (Node 24, two runs).

gthb added 4 commits October 15, 2026 13:41
A fixed-point pattern rounded the number with Math.round(number * 10 **
places), and wrote its digits with String(), so:

- a number of 1e21 or more came out garbled: `#,##0` of 1e300 gave `1e+,300`
- so did a small number with many decimals: `0.0000000000` of 1e-7 gave
  `0.0000000000`
- rounding followed the double's binary value, not Excel: `0.00` of 1.005
  gave `1.00` (Excel: `1.01`), and `0` of 10^15+5 gave `1000000000000005`
  (Excel: `1000000000000000`)

Excel rounds in two steps: first the double's exact value to 15
significant digits, a tie going toward zero; then that decimal to the
pattern's last digit, a tie going away from zero.

toDecimal does the first step: toPrecision(15) rounds the exact value,
and a tie, which it rounds the wrong way, is detected exactly with float
arithmetic. roundDecimal does the second step on the digit string, and
the integer and fraction digits come out of it as strings, so no number
is formatted by String() any longer.

The exponent pattern's check for a significand rounding up to 10 now uses
the same rounding, so that it agrees with the digits written.

Number.MAX_SAFE_INTEGER under `0` now gives 15 significant digits, as
`0.0` already did.
An exponent pattern computed its significand as number * 10 ** -exp in
floating point and then rounded that, so its digits could differ from the
number's own: `0.00000000000000E+00` of 3 * 2 ** 51 + 1 gave
`6.75539944105575E+15` (Excel: `6.75539944105574E+15`), and `0.00E+00` of
1.005 gave `1.00E+00`.

The significand now takes the number's 15-digit decimal with its point
shifted, and the exponent comes from that decimal's point, so both of
Excel's rounding steps apply as they do for fixed point.

Subnormal numbers, which Excel doesn't have, keep their shortest
representation (`0.000E+000` of 2.33e-321 stays `2.330E-321`) rather than
show the digits of their exact value.
The tie branch of toDecimal has a second test for a value whose 15-digit
rounding carries into a new integer digit; no test reached it. Exponent
patterns of a subnormal number had the exponent off by one (Math.log10 of
1e-320 floors to -321), which the branch fixes without a test.
The shortest representation of a subnormal can have 16 digits, so the
15-digit rounding applies to it; the comment said it is used as is.
@gthb
gthb marked this pull request as ready for review October 15, 2026 14:44
@gthb gthb changed the title Fix number patterns garbling large numbers and rounding unlike Excel Fix number patterns rounding unlike Excel and garbling large numbers Oct 15, 2026
Excel's TEXT(501.5,"0.00E+00") is 5.02E+02; master gave 5.01E+02.
borgar added a commit that referenced this pull request Oct 16, 2026
Number formats now round in two steps, matching Excel: first the exact
value is rounded to 15 significant digits, with a tie going toward zero;
then that decimal is rounded to the pattern's last digit, with a tie
going away from zero. Digits are always written out in full, so large
and small values no longer appear as `e+21` or similar.

- `1.005` with `0.00` gives `1.01` (was `1.00`)
- `2 ** 70` with `#,##0` gives `1,180,591,620,717,410,000,000` (was
garbled)
- Engineering notation moves the exponent when rounding carries:
`999.96` with `##0.0E+0` gives `1.0E+3`
- `General` rounds a tie at the tenth digit up, as Excel does

This is an alternative to #93, using a faster, arithmetic-based
approach.

### Notable differences from #93

Engineering notation when rounding carries:

| Pattern | Value | #93 | This PR |
|---|---|---|---|
| `##0.0E+0` | `999.96` | `1000.0E+0` | `1.0E+3` |
| `##0.0E+0` | `999960` | `1000.0E+3` | `1.0E+6` |
| `##0.0E+0` | `999999999999999 + 0.5` | `1000.0E+12` | `1.0E+15` |

`General` with a tie at the tenth digit:

| Value | #93 | This PR |
|---|---|---|
| `4.2394791745` | `4.239479174` | `4.239479175` |
| `19147467.115` | `19147467.11` | `19147467.12` |
| `42299445.995` | `42299445.99` | `42299446` |

This is because `round()` now follows the same rules as the formats:

| Call | `master` | This PR |
|---|---|---|
| `round(1.005, 2)` | `1` | `1.01` |
| `round(0.285, 2)` | `0.28` | `0.29` |
| `round(1e15 + 5)` | `1000000000000005` | `1000000000000000` |

Vulgar fractions keep the old rounding, which matches Excel more
closely. A known difference is recorded as an expected-failure test.

Co-authored-by: Gunnlaugur Thor Briem <gunnlaugur@gmail.com>
@borgar

borgar commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Closed by #95 which is loosely based on this.

@borgar borgar closed this Oct 16, 2026
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