//! How cells look as text: number formats applied, for the grid, clipboard, CSV and agents. use std::collections::HashMap; use std::sync::Mutex; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Sheet, Workbook}; use gridcraft_numfmt::{Formatted, NumberFormat, format_value}; /// Parsed number formats are cached by code. fn parsed(code: &str) -> NumberFormat { static CACHE: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(f) = lock().get(code) { return f.clone(); } // Parse outside the lock, so a slow code never blocks the other threads' lookups. let f = NumberFormat::parse(code); let mut m = lock(); if m.len() > 4096 { m.clear(); } m.insert(code.to_string(), f.clone()); f } pub fn number_format(code: &str) -> NumberFormat { parsed(code) } /// Formats a value with a format code. pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } /// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a /// constant number in a date or time format is shown as a date (as the grid shows it), not as its /// serial. When the formatted text would not read back as the same date (for example /// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss` /// for a time alone) is used instead, and if even that would not read back, the serial. pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let raw = sheet.input_text(c); let Some(cell) = sheet.cell(c) else { return raw }; let Value::Number(n) = &cell.value else { return raw }; let code = wb.styles.get(cell.style).num_fmt.as_str(); if cell.formula.is_some() || !parsed(code).is_date() { return raw; } let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9); let shown = format(&cell.value, code, wb).text; if reads_back(&shown) { return shown; } let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw }; let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second); let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day); let iso = if *n < 1.0 { time } else if n.fract() == 0.0 { date } else { format!("{date} {time}") }; if reads_back(&iso) { iso } else { raw } } /// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a /// protected sheet. A spilled cell follows its anchor. pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { if !sheet.is_protected() { return false; } let at = match sheet.cell(c) { Some(x) if x.formula.is_some() => c, _ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) { Some((anchor, _)) => *anchor, None => return false, }, }; wb.styles.get(sheet.style_id(at)).protection.hidden } /// The text a cell shows (full precision General; the grid narrows General to fit the column). pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { if sheet.show_formulas && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) && !formula_hidden(wb, sheet, c) { return format!("={}", f.text); } value_text(wb, sheet, c) } /// A cell's value with its number format applied, independent of column width: no `#####` /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let style = wb.styles.get(sheet.style_id(c)); let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); match f.fill { Some((_, pos)) => { let mut t = f.text; if pos <= t.len() && t.is_char_boundary(pos) { t.insert(pos, ' '); } t } None => f.text, } } /// Tab-separated text of a range, as the system clipboard gets it. pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; let mut out = String::new(); let r = clipboard_range(r); for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; } let mut first = true; for col in r.start.col..=r.end.col { if !first { out.push('\t'); } first = false; let t = cell_text(wb, sh, CellRef::new(row, col)); if t.contains(['\t', '\n', '"']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); out.push('"'); } else { out.push_str(&t); } } out.push('\n'); } out } /// The existing plain-text clipboard limit, shared with its HTML representation. pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef { if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r } } /// Status-bar statistics for a selection: average, count (non-empty), numerical count, min, /// max, sum. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] pub struct Stats { pub count: u64, pub numeric: u64, pub sum: f64, pub min: f64, pub max: f64, pub average: Option, } pub fn stats(sheet: &Sheet, ranges: &[RangeRef]) -> Stats { let mut s = Stats { min: f64::INFINITY, max: f64::NEG_INFINITY, ..Default::default() }; let mut seen = std::collections::HashSet::new(); for r in ranges { let r = match sheet.used_range().and_then(|u| r.intersection(&u)) { Some(x) => x, None => continue, }; let mut visit = |c: CellRef, v: &Value| { if !seen.insert(c) || sheet.is_row_hidden(c.row) { return; } if v.is_empty() { return; } s.count += 1; if let Value::Number(n) = v { s.numeric += 1; s.sum += n; s.min = s.min.min(*n); s.max = s.max.max(*n); } }; for (c, cell) in sheet.cells.iter_range(r) { visit(c, &cell.value); } for (c, v) in sheet.spill.range(r.start..=r.end) { if r.contains(*c) { visit(*c, v); } } } if s.numeric > 0 { s.average = Some(s.sum / s.numeric as f64); } else { s.min = 0.0; s.max = 0.0; } s }