mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 22:26:57 -06:00
322 lines
11 KiB
Rust
322 lines
11 KiB
Rust
//! 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 {
|
|
let trimmed = code.trim();
|
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") {
|
|
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);
|
|
};
|
|
if minus && sec.kind != SecKind::Text {
|
|
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),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
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(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(if b { "TRUE" } else { "FALSE" }.into()),
|
|
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;
|