mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
344 lines
14 KiB
Rust
344 lines
14 KiB
Rust
//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart
|
|
//! axes and data labels show them, in the chart's language (digit grouping and decimal mark).
|
|
|
|
/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`).
|
|
fn marks(lang: Option<&str>) -> (char, char) {
|
|
let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase();
|
|
match l.as_str() {
|
|
// Space-grouped, comma decimal.
|
|
"ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy"
|
|
| "ka" | "az" => ('\u{a0}', ','),
|
|
// Dot-grouped, comma decimal.
|
|
"de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','),
|
|
_ => (',', '.'),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
enum Tok {
|
|
/// Text shown as is.
|
|
Lit(String),
|
|
/// A digit placeholder: `0`, `#` or `?`.
|
|
Digit(char),
|
|
Dot,
|
|
Comma,
|
|
Percent,
|
|
}
|
|
|
|
/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out).
|
|
fn tokens(section: &str) -> Vec<Tok> {
|
|
let mut out = vec![];
|
|
let mut it = section.chars().peekable();
|
|
while let Some(c) = it.next() {
|
|
match c {
|
|
'"' => {
|
|
let mut s = String::new();
|
|
for d in it.by_ref() {
|
|
if d == '"' {
|
|
break;
|
|
}
|
|
s.push(d);
|
|
}
|
|
out.push(Tok::Lit(s));
|
|
}
|
|
'\\' => {
|
|
if let Some(d) = it.next() {
|
|
out.push(Tok::Lit(d.to_string()));
|
|
}
|
|
}
|
|
// Padding the width of the next character: a space.
|
|
'_' => {
|
|
it.next();
|
|
out.push(Tok::Lit(" ".into()));
|
|
}
|
|
// Repeat-to-fill: nothing on an axis.
|
|
'*' => {
|
|
it.next();
|
|
}
|
|
'[' => {
|
|
let mut s = String::new();
|
|
for d in it.by_ref() {
|
|
if d == ']' {
|
|
break;
|
|
}
|
|
s.push(d);
|
|
}
|
|
if let Some(sym) = s.strip_prefix('$') {
|
|
let sym = sym.split('-').next().unwrap_or("");
|
|
if !sym.is_empty() {
|
|
out.push(Tok::Lit(sym.to_string()));
|
|
}
|
|
}
|
|
}
|
|
'0' | '#' | '?' => out.push(Tok::Digit(c)),
|
|
'.' => out.push(Tok::Dot),
|
|
',' => out.push(Tok::Comma),
|
|
'%' => out.push(Tok::Percent),
|
|
'@' => {}
|
|
_ => out.push(Tok::Lit(c.to_string())),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The format's sections split at `;` (outside quotes, brackets and escapes).
|
|
fn sections(code: &str) -> Vec<&str> {
|
|
let mut out = vec![];
|
|
let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false);
|
|
for (i, c) in code.char_indices() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
match c {
|
|
'\\' if !quoted => escaped = true,
|
|
'"' => quoted = !quoted,
|
|
'[' if !quoted => bracket = true,
|
|
']' if !quoted => bracket = false,
|
|
';' if !quoted && !bracket => {
|
|
out.push(code.get(start..i).unwrap_or(""));
|
|
start = i + 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
out.push(code.get(start..).unwrap_or(""));
|
|
out
|
|
}
|
|
|
|
/// `v` in the General format: up to 10 significant digits, no grouping.
|
|
fn general(v: f64, dec: char) -> String {
|
|
if v == 0.0 {
|
|
return "0".into();
|
|
}
|
|
let a = v.abs();
|
|
let s = if !(1e-9..1e11).contains(&a) {
|
|
format!("{v:.5E}")
|
|
} else {
|
|
let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize;
|
|
let s = format!("{v:.digits$}");
|
|
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
|
};
|
|
s.replace('.', &dec.to_string())
|
|
}
|
|
|
|
/// Group an integer's digits in threes with `sep`.
|
|
fn group(digits: &str, sep: char) -> String {
|
|
let n = digits.chars().count();
|
|
let mut out = String::new();
|
|
for (i, d) in digits.chars().enumerate() {
|
|
if i > 0 && (n - i).is_multiple_of(3) {
|
|
out.push(sep);
|
|
}
|
|
out.push(d);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
|
|
/// section of a format doesn't apply).
|
|
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
|
|
let (sep, dec) = marks(lang);
|
|
if !v.is_finite() {
|
|
return String::new();
|
|
}
|
|
let secs = sections(code);
|
|
// The section for the value's sign; a negative or zero section shows the value unsigned.
|
|
let (sec, v, minus) = match (secs.as_slice(), v) {
|
|
([_, neg, ..], v) if v < 0.0 => (*neg, -v, false),
|
|
([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false),
|
|
([pos, ..], v) => (*pos, v.abs(), v < 0.0),
|
|
([], v) => ("General", v.abs(), v < 0.0),
|
|
};
|
|
if sec.trim().eq_ignore_ascii_case("general") || sec.trim().is_empty() {
|
|
let s = general(v, dec);
|
|
return if minus { format!("-{s}") } else { s };
|
|
}
|
|
let toks = tokens(sec);
|
|
let first = toks.iter().position(|t| matches!(t, Tok::Digit(_)));
|
|
let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_)));
|
|
let (Some(first), Some(last)) = (first, last) else {
|
|
// No digits: just the section's text ("-" for zero, a label).
|
|
let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect();
|
|
return s.trim().to_string();
|
|
};
|
|
let out = sci(&toks, v, sep, dec).unwrap_or_else(|| fixed(&toks, first, last, v, sep, dec));
|
|
let out = out.trim();
|
|
if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() }
|
|
}
|
|
|
|
/// The text of the literal and percent tokens in `ts`.
|
|
fn lits(ts: &[Tok]) -> String {
|
|
ts.iter()
|
|
.filter_map(|t| match t {
|
|
Tok::Lit(s) => Some(s.clone()),
|
|
Tok::Percent => Some("%".into()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Scientific notation (`0.00E+00`, `##0.0E-0`): the mantissa takes the placeholders before the `E`,
|
|
/// the exponent's sign and minimum width come from what follows it; with several integer
|
|
/// placeholders the exponent is a multiple of their count (engineering notation). `None` if the
|
|
/// section has no exponent part.
|
|
fn sci(toks: &[Tok], v: f64, sep: char, dec: char) -> Option<String> {
|
|
let e = toks.iter().position(|t| matches!(t, Tok::Lit(s) if s.as_str() == "E" || s.as_str() == "e"))?;
|
|
let e_char = if let Some(Tok::Lit(s)) = toks.get(e) { s.as_str() } else { "E" };
|
|
let plus = match toks.get(e + 1)? {
|
|
Tok::Lit(s) if s.as_str() == "+" => true,
|
|
Tok::Lit(s) if s.as_str() == "-" => false,
|
|
_ => return None,
|
|
};
|
|
let mant = toks.get(..e)?;
|
|
let mfirst = mant.iter().position(|t| matches!(t, Tok::Digit(_)))?;
|
|
let mlast = mant.iter().rposition(|t| matches!(t, Tok::Digit(_)))?;
|
|
let exp_toks = toks.get(e + 2..)?;
|
|
let exp_len = exp_toks.iter().take_while(|t| matches!(t, Tok::Digit(_))).count();
|
|
if exp_len == 0 {
|
|
return None;
|
|
}
|
|
let width = exp_toks.iter().take(exp_len).filter(|t| **t == Tok::Digit('0')).count();
|
|
let rest = exp_toks.get(exp_len..).unwrap_or(&[]);
|
|
let mant_frac = mant.iter().skip_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count();
|
|
let (mut m, mut exp) = (0.0, 0i32);
|
|
if v != 0.0 {
|
|
exp = v.log10().floor() as i32;
|
|
m = v / 10f64.powi(exp);
|
|
if !m.is_finite() {
|
|
m = 0.0;
|
|
exp = 0;
|
|
}
|
|
if m >= 10.0 {
|
|
m /= 10.0;
|
|
exp += 1;
|
|
} else if m < 1.0 {
|
|
m *= 10.0;
|
|
exp -= 1;
|
|
}
|
|
// Rounding the mantissa to its digits can reach 10 (9.999 as `0.00`).
|
|
if format!("{m:.mant_frac$}").starts_with("10") {
|
|
m /= 10.0;
|
|
exp += 1;
|
|
}
|
|
}
|
|
let step = mant.iter().take_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count().max(1) as i32;
|
|
let shift = exp.rem_euclid(step);
|
|
exp -= shift;
|
|
m *= 10f64.powi(shift);
|
|
let mantissa = fixed(mant, mfirst, mlast, m, sep, dec);
|
|
let sign = if exp < 0 {
|
|
"-"
|
|
} else if plus {
|
|
"+"
|
|
} else {
|
|
""
|
|
};
|
|
let digits = exp.unsigned_abs().to_string();
|
|
let pad = "0".repeat(width.saturating_sub(digits.len()));
|
|
Some(format!("{mantissa}{e_char}{sign}{pad}{digits}{}", lits(rest)))
|
|
}
|
|
|
|
/// `v` (not negative) in the fixed-point format whose tokens are `toks`, with its digits from
|
|
/// `first` to `last`.
|
|
fn fixed(toks: &[Tok], first: usize, last: usize, v: f64, sep: char, dec: char) -> String {
|
|
let percent = toks.contains(&Tok::Percent);
|
|
let number = toks.get(first..=last).unwrap_or(&[]);
|
|
let dot = number.iter().position(|t| *t == Tok::Dot);
|
|
let (int_part, frac_part) = match dot {
|
|
Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])),
|
|
None => (number, &[][..]),
|
|
};
|
|
// Grouping: a comma (or the locale's space, written literally) between integer digits.
|
|
let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty()));
|
|
// Commas right after the last digit scale by a thousand each (`#,##0,` = thousands).
|
|
let after = toks.get(last + 1..).unwrap_or(&[]);
|
|
let scale_commas = if dot.is_none() { after.iter().take_while(|t| **t == Tok::Comma).count() } else { 0 };
|
|
let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count();
|
|
let frac_digits: Vec<char> = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect();
|
|
let max_frac = frac_digits.len();
|
|
let min_frac = frac_digits.iter().filter(|c| **c == '0').count();
|
|
let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32);
|
|
if !x.is_finite() {
|
|
x = 0.0;
|
|
}
|
|
let s = format!("{x:.max_frac$}");
|
|
let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), ""));
|
|
let mut fp = fp.to_string();
|
|
while fp.len() > min_frac && fp.ends_with('0') {
|
|
fp.pop();
|
|
}
|
|
let ip = ip.trim_start_matches('0');
|
|
let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() };
|
|
let mut num = if grouping { group(&ip, sep) } else { ip };
|
|
if !fp.is_empty() {
|
|
num.push(dec);
|
|
num.push_str(&fp);
|
|
}
|
|
let before = lits(toks.get(..first).unwrap_or(&[]));
|
|
let after = lits(toks.get(last + 1..).unwrap_or(&[]));
|
|
format!("{before}{num}{after}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
|
|
|
|
#[test]
|
|
fn russian_accounting_roubles() {
|
|
assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽");
|
|
assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽");
|
|
assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽");
|
|
assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽");
|
|
}
|
|
|
|
#[test]
|
|
fn common_codes() {
|
|
assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000");
|
|
assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000");
|
|
assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000");
|
|
assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%");
|
|
assert_eq!(format("0.0%", 0.125, None), "12.5%");
|
|
assert_eq!(format("0%", 0.0, None), "0%");
|
|
assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5");
|
|
assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000");
|
|
assert_eq!(format("0.00", 1.23456, None), "1.23");
|
|
assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5");
|
|
assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346");
|
|
assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500");
|
|
assert_eq!(format("0;[Red]-0", -3.0, None), "-3");
|
|
assert_eq!(format("0;0", -3.0, None), "3");
|
|
assert_eq!(format("0", -3.0, None), "-3");
|
|
assert_eq!(format("\"x\"0", 5.0, None), "x5");
|
|
}
|
|
|
|
#[test]
|
|
fn scientific_formats() {
|
|
assert_eq!(format("0.00E+00", 1234.0, None), "1.23E+03");
|
|
assert_eq!(format("0.00E+00", 1234.0, Some("ru-RU")), "1,23E+03");
|
|
assert_eq!(format("0.00E+00", 0.00012, None), "1.20E-04");
|
|
assert_eq!(format("0.00E+00", -1234.0, None), "-1.23E+03");
|
|
assert_eq!(format("0.00E+00", 0.0, None), "0.00E+00");
|
|
assert_eq!(format("0.0E+000", 5e10, None), "5.0E+010");
|
|
assert_eq!(format("0.0E-0", 1234.0, None), "1.2E3");
|
|
assert_eq!(format("0.0E-0", 0.0012, None), "1.2E-3");
|
|
assert_eq!(format("0E+00", 9999.0, None), "1E+04");
|
|
assert_eq!(format("0.00E+00", 9.999, None), "1.00E+01");
|
|
assert_eq!(format("0.00e+00", 1234.0, None), "1.23e+03");
|
|
assert_eq!(format("##0.0E+0", 12_345.0, None), "12.3E+3");
|
|
assert_eq!(format("##0.0E+0", 0.012, None), "12.0E-3");
|
|
assert_eq!(format("##0.0E+0", 999.96, None), "1.0E+3");
|
|
}
|
|
|
|
#[test]
|
|
fn never_panics() {
|
|
for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] {
|
|
for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] {
|
|
let _ = format(code, v, Some("ru"));
|
|
}
|
|
}
|
|
}
|
|
}
|