mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 13:46:47 -06:00
Local clippy 1.96 flags pre-existing patterns the code has always had: manual range-contains comparisons, a dead `|| true` filter and a simplifiable boolean. None change behaviour; they only clear the lints so `cargo xtask ci` passes on a current toolchain.
480 lines
17 KiB
Rust
480 lines
17 KiB
Rust
//! Rendering a value with a parsed section.
|
|
|
|
use gridcraft_core::DateSystem;
|
|
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_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)>,
|
|
}
|
|
|
|
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<Out> {
|
|
if !v.is_finite() || !(0.0..2_958_466.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.
|
|
fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) {
|
|
let ds: Vec<char> = 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 && leftmost && pos == 0 => push_digit('0', &mut rev, &mut pos),
|
|
'?' => rev.push(' '),
|
|
_ => {}
|
|
}
|
|
} 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<char> = 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<usize>) -> Vec<usize> {
|
|
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();
|
|
if sec.percent > 0 {
|
|
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.
|
|
let mut pieces: Vec<String> = vec![String::new(); n];
|
|
let mut before: Vec<String> = vec![String::new(); n + 1];
|
|
if sec.fraction {
|
|
render_fraction(sec, x, &mut pieces);
|
|
} 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 {
|
|
(0i32, String::new(), "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 mut head = String::from(if upper { "E" } else { "e" });
|
|
if e < 0 {
|
|
head.push('-');
|
|
} else if plus {
|
|
head.push('+');
|
|
}
|
|
let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20);
|
|
let digits = format!("{:0w$}", e.unsigned_abs(), w = width);
|
|
match exp_idx.first() {
|
|
Some(&first) => {
|
|
if let Some(p) = pieces.get_mut(e_at) {
|
|
*p = head;
|
|
}
|
|
if let Some(p) = pieces.get_mut(first) {
|
|
*p = digits;
|
|
}
|
|
}
|
|
None => {
|
|
if let Some(p) = pieces.get_mut(e_at) {
|
|
*p = head + &digits;
|
|
}
|
|
}
|
|
}
|
|
(int_s, frac_s)
|
|
} else {
|
|
fixed(x, frac_idx.len())
|
|
};
|
|
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(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
|
|
}
|
|
|
|
/// 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) }
|
|
}
|
|
|
|
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
|
|
let toks = &sec.toks;
|
|
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
|
|
let mut num_start = slash;
|
|
while num_start > 0 && matches!(toks.get(num_start - 1), Some(Tok::Digit(_))) {
|
|
num_start -= 1;
|
|
}
|
|
let whole_idx = digit_indices(toks, 0..num_start);
|
|
let num_idx = digit_indices(toks, num_start..slash);
|
|
let mut den_end = slash + 1;
|
|
let fixed_den: Option<u64> = match toks.get(slash + 1) {
|
|
Some(Tok::FixedDenom(s)) => {
|
|
den_end += 1;
|
|
Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000))
|
|
}
|
|
_ => None,
|
|
};
|
|
if fixed_den.is_none() {
|
|
while matches!(toks.get(den_end), Some(Tok::Digit(_))) {
|
|
den_end += 1;
|
|
}
|
|
}
|
|
let den_idx = digit_indices(toks, slash + 1..den_end);
|
|
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 blank = has_whole && num == 0;
|
|
fill_int(toks, &whole_idx, &whole_s, sec.thousands, blank, pieces);
|
|
let num_s = num.to_string();
|
|
let den_s = den.to_string();
|
|
if blank {
|
|
let width = |idx: &[usize]| idx.len();
|
|
let nw = width(&num_idx).max(1);
|
|
if let Some(&first) = num_idx.first()
|
|
&& let Some(p) = pieces.get_mut(first)
|
|
{
|
|
*p = " ".repeat(nw);
|
|
}
|
|
if let Some(p) = pieces.get_mut(slash) {
|
|
*p = " ".into();
|
|
}
|
|
let dw = if fixed_den.is_some() { den_s.len() } else { den_idx.len() };
|
|
if let Some(p) = pieces.get_mut(slash + 1) {
|
|
*p = " ".repeat(dw);
|
|
}
|
|
// Literals between the whole part and the numerator stay as they are.
|
|
return;
|
|
}
|
|
fill_int(toks, &num_idx, &num_s, false, true, pieces);
|
|
if let Some(p) = pieces.get_mut(slash) {
|
|
*p = "/".into();
|
|
}
|
|
if fixed_den.is_some() {
|
|
if let Some(p) = pieces.get_mut(slash + 1) {
|
|
*p = den_s;
|
|
}
|
|
} else {
|
|
// Denominator digits are left aligned; `?` pads with spaces after them.
|
|
let dc: Vec<char> = den_s.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::<String>()).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));
|
|
}
|
|
}
|