Skip to main content

ravenrs_sheet/numfmt/
decimal.rs

1//! Decimal-string rounding and the General format.
2//!
3//! Excel works with 15 significant digits, so numbers are first written with 15 significant
4//! digits and then rounded half away from zero on that decimal string. That makes 2.675 with
5//! "0.00" show 2.68 and 1.005 show 1.01, as in Excel, even though neither is exact in binary.
6
7/// Most decimals we ever compute; placeholders beyond this are padded with zeros.
8pub(crate) const MAX_DECIMALS: usize = 400;
9
10/// The 15 significant digits of a positive finite number and the power of ten of the first
11/// digit: `a ≈ d0.d1d2… × 10^exp`.
12///
13/// The value is rounded to the nearest 15-digit decimal. An exact tie at the 16th digit (the exact
14/// decimal expansion is 15 digits, a 5, then only zeros, as for 1234567890123455 or
15/// 123456789012345.5) goes toward zero, as Excel does: `1234567890123455` shows `1234567890123450`,
16/// not `…460`. Excel observed (Windows 16.0.20430, TEXT()).
17pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) {
18    let mut digits = [0u8; 15];
19    if !a.is_finite() || a <= 0.0 {
20        return (digits, 0);
21    }
22    // 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds
23    // down, 6 or more up), so one format serves almost every number.
24    let (d16, exp) = significant_digits(a, 16);
25    for (slot, d) in digits.iter_mut().zip(&d16) {
26        *slot = *d;
27    }
28    match d16.get(15).copied().unwrap_or(0) {
29        0..=4 => (digits, exp),
30        5 if is_exact_tie(a) => (digits, exp),
31        5 => {
32            // Not a tie: round the exact value (the 16-digit rounding above may be a double rounding).
33            let (d15, exp15) = significant_digits(a, 15);
34            let mut out = [0u8; 15];
35            for (slot, d) in out.iter_mut().zip(&d15) {
36                *slot = *d;
37            }
38            (out, exp15)
39        }
40        _ => {
41            let mut exp = exp;
42            let mut carry = true;
43            for d in digits.iter_mut().rev() {
44                if *d == 9 {
45                    *d = 0;
46                } else {
47                    *d += 1;
48                    carry = false;
49                    break;
50                }
51            }
52            if carry {
53                digits = [0; 15];
54                digits[0] = 1;
55                exp += 1;
56            }
57            (digits, exp)
58        }
59    }
60}
61
62/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of
63/// ten of the first.
64fn significant_digits(a: f64, n: usize) -> (Vec<u8>, i32) {
65    let s = format!("{:.*e}", n.saturating_sub(1), a);
66    let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
67    (mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0))
68}
69
70/// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after.
71fn is_exact_tie(a: f64) -> bool {
72    let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0));
73    // A cheap screen first (30 digits), then the exact expansion: a double has at most 767
74    // significant digits and Rust prints every one of them.
75    is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0)
76}
77
78/// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away
79/// from zero. Returns the integer digits ("" when the integer part is zero) and exactly
80/// `decimals` fraction digits.
81pub(crate) fn fixed_from_digits(digits: &[u8; 15], exp: i32, decimals: usize) -> (String, String) {
82    let decimals_calc = decimals.min(MAX_DECIMALS);
83    let keep = exp as i64 + decimals_calc as i64 + 1;
84    let mut m: Vec<u8> = if keep <= 0 {
85        if keep == 0 && digits.first().copied().unwrap_or(0) >= 5 { vec![1] } else { Vec::new() }
86    } else if keep as usize >= digits.len() {
87        let mut v = digits.to_vec();
88        v.resize(keep as usize, 0);
89        v
90    } else {
91        let keep = keep as usize;
92        let mut v = digits.get(..keep).map(<[u8]>::to_vec).unwrap_or_default();
93        if digits.get(keep).copied().unwrap_or(0) >= 5 {
94            let mut carry = true;
95            for d in v.iter_mut().rev() {
96                if *d == 9 {
97                    *d = 0;
98                } else {
99                    *d += 1;
100                    carry = false;
101                    break;
102                }
103            }
104            if carry {
105                v.insert(0, 1);
106            }
107        }
108        v
109    };
110    let lead = m.iter().take_while(|&&d| d == 0).count();
111    m.drain(..lead);
112    let to_s = |ds: &[u8]| ds.iter().map(|d| char::from(b'0' + d)).collect::<String>();
113    let (int, mut frac) = if m.len() <= decimals_calc {
114        let mut f = "0".repeat(decimals_calc - m.len());
115        f.push_str(&to_s(&m));
116        (String::new(), f)
117    } else {
118        let split = m.len() - decimals_calc;
119        (to_s(m.get(..split).unwrap_or(&[])), to_s(m.get(split..).unwrap_or(&[])))
120    };
121    if decimals > decimals_calc {
122        frac.push_str(&"0".repeat(decimals - decimals_calc));
123    }
124    (int, frac)
125}
126
127/// Rounds a non-negative number to `decimals` places (see [`fixed_from_digits`]).
128pub(crate) fn fixed(a: f64, decimals: usize) -> (String, String) {
129    if !a.is_finite() || a <= 0.0 {
130        return (String::new(), "0".repeat(decimals.min(MAX_DECIMALS * 4)));
131    }
132    let (d, e) = decimal_digits(a);
133    fixed_from_digits(&d, e, decimals)
134}
135
136/// General format squeezed into `max_chars` characters. Excel shows fewer decimals, then
137/// scientific notation, then "####" when a column is narrow; `None` means the caller paints "####".
138pub fn format_general_fit(n: f64, max_chars: usize) -> Option<String> {
139    // The widest fixed rendering of a double is about 330 characters; a larger request only
140    // pads zero decimals the trim below throws away.
141    let max_chars = max_chars.min(1024);
142    if !n.is_finite() {
143        return None;
144    }
145    if n == 0.0 {
146        return (max_chars >= 1).then(|| "0".to_string());
147    }
148    let sign = if n < 0.0 { "-" } else { "" };
149    let (digits, x) = decimal_digits(n.abs());
150    let int_len = sign.len() + (x.max(0) as usize) + 1;
151    // Fixed notation for magnitudes from 1E-4 up to what fits.
152    if x >= -4 && int_len <= max_chars {
153        let avail = max_chars - int_len;
154        let mut decimals = if avail >= 2 { avail - 1 } else { 0 };
155        loop {
156            let (i, f) = fixed_from_digits(&digits, x, decimals);
157            let f = f.trim_end_matches('0');
158            if i.is_empty() && f.is_empty() {
159                break; // rounded away to zero: use scientific
160            }
161            let mut s = String::from(sign);
162            s.push_str(if i.is_empty() { "0" } else { &i });
163            if !f.is_empty() {
164                s.push('.');
165                s.push_str(f);
166            }
167            if s.chars().count() <= max_chars {
168                return Some(s);
169            }
170            if decimals == 0 {
171                break;
172            }
173            decimals -= 1;
174        }
175    }
176    for md in (0..=max_chars.min(14)).rev() {
177        let mut e = x;
178        let (mut mi, mut mf) = fixed_from_digits(&digits, 0, md);
179        if mi.len() > 1 {
180            e += 1;
181            (mi, mf) = fixed_from_digits(&digits, -1, md);
182        }
183        let mf = mf.trim_end_matches('0');
184        let mut s = String::from(sign);
185        s.push_str(if mi.is_empty() { "0" } else { &mi });
186        if !mf.is_empty() {
187            s.push('.');
188            s.push_str(mf);
189        }
190        s.push_str(&format!("E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs()));
191        if s.chars().count() <= max_chars {
192            return Some(s);
193        }
194    }
195    None
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn rounding() {
204        assert_eq!(fixed(2.675, 2), ("2".into(), "68".into()));
205        assert_eq!(fixed(1.005, 2), ("1".into(), "01".into()));
206        assert_eq!(fixed(0.5, 0), ("1".into(), "".into()));
207        assert_eq!(fixed(0.04, 1), ("".into(), "0".into()));
208        assert_eq!(fixed(0.05, 1), ("".into(), "1".into()));
209        assert_eq!(fixed(9.999, 2), ("10".into(), "00".into()));
210        assert_eq!(fixed(0.0004, 3), ("".into(), "000".into()));
211        assert_eq!(fixed(0.0005, 3), ("".into(), "001".into()));
212        assert_eq!(fixed(1e20, 0).0, "100000000000000000000");
213        assert_eq!(fixed(0.0, 2), ("".into(), "00".into()));
214    }
215
216    #[test]
217    fn general_fit() {
218        assert_eq!(format_general_fit(1e11, 11).as_deref(), Some("1E+11"));
219        assert_eq!(format_general_fit(12345678901.0, 11).as_deref(), Some("12345678901"));
220        // A huge width neither allocates width-sized padding nor changes the text.
221        assert_eq!(format_general_fit(1.5, usize::MAX).as_deref(), Some("1.5"));
222        assert_eq!(format_general_fit(123456789012.0, 11).as_deref(), Some("1.23457E+11"));
223        assert_eq!(format_general_fit(0.1 + 0.2, 11).as_deref(), Some("0.3"));
224        assert_eq!(format_general_fit(1.0 / 3.0, 11).as_deref(), Some("0.333333333"));
225        assert_eq!(format_general_fit(123456.789012, 11).as_deref(), Some("123456.789"));
226        assert_eq!(format_general_fit(1e-10, 11).as_deref(), Some("1E-10"));
227        assert_eq!(format_general_fit(0.0000123456789, 11).as_deref(), Some("1.23457E-05"));
228        assert_eq!(format_general_fit(0.0001, 11).as_deref(), Some("0.0001"));
229        assert_eq!(format_general_fit(-5.0, 11).as_deref(), Some("-5"));
230        assert_eq!(format_general_fit(0.0, 11).as_deref(), Some("0"));
231        assert_eq!(format_general_fit(1234.5678, 6).as_deref(), Some("1234.6"));
232        assert_eq!(format_general_fit(1234.5678, 4).as_deref(), Some("1235"));
233        assert_eq!(format_general_fit(123456.0, 5).as_deref(), Some("1E+05"));
234        assert_eq!(format_general_fit(123456.0, 3), None);
235        assert_eq!(format_general_fit(9.99999999999, 5).as_deref(), Some("10"));
236        assert_eq!(format_general_fit(f64::NAN, 11), None);
237        assert_eq!(format_general_fit(1.7976931348623157e308, 11).as_deref(), Some("1.7977E+308"));
238    }
239}