diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index d471f3b..674e844 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -295,7 +295,8 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted } /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is -/// converted to a number first; other text goes through the text section (or is returned as is). +/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text +/// section, or are returned as is. pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result { if code.is_empty() { return Ok(String::new()); @@ -305,7 +306,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, Value::Number(n) => n, - Value::Bool(b) => return Ok(if b { "TRUE" } else { "FALSE" }.into()), + Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { Some(n) => n, None => return Ok(fmt.format_text(&t).text), diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index 8b94937..e54f04c 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -555,7 +555,9 @@ fn analyze(raw: Raw) -> Section { } } Tok::Slash => { - let next_ok = matches!(toks.get(i + 1), Some(Tok::Digit(_) | Tok::FixedDenom(_))); + // Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`). + let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_))) + || matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_))); let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. })); if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp { fraction_slash = true; diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 79fe490..7befe59 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -138,7 +138,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option } /// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder -/// takes all remaining digits. +/// 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(); @@ -162,8 +163,14 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z 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(' '), + _ 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 { @@ -235,11 +242,14 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { } let toks = &sec.toks; let n = toks.len(); - // Pieces replace the token's own output; `before` is inserted ahead of a token. + // 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(); if sec.fraction { - render_fraction(sec, x, &mut pieces); + 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 { .. })); @@ -321,7 +331,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { out.push(b); } match t { - Tok::Lit(s) => out.push(s), + 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 => {} @@ -370,29 +380,77 @@ fn approximate(x: f64, max_den: u64) -> (u64, u64) { if err_b < err_a { (pb, qb) } else { (p1, q1) } } -fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) { +/// 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 mut num_start = slash; - while num_start > 0 && matches!(toks.get(num_start - 1), Some(Tok::Digit(_))) { + 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..slash); - let mut den_end = slash + 1; - let fixed_den: Option = match toks.get(slash + 1) { - Some(Tok::FixedDenom(s)) => { - den_end += 1; - Some(s.parse::().unwrap_or(u64::MAX).clamp(1, 1_000_000_000)) + 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) } - _ => None, }; - if fixed_den.is_none() { - while matches!(toks.get(den_end), Some(Tok::Digit(_))) { + 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, slash + 1..den_end); + 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 { @@ -407,39 +465,39 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) { 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. + 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; } - fill_int(toks, &num_idx, &num_s, false, true, pieces); + 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(slash + 1) { + if let Some(p) = pieces.get_mut(den_start) { *p = den_s; } } else { - // Denominator digits are left aligned; `?` pads with spaces after them. - let dc: Vec = den_s.chars().collect(); + // 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(); + let padded = format!("{den_s:0>zeros$}"); + 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 { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 0742b26..6c2306c 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -122,6 +122,82 @@ fn fractions() { assert_eq!(f("# ???/???", 0.5), " 1/2 "); } +/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results). +#[test] +fn fractions_with_literals() { + let cases: &[(&str, f64, &str)] = &[ + // Literals between the parts and around the bar. + (r"|#\:#/#|", 23.75, "|23:3/4|"), + (r"|#\:#=/=#|", 23.75, "|23:3=/=4|"), + (r"|#\:#=/=#|", -23.75, "-|23:3=/=4|"), + (r"|#\:? ?=/=#|", 23.75, "|2:3 3=/=4|"), + (r"|#-#-#\:#/#|", 23.75, "|-2-3:3/4|"), + // A whole part without digits drops its separator (spaces next to a `?`). + (r"|#\:#/#|", 0.75, "|3/4|"), + (r"|#\:#/#|", -0.75, "-|3/4|"), + (r"|#-#-#\:#/#|", 0.75, "|--3/4|"), + (r"|#\:#=/=#|", 0.75, "|3=/=4|"), + (r"|#\:0=/=?|", 0.75, "|3=/=4|"), + (r"|#\:?=/=#|", 0.75, "| 3=/=4|"), + (r"|?\:#=/=#|", 0.75, "| 3=/=4|"), + (r"|?\:0=/=0|", -0.75, "-| 3=/=4|"), + (r"|?\:?0=/=#|", 0.75, "| 3=/=4|"), + (r"|0\:#=/=#|", 0.75, "|0:3=/=4|"), + // A zero fraction hides separator, numerator, bar and denominator (spaces if any `?`). + (r"|#\:#/#|", 0.0, "|0|"), + (r"|#\:#/#|", 1.0, "|1|"), + (r"|#\:#/#|", -1.0, "-|1|"), + (r"|#-#-#\:#/#|", 0.0, "|--0|"), + (r"|#-#-#\:#/#|", 1.0, "|--1|"), + (r"|#\:#=/=0|", 0.0, "|0|"), + (r"|0\:#=/=#|", 1.0, "|1|"), + (r"|#\:#=/=?|", 0.0, "|0 |"), + (r"|#\:?=/=0|", 1.0, "|1 |"), + (r"|?\:#=/=#|", 0.0, "|0 |"), + (r"|?\:#=/=#|", -1.0, "-|1 |"), + (r"|#\:? ?=/=#|", 0.0, "|:0 |"), + (r"|#\:? ?=/=?|", 1.0, "|:1 |"), + // ... unless the numerator has a `0` placeholder: then it shows 0/1. + (r"|#\:0=/=#|", 0.0, "|0=/=1|"), + (r"|?\:0=/=?|", 0.0, "|0:0=/=1|"), + (r"|0\:0=/=0|", 0.0, "|0:0=/=1|"), + (r"|#\:0=/=#|", 1.0, "|1:0=/=1|"), + (r"|?\:0=/=#|", -1.0, "-|1:0=/=1|"), + (r"|#\:? ?0#/000", 0.0, "|:0 00/001"), + (r"|#\:? ?0#/000", 1.0, "|:1 00/001"), + // `0` denominator placeholders zero pad on the left. + (r"|#\:? ?0#/000", 23.75, "|2:3 03/004"), + (r"|#\:? ?#/#|", 23.75, "|2:3 3/4|"), + // Literal text before the bar: still an improper fraction. + (r"|#_#/#|", 3.75, "|15 /4|"), + (r"|#_#/#|", -0.75, "-|3 /4|"), + (r"|#_#/#|", 0.0, "|0 /1|"), + (r"|#_#/#|", -1.0, "-|1 /1|"), + (r"|#_#=/=?|", 3.75, "|15 =/=4|"), + (r"|#_?=/=0|", 0.0, "|0 =/=1|"), + (r"|#_?=/=?|", 1.0, "|1 =/=1|"), + ]; + for &(code, v, want) in cases { + assert_eq!(f(code, v), want, "{code} with {v}"); + } +} + +#[test] +fn question_mark_pads_thousands_separator() { + let cases: &[(&str, f64, &str)] = &[ + ("?,???????", 1234567.0, " 1,234,567"), + ("?,????????", 1234567.0, " 1,234,567"), + ("?,?????????", 1234567.0, " 1,234,567"), + ("?,?????????", -1234567.0, "- 1,234,567"), + ("?,??????", 1234567.0, "1,234,567"), + ("?,????", 12345.0, "12,345"), + ("0,000000000", 1234567.0, "0,001,234,567"), + ]; + for &(code, v, want) in cases { + assert_eq!(f(code, v), want, "{code} with {v}"); + } +} + #[test] fn literals_and_fill() { assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00"); @@ -264,6 +340,21 @@ fn text_fn() { assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into())); assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); + // Booleans go through the text section as TRUE/FALSE (observed Excel results). + let cases: &[(bool, &str, &str)] = &[ + (true, ";;;@", "TRUE"), + (true, ";;;\"hi\"", "hi"), + (true, ";;;\"h\"\"I\"", "hI"), + (false, ";;;\\h\\i", "hi"), + (true, ";;;\\@@\\@", "@TRUE@"), + (false, ";;;-@-", "-FALSE-"), + (true, ";;;@-", "TRUE-"), + (false, ";;;-@-@-", "-FALSE-FALSE-"), + (true, "0.00", "TRUE"), + ]; + for &(b, code, want) in cases { + assert_eq!(text_function(&Value::Bool(b), code, S), Ok(want.into()), "{code} with {b}"); + } } /// Deterministic xorshift for fuzzing. diff --git a/docs/function-parity.md b/docs/function-parity.md index 901507e..89526b3 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -95,6 +95,12 @@ reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past 10-09). Statistical distributions and financial functions with iterative solvers (RATE, IRR, XIRR, YIELD) are the highest-risk for last-digit differences. +TEXT() and cell display share `crates/numfmt`; checked against Excel-cached results in +number- and text-format test workbooks (352 TEXT cells), all match: +fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero-padded +denominators, `?` placeholders padding the thousands separator, and booleans through the text +section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`). + Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above), an oracle corpus of Excel-computed results per function (owner runs Excel on generated workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h). @@ -103,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working | | 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working | | 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working |