Files
gridcraft/crates/numfmt/src/render.rs
T
c35234e5f5 Number formats: honour the B1/B2 calendar prefixes (Hijri dates for B2) (#1918)
* M12.AR6: honour the B1/B2 calendar prefixes in number formats (Hijri for B2)

* M12.AR6: match Excel's B1/B2 behaviour measured in review

Changes from a reviewer's Excel run (TEXT and Range.NumberFormat):
- A [$-...] locale tag before B2 keeps the date Gregorian (prefix still
  hidden); B2[$-401] stays Hijri.
- Any other unquoted B and digit (B3dd, dd/mm/yyyyB2, dd B2 mm), and B2
  before a number format (B20.00), make the code invalid: TEXT gives
  #VALUE!, a cell shows General, as for a code over 255 characters.
- Under B2, ddd and dddd show the Arabic weekday name (full name for both).
- Serial 60 has no Hijri date: TEXT gives #VALUE!, a cell shows ####.
- Serial 0 shows as serial 1 (29/08/1317).

Also fixes a break from merging main: days_from_civil now returns 0 for
day 0 and 29 February 1900, which sent serials 0 and 60 to 1970
(23/10/1389) under B2. The Hijri day count no longer passes those dates.

Docs: localization-parity states the behaviour, including the one
measured case not matched (mmmm next to dddd prints the weekday in Excel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: Hijri leap year in cycle year 15, not 16, as Excel shows

A reviewer compared Excel's B2yyyy-mm-dd on 43,512 serials with this
formula: it disagreed by one day on 1,372, all in cycle years 15 and 16,
and moving the leap year from 16 to 15 left no disagreement. The leap
years are now 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 (still 10,631
days a cycle).

The count of leap years before a year becomes (4 + 11y) div 30; it
differs from the old (3 + 11y) div 30 only when y = 16 (mod 30), which is
exactly the move from 16 to 15. The inverse's year estimate can now be one
year off at a boundary and is corrected by one step.

Tests: Excel values for 2 March 2005 (1426-01-21) and 29 January 2006
(1426-12-29) plus the 1425/1426 year boundaries they imply, the reviewer's
other anchors, and exhaustive checks: year, month and 30-year cycle
lengths for Hijri years 1 to 10,000; day -> Hijri -> day round trip and
day-by-day succession for every day from 1 Muharram 1 AH to one cycle past
31 December 9999; every 1900-system serial 1 to 2,958,465 (except 60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: match B1/B2 details checked in Excel 16.0 build 20527

Compared GridCraft with Excel 16.0 (build 20527, en-US) through COM: TEXT
with B2dd/mm/yyyy on every serial from 1 to 2,958,465 (no difference), and
about 1,750 format/value pairs through both a cell's NumberFormat and TEXT.
Fixed what differed and is in scope:

- Two-word Hijri month names use a no-break space (U+00A0), as Excel does.
- B1 before a number format is rejected like B2 (B10.00, B1#,##0).
- Prefix runs: a repeated prefix is accepted (B2B2, B2b2, [Red]B2B2, B1B1);
  a mixed one (B1B2, B2B1), more digits (B12, B21, B02), a prefix after a
  tag or space (B2[Red]B2, B2 B2) and an escaped \B2 are rejected.
- Serial 60 under B2 is #VALUE! for any date or clock part (hh:mm, ss,
  AM/PM), not only date parts; [h] and literal-only sections still show.

Regression tests use the Excel values (all 12 month and 7 weekday names,
the accepted and rejected codes, serial 60). Not changed, listed in the
docs: Excel's weekday/month-name mix-ups when ddd(d) and mmm(m) share a B2
section, weekday-only B2 sections (real weekday for serials 0-59, #VALUE!
for the last days of 9999), and Buddhist-year b/bb/bbbb tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:54:05 +01:00

665 lines
26 KiB
Rust

//! Rendering a value with a parsed section.
use gridcraft_core::DateSystem;
use gridcraft_core::date::{datetime_from_serial, max_serial};
use crate::calendar::Calendar;
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);
// Under `B2`, Excel shows the 1900 system's fictitious 29 February (serial 60) as `#VALUE!`
// whenever a clock or calendar part is shown, even for `hh:mm` (`[h]` alone still works).
if sec.calendar == Calendar::Hijri
&& sys == DateSystem::D1900
&& day == 60
&& sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if !d.is_elapsed()))
{
return None;
}
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() };
// No date in the section's calendar (serial 60 under `B2`): shown like an out-of-range date.
let date = sec.calendar.date(&dt)?;
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}", date.year.rem_euclid(100))
} else {
format!("{:04}", date.year)
}
}
DatePart::Month(n) => match n {
1 | 2 => pad(date.month as i64, n),
3 => date.month_short.to_string(),
5 => date.month_name.chars().take(1).collect(),
_ => date.month_name.to_string(),
},
DatePart::Day(n) => match n {
1 | 2 => pad(date.day as i64, n),
3 => date.day_short.to_string(),
_ => date.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));
}
}