gridcraft/crates/numfmt/src/lib.rs

400 lines
13 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::{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),
}
/// 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()
}
}
impl NumberFormat {
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
pub fn parse(code: &str) -> 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" {
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)
}
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)
}
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());
}
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;