//! Rendering a value with a parsed section. use gridcraft_core::DateSystem; use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit}; use crate::parse::{DatePart, Section, Tok}; /// Text being built plus the first repeat-fill position. #[derive(Default)] pub(crate) struct Out { pub text: String, pub fill: Option<(char, usize)>, /// True when every digit shown is zero (a number section that rounded to zero). pub zero: bool, } impl Out { fn push(&mut self, s: &str) { self.text.push_str(s); } fn fill(&mut self, c: char) { if self.fill.is_none() { self.fill = Some((c, self.text.len())); } } pub(crate) fn prepend(&mut self, s: &str) { self.text.insert_str(0, s); if let Some((_, p)) = &mut self.fill { *p += s.len(); } } } /// Renders a text value through a text section (`@` is replaced by the text). pub(crate) fn render_text(sec: &Section, text: &str) -> Out { let mut out = Out::default(); for t in &sec.toks { match t { Tok::At => out.push(text), Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } } out } /// Renders a number through a General section (literals around the General keyword). pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { let mut out = Out::default(); for t in &sec.toks { match t { Tok::General => out.push(&format_general_fit(v, width).unwrap_or_else(|| "#".repeat(width.clamp(1, 11)))), Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } } out } /// Renders a date/time. `None` when the serial is outside the supported range. pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) { return None; } let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); let unit: i64 = 10i64.pow(3 - sub_digits as u32); let mut day = v.floor() as i64; let mut ms = (((v - v.floor()) * 86_400_000.0) / unit as f64).round() as i64 * unit; if ms >= 86_400_000 { ms -= 86_400_000; day += 1; } let total_ms = day.saturating_mul(86_400_000).saturating_add(ms); let needs_date = sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar())); let dt = if needs_date { datetime_from_serial(sys, day as f64)? } else { datetime_from_serial(sys, 0.0).unwrap_or_default() }; let ampm = sec.toks.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..)))); let hour = ms / 3_600_000; let minute = (ms / 60_000) % 60; let second = (ms / 1000) % 60; let milli = ms % 1000; let pad = |n: i64, w: u8| if w >= 2 { format!("{:0w$}", n, w = w.min(20) as usize) } else { n.to_string() }; let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or(""); let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or(""); let mut out = Out::default(); for t in &sec.toks { match t { Tok::Date(d) => { let s = match *d { DatePart::Year(n) => { if n <= 2 { format!("{:02}", dt.year.rem_euclid(100)) } else { format!("{:04}", dt.year) } } DatePart::Month(n) => match n { 1 | 2 => pad(dt.month as i64, n), 3 => month_name.chars().take(3).collect(), 5 => month_name.chars().take(1).collect(), _ => month_name.to_string(), }, DatePart::Day(n) => match n { 1 | 2 => pad(dt.day as i64, n), 3 => day_name.chars().take(3).collect(), _ => day_name.to_string(), }, DatePart::Hour(n) => { let h = if ampm { let h = hour % 12; if h == 0 { 12 } else { h } } else { hour }; pad(h, n) } DatePart::Minute(n) => pad(minute, n), DatePart::Second(n) => pad(second, n), DatePart::SubSec(n) => { let mut s = format!(".{:03}", milli); s.truncate(1 + (n as usize).min(3)); if n > 3 { s.push_str(&"0".repeat(n as usize - 3)); } s } DatePart::ElapsedH(n) => pad(total_ms / 3_600_000, n), DatePart::ElapsedM(n) => pad(total_ms / 60_000, n), DatePart::ElapsedS(n) => pad(total_ms / 1000, n), DatePart::AmPm => (if hour >= 12 { "PM" } else { "AM" }).to_string(), DatePart::AP(a, p) => (if hour >= 12 { p } else { a }).to_string(), }; out.push(&s); } Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } } Some(out) } /// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder /// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no /// digits. A blank `?` pads with a space, and so does the thousands separator it would carry. fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) { let ds: Vec = digits.chars().collect(); let mut rem = ds.len(); let mut pos = 0usize; let count = idxs.len(); for (k, &ti) in idxs.iter().rev().enumerate() { let leftmost = k + 1 == count; let ph = match toks.get(ti) { Some(Tok::Digit(c)) => *c, _ => '0', }; let mut rev = String::new(); let push_digit = |c: char, rev: &mut String, pos: &mut usize| { if thousands && *pos > 0 && pos.is_multiple_of(3) { rev.push(','); } rev.push(c); *pos += 1; }; let take = if leftmost { rem } else { rem.min(1) }; if take == 0 { match ph { '0' => push_digit('0', &mut rev, &mut pos), _ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos), '?' => { if thousands && pos > 0 && pos.is_multiple_of(3) { rev.push(' '); } rev.push(' '); pos += 1; } _ => {} } } else { for _ in 0..take { rem -= 1; push_digit(ds.get(rem).copied().unwrap_or('0'), &mut rev, &mut pos); } } if let Some(p) = pieces.get_mut(ti) { *p = rev.chars().rev().collect(); } } } fn with_commas(digits: &str, thousands: bool) -> String { if !thousands { return digits.to_string(); } let n = digits.chars().count(); let mut s = String::with_capacity(n + n / 3); for (i, c) in digits.chars().enumerate() { if i > 0 && (n - i).is_multiple_of(3) { s.push(','); } s.push(c); } s } /// Fraction-digit placeholders: trailing zeros show as `0`, nothing (`#`) or a space (`?`). fn fill_frac(toks: &[Tok], idxs: &[usize], frac: &str, pieces: &mut [String]) { let fd: Vec = frac.chars().collect(); let last_sig = fd.iter().rposition(|&c| c != '0'); for (i, &ti) in idxs.iter().enumerate() { let ph = match toks.get(ti) { Some(Tok::Digit(c)) => *c, _ => '0', }; let s = if last_sig.is_some_and(|l| i <= l) { fd.get(i).copied().unwrap_or('0').to_string() } else { match ph { '0' => "0".into(), '?' => " ".into(), _ => String::new(), } }; if let Some(p) = pieces.get_mut(ti) { *p = s; } } } fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { range.filter(|&i| matches!(toks.get(i), Some(Tok::Digit(_)))).collect() } /// Renders a non-negative number through a number section. pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut x = v.abs(); // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as // `1%e+5`). if sec.percent > 0 && !sec.exp { x *= 100f64.powi(sec.percent.min(20)); } if sec.scale > 0 { x /= 1000f64.powi(sec.scale.min(110)); } if !x.is_finite() { x = f64::MAX; } let toks = &sec.toks; let n = toks.len(); // Pieces replace the token's own output; `before` is inserted ahead of a token; `lits` // replaces a literal's text (fractions blank the literals of a hidden part). let mut pieces: Vec = vec![String::new(); n]; let mut before: Vec = vec![String::new(); n + 1]; let mut lits: Vec> = Vec::new(); let mut zero = false; if sec.fraction { lits = vec![None; n]; render_fraction(sec, x, &mut pieces, &mut lits); } else { let point = toks.iter().position(|t| matches!(t, Tok::Point)); let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. })); let int_end = point.or(exp).unwrap_or(n); let frac_end = exp.unwrap_or(n); let int_idx = digit_indices(toks, 0..int_end); let frac_idx = match point { Some(p) => digit_indices(toks, p + 1..frac_end), None => Vec::new(), }; let (int_s, frac_s) = if let Some(e_at) = exp { let exp_idx = digit_indices(toks, e_at + 1..n); let step = int_idx.len().max(1) as i32; let (mut e, int_s, frac_s) = if x == 0.0 { // Observed: zero shows `0` in every integer placeholder, `#` and `?` too // (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`). (0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len())) } else { let (digits, xe) = decimal_digits(x); let mut e = xe - xe.rem_euclid(step); let (mut i, mut f) = fixed_from_digits(&digits, xe - e, frac_idx.len()); if i.len() > step as usize { e += step; (i, f) = fixed_from_digits(&digits, xe - e, frac_idx.len()); } (e, i, f) }; // Exponent text. let (upper, plus) = match toks.get(e_at) { Some(Tok::Exp { upper, plus }) => (*upper, *plus), _ => (true, true), }; if int_s.is_empty() && frac_s.chars().all(|c| c == '0') { e = 0; } let head = if upper { "E" } else { "e" }; let sign = if e < 0 { "-" } else if plus { "+" } else { "" }; let digits = e.unsigned_abs().to_string(); // The sign sits right before the exponent digits, after any literal that follows the // `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces. match exp_idx.first() { Some(&first) => { if let Some(p) = pieces.get_mut(e_at) { *p = head.to_string(); } if let Some(b) = before.get_mut(first) { *b = sign.to_string(); } fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { *p = format!("{head}{sign}{digits}"); } } } (int_s, frac_s) } else { fixed(x, frac_idx.len()) }; zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() && let Some(p) = point.or(exp) && let Some(b) = before.get_mut(p) { *b = with_commas(&int_s, sec.thousands); } fill_frac(toks, &frac_idx, &frac_s, &mut pieces); if let Some(p) = point && let Some(s) = pieces.get_mut(p) { *s = ".".into(); } } let mut out = Out::default(); for (i, t) in toks.iter().enumerate() { if let Some(b) = before.get(i) { out.push(b); } match t { Tok::Lit(s) => out.push(lits.get(i).and_then(Option::as_deref).unwrap_or(s)), Tok::Percent => out.push("%"), Tok::Fill(c) => out.fill(*c), Tok::At | Tok::General | Tok::Date(_) | Tok::Comma => {} _ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")), } } out.zero = zero; out } /// Best rational approximation of `x` with denominator at most `max_den` (continued fractions). fn approximate(x: f64, max_den: u64) -> (u64, u64) { if x.is_nan() || x <= 0.0 || !x.is_finite() { return (0, 1); } if x >= 1e15 { return (x.round() as u64, 1); } let (mut p0, mut q0, mut p1, mut q1) = (0u64, 1u64, 1u64, 0u64); let mut r = x; for _ in 0..64 { let a_f = r.floor(); if a_f > 1e15 { break; } let a = a_f as u64; let Some(q2) = a.checked_mul(q1).and_then(|v| v.checked_add(q0)) else { break }; if q2 > max_den { break; } let Some(p2) = a.checked_mul(p1).and_then(|v| v.checked_add(p0)) else { break }; (p0, q0, p1, q1) = (p1, q1, p2, q2); let frac = r - a_f; if frac < 1e-12 { break; } r = 1.0 / frac; } if q1 == 0 { return (x.round() as u64, 1); } // Semiconvergent bound. let k = (max_den - q0) / q1; let (pb, qb) = (p0.saturating_add(k.saturating_mul(p1)), q0.saturating_add(k.saturating_mul(q1))); let err_a = (p1 as f64 / q1 as f64 - x).abs(); let err_b = if qb > 0 { (pb as f64 / qb as f64 - x).abs() } else { f64::INFINITY }; if err_b < err_a { (pb, qb) } else { (p1, q1) } } /// Display width of a token in a fraction section, for blanking a hidden part with spaces. fn shown_width(t: &Tok) -> usize { match t { Tok::Lit(s) | Tok::FixedDenom(s) => s.chars().count(), Tok::Digit(_) | Tok::Slash => 1, _ => 0, } } /// Replaces the literals in `range` with spaces (`spaces`) or nothing; placeholders and the bar /// in it get the same treatment through `pieces`. fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, pieces: &mut [String], lits: &mut [Option]) { for i in range { let Some(t) = toks.get(i) else { break }; let s = if spaces { " ".repeat(shown_width(t)) } else { String::new() }; match t { Tok::Lit(_) => { if let Some(l) = lits.get_mut(i) { *l = Some(s); } } Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => { if let Some(p) = pieces.get_mut(i) { *p = s; } } _ => {} } } } /// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of /// placeholders ending at the last one before the bar, the whole part is every placeholder /// before it (literals between them stay), `sep` the literals between the two parts. /// /// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or /// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and /// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear /// (or become spaces when any part has a `?`) and the whole part shows at least `0`. fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option]) { let toks = &sec.toks; let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_))); let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return }; let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return }; let mut num_start = num_last; while num_start > 0 && is_digit(num_start - 1) { num_start -= 1; } let whole_idx = digit_indices(toks, 0..num_start); let num_idx = digit_indices(toks, num_start..num_last + 1); let sep_start = whole_idx.last().map_or(num_start, |&w| w + 1); let (fixed_den, den_start): (Option, usize) = match toks.get(slash + 1) { Some(Tok::FixedDenom(s)) => (Some(s.parse::().unwrap_or(u64::MAX).clamp(1, 1_000_000_000)), slash + 1), _ => { let mut i = slash + 1; while matches!(toks.get(i), Some(Tok::Lit(_))) { i += 1; } (None, i) } }; let mut den_end = den_start; if fixed_den.is_some() { den_end += 1; } else { while is_digit(den_end) { den_end += 1; } } let den_idx = digit_indices(toks, den_start..den_end); let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?'))); let has_whole = !whole_idx.is_empty(); let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) }; let (mut num, den) = match fixed_den { Some(d) => ((f * d as f64).round() as u64, d), None => { let digits = den_idx.len().clamp(1, 7) as u32; approximate(f, 10u64.pow(digits) - 1) } }; if has_whole && num >= den && den > 0 { whole += (num / den) as f64; num %= den; } let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() }; let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0'))); if has_whole && num == 0 && !num_zero_ph { fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces); let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx); blank_range(toks, sep_start..den_end, spaces, pieces, lits); return; } if has_whole { // A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one. let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#'))); let force = whole_s.is_empty() && num == 0 && !units_hash; fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces); let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit()))); if !whole_shown { let spaces = has_q(&whole_idx) || has_q(&num_idx); blank_range(toks, sep_start..num_start, spaces, pieces, lits); } } fill_int(toks, &num_idx, &num.to_string(), false, true, pieces); if let Some(p) = pieces.get_mut(slash) { *p = "/".into(); } let den_s = den.to_string(); if fixed_den.is_some() { if let Some(p) = pieces.get_mut(den_start) { *p = den_s; } } else { // Denominator digits are zero padded to the `0` placeholders, then left aligned; `?` // pads with spaces after them. let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count(); // No `{:0>zeros$}`: a format width above u16::MAX panics. let mut padded = "0".repeat(zeros.saturating_sub(den_s.chars().count())); padded.push_str(&den_s); let dc: Vec = padded.chars().collect(); let count = den_idx.len(); for (k, &ti) in den_idx.iter().enumerate() { let s = if k + 1 == count { dc.get(k..).map(|r| r.iter().collect::()).unwrap_or_default() } else { dc.get(k).map(|c| c.to_string()).unwrap_or_default() }; let s = if s.is_empty() { match toks.get(ti) { Some(Tok::Digit('?')) => " ".into(), Some(Tok::Digit('0')) => "0".into(), _ => String::new(), } } else { s }; if let Some(p) = pieces.get_mut(ti) { *p = s; } } } } #[cfg(test)] mod tests { use super::*; #[test] fn approximations() { assert_eq!(approximate(0.5, 9), (1, 2)); assert_eq!(approximate(0.333333, 9), (1, 3)); assert_eq!(approximate(std::f64::consts::PI - 3.0, 99), (14, 99)); assert_eq!(approximate(std::f64::consts::PI - 3.0, 9), (1, 7)); assert_eq!(approximate(0.0, 9), (0, 1)); assert_eq!(approximate(0.999, 9), (1, 1)); } }