mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 12:16:54 -06:00
Excel shows 15 significant digits. When a number is an exact tie at the 16th
(its exact decimal expansion is 15 digits, a 5, then only zeros), Excel
rounds it toward zero: TEXT(1234567890123455,"0") is 1234567890123450.
GridCraft took Rust's `{:.14e}`, which rounds that tie to even, so any tie
with an odd 15th digit came out one unit too high (…460).
decimal_digits now formats 16 digits (correct unless the 16th is a 5),
and only on a 5 checks the exact expansion (a 30-digit screen, then the
full one) and truncates a true tie. Non-ties, including values just above
or below a tie, round as before. One format serves almost every number.
Tests: ties of every 15th-digit parity, negatives, fractional ties, the
neighbours just above and below a tie, the carry after fifteen 9s
(999999999999999.5 shows 999999999999999), and unchanged ordinary cases.
Evidence: Windows Excel 16.0.20430 TEXT() over a 174-row probe and a 24-row
carry probe agree on every row except those under a percent format and
General over negatives (the two bugs named below). The suite's number-format
golden goes from 183,843 to 184,900 of 187,810 passing with no row that
passed before failing now.
modified: crates/numfmt/src/decimal.rs
modified: crates/numfmt/src/tests.rs
235 lines
9.6 KiB
Rust
235 lines
9.6 KiB
Rust
//! Decimal-string rounding and the General format.
|
||
//!
|
||
//! Excel works with 15 significant digits, so numbers are first written with 15 significant
|
||
//! digits and then rounded half away from zero on that decimal string. That makes 2.675 with
|
||
//! "0.00" show 2.68 and 1.005 show 1.01, as in Excel, even though neither is exact in binary.
|
||
|
||
/// Most decimals we ever compute; placeholders beyond this are padded with zeros.
|
||
pub(crate) const MAX_DECIMALS: usize = 400;
|
||
|
||
/// The 15 significant digits of a positive finite number and the power of ten of the first
|
||
/// digit: `a ≈ d0.d1d2… × 10^exp`.
|
||
///
|
||
/// The value is rounded to the nearest 15-digit decimal. An exact tie at the 16th digit (the exact
|
||
/// decimal expansion is 15 digits, a 5, then only zeros, as for 1234567890123455 or
|
||
/// 123456789012345.5) goes toward zero, as Excel does: `1234567890123455` shows `1234567890123450`,
|
||
/// not `…460`. Excel observed (Windows 16.0.20430, TEXT()).
|
||
pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) {
|
||
let mut digits = [0u8; 15];
|
||
if !a.is_finite() || a <= 0.0 {
|
||
return (digits, 0);
|
||
}
|
||
// 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds
|
||
// down, 6 or more up), so one format serves almost every number.
|
||
let (d16, exp) = significant_digits(a, 16);
|
||
for (slot, d) in digits.iter_mut().zip(&d16) {
|
||
*slot = *d;
|
||
}
|
||
match d16.get(15).copied().unwrap_or(0) {
|
||
0..=4 => (digits, exp),
|
||
5 if is_exact_tie(a) => (digits, exp),
|
||
5 => {
|
||
// Not a tie: round the exact value (the 16-digit rounding above may be a double rounding).
|
||
let (d15, exp15) = significant_digits(a, 15);
|
||
let mut out = [0u8; 15];
|
||
for (slot, d) in out.iter_mut().zip(&d15) {
|
||
*slot = *d;
|
||
}
|
||
(out, exp15)
|
||
}
|
||
_ => {
|
||
let mut exp = exp;
|
||
let mut carry = true;
|
||
for d in digits.iter_mut().rev() {
|
||
if *d == 9 {
|
||
*d = 0;
|
||
} else {
|
||
*d += 1;
|
||
carry = false;
|
||
break;
|
||
}
|
||
}
|
||
if carry {
|
||
digits = [0; 15];
|
||
digits[0] = 1;
|
||
exp += 1;
|
||
}
|
||
(digits, exp)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of
|
||
/// ten of the first.
|
||
fn significant_digits(a: f64, n: usize) -> (Vec<u8>, i32) {
|
||
let s = format!("{:.*e}", n.saturating_sub(1), a);
|
||
let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
|
||
(mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0))
|
||
}
|
||
|
||
/// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after.
|
||
fn is_exact_tie(a: f64) -> bool {
|
||
let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0));
|
||
// A cheap screen first (30 digits), then the exact expansion: a double has at most 767
|
||
// significant digits and Rust prints every one of them.
|
||
is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0)
|
||
}
|
||
|
||
/// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away
|
||
/// from zero. Returns the integer digits ("" when the integer part is zero) and exactly
|
||
/// `decimals` fraction digits.
|
||
pub(crate) fn fixed_from_digits(digits: &[u8; 15], exp: i32, decimals: usize) -> (String, String) {
|
||
let decimals_calc = decimals.min(MAX_DECIMALS);
|
||
let keep = exp as i64 + decimals_calc as i64 + 1;
|
||
let mut m: Vec<u8> = if keep <= 0 {
|
||
if keep == 0 && digits.first().copied().unwrap_or(0) >= 5 { vec![1] } else { Vec::new() }
|
||
} else if keep as usize >= digits.len() {
|
||
let mut v = digits.to_vec();
|
||
v.resize(keep as usize, 0);
|
||
v
|
||
} else {
|
||
let keep = keep as usize;
|
||
let mut v = digits.get(..keep).map(<[u8]>::to_vec).unwrap_or_default();
|
||
if digits.get(keep).copied().unwrap_or(0) >= 5 {
|
||
let mut carry = true;
|
||
for d in v.iter_mut().rev() {
|
||
if *d == 9 {
|
||
*d = 0;
|
||
} else {
|
||
*d += 1;
|
||
carry = false;
|
||
break;
|
||
}
|
||
}
|
||
if carry {
|
||
v.insert(0, 1);
|
||
}
|
||
}
|
||
v
|
||
};
|
||
let lead = m.iter().take_while(|&&d| d == 0).count();
|
||
m.drain(..lead);
|
||
let to_s = |ds: &[u8]| ds.iter().map(|d| char::from(b'0' + d)).collect::<String>();
|
||
let (int, mut frac) = if m.len() <= decimals_calc {
|
||
let mut f = "0".repeat(decimals_calc - m.len());
|
||
f.push_str(&to_s(&m));
|
||
(String::new(), f)
|
||
} else {
|
||
let split = m.len() - decimals_calc;
|
||
(to_s(m.get(..split).unwrap_or(&[])), to_s(m.get(split..).unwrap_or(&[])))
|
||
};
|
||
if decimals > decimals_calc {
|
||
frac.push_str(&"0".repeat(decimals - decimals_calc));
|
||
}
|
||
(int, frac)
|
||
}
|
||
|
||
/// Rounds a non-negative number to `decimals` places (see [`fixed_from_digits`]).
|
||
pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) {
|
||
if !a.is_finite() || a <= 0.0 {
|
||
return (String::new(), "0".repeat(decimals.min(MAX_DECIMALS * 4)));
|
||
}
|
||
let (d, e) = decimal_digits(a);
|
||
fixed_from_digits(&d, e, decimals)
|
||
}
|
||
|
||
/// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then
|
||
/// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####".
|
||
pub fn format_general_fit(n: f64, max_chars: usize) -> Option<String> {
|
||
if !n.is_finite() {
|
||
return None;
|
||
}
|
||
if n == 0.0 {
|
||
return (max_chars >= 1).then(|| "0".to_string());
|
||
}
|
||
let sign = if n < 0.0 { "-" } else { "" };
|
||
let (digits, x) = decimal_digits(n.abs());
|
||
let int_len = sign.len() + (x.max(0) as usize) + 1;
|
||
// Fixed notation for magnitudes from 1E-4 up to what fits.
|
||
if x >= -4 && int_len <= max_chars {
|
||
let avail = max_chars - int_len;
|
||
let mut decimals = if avail >= 2 { avail - 1 } else { 0 };
|
||
loop {
|
||
let (i, f) = fixed_from_digits(&digits, x, decimals);
|
||
let f = f.trim_end_matches('0');
|
||
if i.is_empty() && f.is_empty() {
|
||
break; // rounded away to zero: use scientific
|
||
}
|
||
let mut s = String::from(sign);
|
||
s.push_str(if i.is_empty() { "0" } else { &i });
|
||
if !f.is_empty() {
|
||
s.push('.');
|
||
s.push_str(f);
|
||
}
|
||
if s.chars().count() <= max_chars {
|
||
return Some(s);
|
||
}
|
||
if decimals == 0 {
|
||
break;
|
||
}
|
||
decimals -= 1;
|
||
}
|
||
}
|
||
for md in (0..=max_chars.min(14)).rev() {
|
||
let mut e = x;
|
||
let (mut mi, mut mf) = fixed_from_digits(&digits, 0, md);
|
||
if mi.len() > 1 {
|
||
e += 1;
|
||
(mi, mf) = fixed_from_digits(&digits, -1, md);
|
||
}
|
||
let mf = mf.trim_end_matches('0');
|
||
let mut s = String::from(sign);
|
||
s.push_str(if mi.is_empty() { "0" } else { &mi });
|
||
if !mf.is_empty() {
|
||
s.push('.');
|
||
s.push_str(mf);
|
||
}
|
||
s.push_str(&format!("E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs()));
|
||
if s.chars().count() <= max_chars {
|
||
return Some(s);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rounding() {
|
||
assert_eq!(fixed(2.675, 2), ("2".into(), "68".into()));
|
||
assert_eq!(fixed(1.005, 2), ("1".into(), "01".into()));
|
||
assert_eq!(fixed(0.5, 0), ("1".into(), "".into()));
|
||
assert_eq!(fixed(0.04, 1), ("".into(), "0".into()));
|
||
assert_eq!(fixed(0.05, 1), ("".into(), "1".into()));
|
||
assert_eq!(fixed(9.999, 2), ("10".into(), "00".into()));
|
||
assert_eq!(fixed(0.0004, 3), ("".into(), "000".into()));
|
||
assert_eq!(fixed(0.0005, 3), ("".into(), "001".into()));
|
||
assert_eq!(fixed(1e20, 0).0, "100000000000000000000");
|
||
assert_eq!(fixed(0.0, 2), ("".into(), "00".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn general_fit() {
|
||
assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11"));
|
||
assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901"));
|
||
assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11"));
|
||
assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3"));
|
||
assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333"));
|
||
assert_eq!(format_general_fit(123456.789012, 11).as_deref(), Some("123456.789"));
|
||
assert_eq!(format_general_fit(1e-10, 11).as_deref(), Some("1E-10"));
|
||
assert_eq!(format_general_fit(0.0000123456789, 11).as_deref(), Some("1.23457E-05"));
|
||
assert_eq!(format_general_fit(0.0001, 11).as_deref(), Some("0.0001"));
|
||
assert_eq!(format_general_fit(-5.0, 11).as_deref(), Some("-5"));
|
||
assert_eq!(format_general_fit(0.0, 11).as_deref(), Some("0"));
|
||
assert_eq!(format_general_fit(1234.5678, 6).as_deref(), Some("1234.6"));
|
||
assert_eq!(format_general_fit(1234.5678, 4).as_deref(), Some("1235"));
|
||
assert_eq!(format_general_fit(123456.0, 5).as_deref(), Some("1E+05"));
|
||
assert_eq!(format_general_fit(123456.0, 3), None);
|
||
assert_eq!(format_general_fit(9.99999999999, 5).as_deref(), Some("10"));
|
||
assert_eq!(format_general_fit(f64::NAN, 11), None);
|
||
assert_eq!(format_general_fit(1.7976931348623157e308, 11).as_deref(), Some("1.7977E+308"));
|
||
}
|
||
}
|