mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
* ft-121.7.9: apply Set precision as displayed in calculation (#1343) With the option on, a formula result (and each element of a spill) is rounded to its cell's number format before it is stored and before other formulas read it. Numbers typed, pasted or reformatted while it is on are rounded at once, and turning it on through formulas.calculationOptions {precisionAsDisplayed} rounds every number constant, as Excel does. The rounding is NumberFormat::round_as_displayed in the numfmt crate, which shares the renderer's digit logic (fixed, scientific, fraction). General, date/time, text, digitless and conditional formats keep the full value, as observed in Excel through COM. Nothing changes when the flag is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-121.7.9: round conditional formats by the sign's section under precision as displayed (#1343) Excel does round formats with a condition when "Set precision as displayed" is on; the PR kept them at full value. Observed through COM with synthetic values (and in gridcraft-corpus excel/precision-displayed/pad-on-conditional-sections.xlsx): conditions are ignored and the sign picks the section, the first for positives and the second for negatives. `[<1]0.00;0` shows 2+1/3 as 2 but stores 2.33, and stores -10/3 as -3. Two more observed details: with a condition and a single number section, negatives keep the full value (`[<1]0.00`), unless an `@` section follows (`[<1]0.00;@` rounds them by the first section); and a completely empty section passes to the next one (`0.0;;0.000` stores -2.333 as -2.333, `;0.0` stores 2.333 as 2.3), for formats with or without a condition. Display is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * engine: recalculate spill anchors when a spill cell is reformatted under precision as displayed round_edited_as_displayed only looked at stored-cell changes, but a spill destination lives in Sheet::spill, so reformatting it (or its row or column) left the anchor out of the changed set and the element unrounded. Add each spill anchor whose range holds a changed cell or a changed row/column style to the changed set. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * numfmt: drop a cast the fraction merge made redundant * precision as displayed: keep locked constants on protected sheets, round new sheets, fix manual-mode comment * precision as displayed: name the input sheet in the new-sheet histogram test * precision as displayed: use six values in the new-sheet histogram test --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
655 lines
25 KiB
Rust
655 lines
25 KiB
Rust
//! 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<Out> {
|
|
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<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 && 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<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()
|
|
}
|
|
|
|
/// Where a number section's placeholders are: the decimal point, the exponent, and the digit
|
|
/// placeholders before the point (or exponent) and between the point and the exponent.
|
|
struct NumberLayout {
|
|
point: Option<usize>,
|
|
exp: Option<usize>,
|
|
int_idx: Vec<usize>,
|
|
frac_idx: Vec<usize>,
|
|
}
|
|
|
|
fn number_layout(toks: &[Tok]) -> NumberLayout {
|
|
let n = toks.len();
|
|
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(),
|
|
};
|
|
NumberLayout { point, exp, int_idx, frac_idx }
|
|
}
|
|
|
|
/// The number a section works on: each `%` multiplies by 100, each scaling comma divides by 1000.
|
|
fn scaled(sec: &Section, mut x: f64) -> f64 {
|
|
// 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));
|
|
}
|
|
x
|
|
}
|
|
|
|
/// Scientific notation of a non-negative `x` with `int_count` integer and `frac_count` fraction
|
|
/// placeholders: the exponent (a multiple of the integer placeholder count, engineering style)
|
|
/// and the mantissa's integer and fraction digits.
|
|
fn scientific_parts(x: f64, int_count: usize, frac_count: usize) -> (i32, String, String) {
|
|
if x == 0.0 {
|
|
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
|
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
|
return (0, "0".repeat(int_count), "0".repeat(frac_count));
|
|
}
|
|
let step = int_count.max(1) as i32;
|
|
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_count);
|
|
if i.len() > step as usize {
|
|
e += step;
|
|
(i, f) = fixed_from_digits(&digits, xe - e, frac_count);
|
|
}
|
|
(e, i, f)
|
|
}
|
|
|
|
/// The number a number section shows for a non-negative `v`, as a value: what "Set precision as
|
|
/// displayed" stores. `None` when the section shows no digits (`"x"`) or the result isn't finite.
|
|
/// It uses the display's own rounding, so 1.005 with `0.00` gives 1.01, and 0.123456 with
|
|
/// `0.00%` gives 0.1235 (the nearest double to the decimal shown).
|
|
pub(crate) fn shown_value(sec: &Section, v: f64) -> Option<f64> {
|
|
if !sec.has_digits || !v.is_finite() {
|
|
return None;
|
|
}
|
|
let x = scaled(sec, v.abs());
|
|
if !x.is_finite() {
|
|
return None;
|
|
}
|
|
if x == 0.0 {
|
|
return Some(0.0);
|
|
}
|
|
// `%` and scaling are undone by moving the decimal point in the digits shown, so the result
|
|
// is the nearest double to that decimal rather than a product with its own rounding error.
|
|
let pct = if sec.exp { 0 } else { sec.percent.min(20) };
|
|
let shift = 3 * sec.scale.clamp(0, 110) - 2 * pct;
|
|
let shown = if sec.fraction {
|
|
let lay = fraction_layout(&sec.toks)?;
|
|
let (whole, num, den) = fraction_parts(&lay, x);
|
|
let den = den.max(1) as f64;
|
|
(whole * den + num) / den * 10f64.powi(shift)
|
|
} else {
|
|
let NumberLayout { exp, frac_idx, .. } = number_layout(&sec.toks);
|
|
let (e, int_s, frac_s) = match exp {
|
|
// Observed: the stored value keeps one digit plus the fraction digits, whatever the
|
|
// integer placeholders (`##0.0E+0` stores 123456.789 as 120000 though it shows
|
|
// `123.5E+3`; `###.00E+0` keeps three digits). A `%` in a scientific section stores a
|
|
// value that matches neither the display nor this rule: left as is.
|
|
Some(_) if sec.percent > 0 => return None,
|
|
Some(_) => scientific_parts(x, 1, frac_idx.len()),
|
|
None => {
|
|
let (i, f) = fixed(x, frac_idx.len());
|
|
(0, i, f)
|
|
}
|
|
};
|
|
format!("0{int_s}.{frac_s}0e{}", i64::from(e) + i64::from(shift)).parse::<f64>().ok()?
|
|
};
|
|
shown.is_finite().then_some(shown)
|
|
}
|
|
|
|
/// Renders a non-negative number through a number section.
|
|
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
|
let mut x = scaled(sec, v.abs());
|
|
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<String> = vec![String::new(); n];
|
|
let mut before: Vec<String> = vec![String::new(); n + 1];
|
|
let mut lits: Vec<Option<String>> = Vec::new();
|
|
let mut zero = false;
|
|
if sec.fraction {
|
|
lits = vec![None; n];
|
|
render_fraction(sec, x, &mut pieces, &mut lits);
|
|
} else {
|
|
let NumberLayout { point, exp, int_idx, frac_idx } = number_layout(toks);
|
|
let (int_s, frac_s) = if let Some(e_at) = exp {
|
|
let exp_idx = digit_indices(toks, e_at + 1..n);
|
|
let (mut e, int_s, frac_s) = scientific_parts(x, int_idx.len(), frac_idx.len());
|
|
// 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<usize>, spaces: bool, pieces: &mut [String], lits: &mut [Option<String>]) {
|
|
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;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a fraction section's parts are (see [`render_fraction`]).
|
|
struct FractionLayout {
|
|
slash: usize,
|
|
num_start: usize,
|
|
whole_idx: Vec<usize>,
|
|
num_idx: Vec<usize>,
|
|
sep_start: usize,
|
|
fixed_den: Option<u64>,
|
|
den_start: usize,
|
|
den_end: usize,
|
|
den_idx: Vec<usize>,
|
|
}
|
|
|
|
fn fraction_layout(toks: &[Tok]) -> Option<FractionLayout> {
|
|
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
|
|
let slash = toks.iter().position(|t| matches!(t, Tok::Slash))?;
|
|
let num_last = (0..slash).rev().find(|&i| is_digit(i))?;
|
|
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<u64>, usize) = match toks.get(slash + 1) {
|
|
Some(Tok::FixedDenom(s)) => (Some(s.parse::<u64>().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);
|
|
Some(FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx })
|
|
}
|
|
|
|
/// The whole part, numerator and denominator a fraction section shows for a non-negative `x`.
|
|
fn fraction_parts(lay: &FractionLayout, x: f64) -> (f64, f64, u64) {
|
|
let has_whole = !lay.whole_idx.is_empty();
|
|
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
|
// Without a whole part the numerator carries the whole value and can pass u64::MAX, so it
|
|
// stays an f64 (a float-to-u64 cast saturates).
|
|
let (mut num, den): (f64, u64) = match lay.fixed_den {
|
|
Some(d) => ((f * d as f64).round(), d),
|
|
None if f >= 1e15 => (f.round(), 1),
|
|
None => {
|
|
let digits = lay.den_idx.len().clamp(1, 7) as u32;
|
|
let (n, d) = approximate(f, 10u64.pow(digits) - 1);
|
|
(n as f64, d)
|
|
}
|
|
};
|
|
if has_whole && num >= den as f64 && den > 0 {
|
|
// The fractional part is below 1, so the numerator is at most the denominator here.
|
|
let n = num as u64;
|
|
whole += (n / den) as f64;
|
|
num = (n % den) as f64;
|
|
}
|
|
(whole, num, den)
|
|
}
|
|
|
|
/// 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<String>]) {
|
|
let toks = &sec.toks;
|
|
let Some(lay) = fraction_layout(toks) else { return };
|
|
let (whole, num, den) = fraction_parts(&lay, x);
|
|
let FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx } = lay;
|
|
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
|
let has_whole = !whole_idx.is_empty();
|
|
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.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.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);
|
|
}
|
|
}
|
|
let num_s = if num < 1e15 { (num as u64).to_string() } else { fixed(num, 0).0 };
|
|
fill_int(toks, &num_idx, &num_s, 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<char> = 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::<String>()).unwrap_or_default()
|
|
} else {
|
|
dc.get(k).map(ToString::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));
|
|
}
|
|
}
|