Files
gridcraft/crates/numfmt/src/lib.rs
T
Harrison BoothandClaude Sonnet 5.5 292b784f02 fix(calc): apply "Set precision as displayed" in calculation (#1366)
* 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>
2026-11-05 22:24:49 +01:00

510 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GridCraft number formats: Excel format codes (`#,##0.00;[Red](#,##0.00)`, dates, fractions,
//! scientific…), the General display format and the `TEXT()` worksheet function. en-US only.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod builtin;
mod decimal;
mod parse;
mod render;
use std::sync::Arc;
use gridcraft_core::{CellError, DateSystem, Value};
pub use builtin::{builtin_format, builtin_id};
pub use decimal::format_general_fit;
use parse::{DatePart, SecKind, Section, Tok};
use render::Out;
/// Width (in characters) of General output in a cell.
const GENERAL_WIDTH: usize = 11;
/// What Excel paints when a date is out of range.
const OVERFLOW: &str = "########";
/// Colour from a `[Red]` or `[ColorN]` section tag.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FormatColor {
Black,
Blue,
Cyan,
Green,
Magenta,
Red,
White,
Yellow,
/// `[Color1]`..`[Color56]` (palette index).
Indexed(u8),
}
impl FormatColor {
/// RGB value used to render this number-format color.
pub fn rgb(self) -> Option<[u8; 3]> {
use FormatColor as F;
Some(match self {
F::Black => [0, 0, 0],
F::Blue => [0, 0, 255],
F::Cyan => [0, 255, 255],
F::Green => [0, 255, 0],
F::Magenta => [255, 0, 255],
F::Red => [255, 0, 0],
F::White => [255, 255, 255],
F::Yellow => [255, 255, 0],
F::Indexed(index) => return gridcraft_core::color::indexed_color(index),
})
}
}
/// Best guess at the ribbon's number-format category.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FormatKind {
General,
Number,
Currency,
Accounting,
Date,
Time,
Percentage,
Fraction,
Scientific,
Text,
/// Zip codes, phone numbers, social security numbers.
Special,
Custom,
}
/// A formatted value ready to paint.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct Formatted {
pub text: String,
pub color: Option<FormatColor>,
/// Repeat-fill char from `*x` and its byte position in `text`, for the renderer to expand to
/// the cell width.
pub fill: Option<(char, usize)>,
/// True when the result should be right-aligned by default (numbers); General text is left
/// aligned.
pub numeric: bool,
}
#[derive(Debug)]
struct Inner {
code: String,
general: bool,
sections: Vec<Section>,
/// Sections that apply to numbers (a trailing text section is excluded).
num_sections: usize,
/// Index of the section that applies to text, if any.
text_section: Option<usize>,
}
/// A parsed number format code. Cheap to clone.
#[derive(Clone, Debug)]
pub struct NumberFormat {
inner: Arc<Inner>,
}
impl PartialEq for NumberFormat {
fn eq(&self, other: &Self) -> bool {
self.inner.code == other.inner.code
}
}
impl Default for NumberFormat {
fn default() -> Self {
NumberFormat::general()
}
}
/// Longest number format code, in characters (not bytes), that is parsed: Excel's own limit.
/// A longer code is invalid: [`NumberFormat::parse`] returns a General format (the cell shows
/// its value unformatted; the stored code is kept as is) and `TEXT` returns `#VALUE!`. The cap
/// also bounds every allocation that scales with the number of placeholders in a code.
pub const MAX_FORMAT_CODE_CHARS: usize = 255;
/// True when `code` is longer than [`MAX_FORMAT_CODE_CHARS`] characters.
fn too_long(code: &str) -> bool {
// A code of more than 255 bytes can still be short in characters; count only when needed.
code.len() > MAX_FORMAT_CODE_CHARS && code.chars().count() > MAX_FORMAT_CODE_CHARS
}
impl NumberFormat {
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General.
pub fn parse(code: &str) -> NumberFormat {
Self::parse_inner(code, false)
}
/// `parse` without the length cap, so tests can reach renderer paths a capped code cannot.
#[cfg(test)]
fn parse_uncapped(code: &str) -> NumberFormat {
Self::parse_inner(code, true)
}
fn parse_inner(code: &str, uncapped: bool) -> NumberFormat {
#[cfg(test)]
parse_cache::count_parse();
let trimmed = code.trim();
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
// value displays as General.
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) {
return NumberFormat {
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
};
}
let sections = parse::parse_sections(code);
let len = sections.len();
let last_is_text = sections.last().is_some_and(|s| s.kind == SecKind::Text);
let text_section = if len >= 4 {
Some(3)
} else if last_is_text {
Some(len - 1)
} else {
None
};
let num_sections = if len >= 4 {
3
} else if last_is_text {
len - 1
} else {
len
};
NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: false, sections, num_sections, text_section }) }
}
pub fn general() -> NumberFormat {
NumberFormat::parse("General")
}
pub fn code(&self) -> &str {
&self.inner.code
}
pub fn is_general(&self) -> bool {
self.inner.general
}
fn num_sections(&self) -> &[Section] {
self.inner.sections.get(..self.inner.num_sections).unwrap_or(&[])
}
/// True when the code contains date or time tokens.
pub fn is_date(&self) -> bool {
self.num_sections().iter().any(|s| s.kind == SecKind::Date)
}
/// For a date or time format, whether the section that formats `value` shows calendar parts (year, month,
/// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for
/// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment.
pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> {
let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?;
let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None });
if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) {
return None;
}
Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar())))
}
pub fn is_percent(&self) -> bool {
self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0)
}
/// True for a pure text format such as `@`.
pub fn is_text(&self) -> bool {
self.inner.num_sections == 0 && self.inner.text_section.is_some()
}
/// Best guess at the format category for the ribbon's dropdown.
pub fn kind(&self) -> FormatKind {
if self.is_general() {
return FormatKind::General;
}
if self.is_text() {
return FormatKind::Text;
}
const SPECIAL: [&str; 4] = ["00000", "00000-0000", "[<=9999999]###-####;(###) ###-####", "000-00-0000"];
if SPECIAL.contains(&self.code()) {
return FormatKind::Special;
}
let Some(s) = self.inner.sections.first() else { return FormatKind::Custom };
match s.kind {
SecKind::General => return FormatKind::General,
SecKind::Text => return FormatKind::Text,
SecKind::Date => {
let calendar = s.toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar()));
return if calendar { FormatKind::Date } else { FormatKind::Time };
}
SecKind::Number => {}
}
if s.fraction {
FormatKind::Fraction
} else if s.exp {
FormatKind::Scientific
} else if s.percent > 0 {
FormatKind::Percentage
} else if s.fill && s.has_digits {
FormatKind::Accounting
} else if s.currency && s.has_digits {
FormatKind::Currency
} else if s.has_digits {
FormatKind::Number
} else {
FormatKind::Custom
}
}
/// Picks the number section for `v`. Returns the section and whether the minus sign is
/// dropped (the default negative section shows the absolute value). `None` = General.
fn select(&self, v: f64) -> Option<(&Section, bool)> {
let secs = self.num_sections();
let k = secs.len();
let s0 = secs.first()?;
let c0 = s0.cond;
let c1 = secs.get(1).and_then(|s| s.cond);
if c0.is_some() || c1.is_some() {
let idx = if c0.is_some_and(|c| c.test(v)) {
0
} else if c1.is_some_and(|c| c.test(v)) {
1
} else if c0.is_some() && c1.is_some() {
2
} else if c0.is_none() {
0
} else {
1
};
// Excel never adds a minus sign in a multi-section conditional format.
return secs.get(idx).map(|s| (s, k >= 2));
}
if v < 0.0 && k >= 2 {
return secs.get(1).map(|s| (s, true));
}
if v == 0.0 && k >= 3 {
return secs.get(2).map(|s| (s, false));
}
Some((s0, false))
}
/// Formats a number. The flag is true when the value cannot be shown (date out of range).
fn format_number(&self, n: f64, sys: DateSystem) -> (Formatted, bool) {
let general = |n: f64| Formatted {
text: format_general_fit(n, GENERAL_WIDTH).unwrap_or_else(|| OVERFLOW.into()),
color: None,
fill: None,
numeric: true,
};
if self.is_general() {
return (general(n), false);
}
let Some((sec, drop_sign)) = self.select(n) else { return (general(n), false) };
let minus = n < 0.0 && !drop_sign;
let out: Option<Out> = match sec.kind {
SecKind::Text => Some(render::render_text(sec, &format_general_fit(n, GENERAL_WIDTH).unwrap_or_default())),
SecKind::General => {
let width = if minus { GENERAL_WIDTH - 1 } else { GENERAL_WIDTH };
Some(render::render_general(sec, n.abs(), width))
}
SecKind::Date => {
if minus {
None
} else {
render::render_date(sec, n.abs(), sys)
}
}
SecKind::Number => Some(render::render_number(sec, n.abs())),
};
let Some(mut out) = out else {
return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true);
};
// Excel drops the minus sign when the first section shows a negative number as all zeros.
if minus && sec.kind != SecKind::Text && !out.zero {
out.prepend("-");
}
(Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false)
}
/// The number `n` as this format displays it: what Excel stores when "Set precision as
/// displayed" is on. Fixed decimals, percent, thousands scaling (`0.0,,`), scientific and
/// fraction sections round to what they show; the section is picked as for display, so
/// `0.00;(0.0)` keeps one decimal for negatives. Observed in Excel (COM,
/// `Workbook.PrecisionAsDisplayed`), these keep the full value: General, date and time
/// formats (`mm:ss.0` included), text formats and sections without digits (`"x"`, `;;;`).
/// The section is picked by [`NumberFormat::rounding_section`], which ignores conditions.
/// Never returns `-0`.
pub fn round_as_displayed(&self, n: f64) -> f64 {
if self.is_general() || !n.is_finite() {
return n;
}
let Some(sec) = self.rounding_section(n) else { return n };
if sec.kind != SecKind::Number {
return n;
}
let Some(m) = render::shown_value(sec, n) else { return n };
if m == 0.0 {
0.0
} else if n < 0.0 {
-m
} else {
m
}
}
/// The section whose digits "Set precision as displayed" rounds `n` by. Observed in Excel
/// (COM), this differs from the section that displays `n`:
/// - Conditions are ignored; the sign picks the section, the first for positives and the
/// second for negatives. `[<1]0.00;0` shows 2.333 as `2` but stores 2.33, and stores -10/3
/// as -3 although it shows `-3.00`. `[>1]0.0;0.000` stores 1/3 as 0.3.
/// - A format with a condition and a single number section keeps negatives at full value
/// (`[<1]0.00`, `[>=-100]0.0`); with an `@` section added (`[<1]0.00;@`) negatives use the
/// first section, as every format without a condition does.
/// - A completely empty section (no characters, not even a colour) passes to the next one:
/// `0.0;;0.000` stores -2.333 as -2.333, `;0.0` stores 2.333 as 2.3, `;;0.0` too. A section
/// with only a colour or a literal (`0.0;[Red];0.000`, `0.0;" "`) has no digits and keeps
/// the full value.
///
/// `None` keeps the full value.
fn rounding_section(&self, n: f64) -> Option<&Section> {
let secs = self.num_sections();
let conditional = secs.iter().any(|s| s.cond.is_some());
let slot = if n < 0.0 && secs.len() >= 2 {
1
} else if n < 0.0 && conditional && self.inner.text_section.is_none() {
return None;
} else {
0
};
let empty = |s: &Section| s.toks.is_empty() && s.color.is_none() && s.cond.is_none();
secs.get(slot..)?.iter().find(|s| !empty(s))
}
fn format_text(&self, t: &str) -> Formatted {
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
Some(sec) => {
let out = render::render_text(sec, t);
Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: false }
}
None => Formatted { text: t.to_string(), color: None, fill: None, numeric: false },
}
}
}
/// Formats a cell value for display.
pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted {
match v {
Value::Empty => Formatted::default(),
Value::Number(n) if !n.is_finite() => Formatted { text: CellError::Num.as_str().into(), ..Formatted::default() },
Value::Number(n) => fmt.format_number(*n, sys).0,
Value::Text(t) => fmt.format_text(t),
Value::Bool(b) => Formatted { text: if *b { "TRUE" } else { "FALSE" }.into(), ..Formatted::default() },
Value::Error(e) => Formatted { text: e.as_str().into(), ..Formatted::default() },
Value::Array(a) => match a.data.first() {
Some(Value::Array(_)) | None => Formatted::default(),
Some(first) => format_value(first, fmt, sys),
},
}
}
/// A small per-thread cache of parsed format codes for callers that format many values with the
/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only
/// (the date system and values are applied when formatting), so the code is the whole key.
mod parse_cache {
use std::cell::RefCell;
use std::collections::HashMap;
use super::NumberFormat;
/// Most codes kept per thread. A full cache is emptied, so memory stays bounded.
pub(super) const CAPACITY: usize = 64;
/// Longer codes are parsed every time and never stored.
pub(super) const MAX_CODE_LEN: usize = 256;
thread_local! {
static CACHE: RefCell<HashMap<String, NumberFormat>> = RefCell::new(HashMap::new());
}
pub(super) fn get(code: &str) -> NumberFormat {
if code.len() > MAX_CODE_LEN {
return NumberFormat::parse(code);
}
// Not usable (thread shutting down, re-entered): parse without the cache.
let cached = CACHE
.try_with(|c| {
let mut c = c.try_borrow_mut().ok()?;
if let Some(f) = c.get(code) {
return Some(f.clone());
}
let f = NumberFormat::parse(code);
if c.len() >= CAPACITY {
c.clear();
}
c.insert(code.to_string(), f.clone());
Some(f)
})
.ok()
.flatten();
cached.unwrap_or_else(|| NumberFormat::parse(code))
}
#[cfg(test)]
thread_local! {
static PARSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(super) fn count_parse() {
let _ = PARSES.try_with(|p| p.set(p.get() + 1));
}
/// Parses done on this thread so far (tests only).
#[cfg(test)]
pub(super) fn parses() -> usize {
PARSES.try_with(std::cell::Cell::get).unwrap_or(0)
}
/// Empties this thread's cache (tests only).
#[cfg(test)]
pub(super) fn clear() {
let _ = CACHE.try_with(|c| c.borrow_mut().clear());
}
}
impl NumberFormat {
/// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so
/// formatting many values with one code parses it once. Same result as `parse`.
pub fn parse_cached(code: &str) -> NumberFormat {
parse_cache::get(code)
}
}
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text 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<String, CellError> {
if code.is_empty() {
return Ok(String::new());
}
if too_long(code) {
return Err(CellError::Value);
}
let fmt = NumberFormat::parse_cached(code);
let n = match v.scalar() {
Value::Error(e) => return Err(e),
Value::Empty => 0.0,
Value::Number(n) => n,
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),
},
Value::Array(_) => return Err(CellError::Value),
};
if !n.is_finite() {
return Err(CellError::Num);
}
let (f, overflow) = fmt.format_number(n, sys);
if overflow { Err(CellError::Value) } else { Ok(f.text) }
}
#[cfg(test)]
mod tests;