//! PivotTables: reading the source list, grouping and aggregating, laying out the report and //! writing it onto the sheet as static, styled cells. //! //! Behaviour follows Excel's documented PivotTable model (rows/columns/values/filters areas, //! Compact/Outline/Tabular layouts, subtotals, grand totals, show-values-as); the code and the //! look (our own palette) are original. use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use gridcraft_core::date::datetime_from_serial; pub use gridcraft_core::quote_sheet; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet}; use gridcraft_model::{ BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook, }; use crate::pivot_calc::CalcSet; mod charts; mod compute; mod items; mod output; use charts::rebind_charts; pub use charts::{chart_link, chart_series, clear_output, links_to, parse_chart_link}; use compute::compute_from; pub use compute::{NOT_A_VALUE_CELL, detail_cells, detail_records}; pub use items::{distinct_labels, func_caption, item_label}; #[cfg(test)] use output::data_cols_exceed_sheet; pub use output::{CellKind, OutCell, Output, RowKind}; use output::{cell_style, look}; /// Largest source block read (cells). pub const MAX_SOURCE_CELLS: u64 = 10_000_000; /// Largest report written (cells). pub const MAX_OUTPUT_CELLS: u64 = 2_000_000; /// Most fields in the Rows plus Columns areas. pub const MAX_AXIS_FIELDS: usize = 32; /// Most fields in the Values area. pub const MAX_VALUE_FIELDS: usize = 256; const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // ---------------------------------------------------------------- source /// The source list: header names, per-column number formats and the records. #[derive(Clone, Debug)] pub struct Source { pub sheet: usize, pub range: RangeRef, pub headers: Vec, pub formats: Vec, /// Every non-blank value in the column is a number. pub numeric: Vec, /// Numeric with a date number format. pub dates: Vec, pub rows: Vec>, } impl Source { pub fn col(&self, name: &str) -> Option { self.headers.iter().position(|h| h.eq_ignore_ascii_case(name)) } } /// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`. pub fn range_text(sheet_name: &str, r: RangeRef) -> String { let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); format!("{}!{}:{}", quote_sheet(sheet_name), abs(r.start), abs(r.end)) } /// Resolves source text (range, table name or defined name) to (sheet, range including header). pub fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<(usize, RangeRef), String> { resolve_inner(wb, text, default_sheet, 0) } fn resolve_inner(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Result<(usize, RangeRef), String> { const INVALID: &str = "The reference isn't valid."; let t = text.trim().trim_start_matches('=').trim(); if t.is_empty() || depth > 4 { return Err(INVALID.into()); } if let Some((si, ti)) = wb.table(t) && let Some(tb) = wb.sheet(si).and_then(|s| s.tables.get(ti)) { if !tb.header_row { return Err("The table needs a header row to be used as a PivotTable source.".into()); } let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); return Ok((si, RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)))); } let (sheet, body) = split_sheet(t); if let Some(r) = RangeRef::parse(body) { let si = match sheet { Some(n) => wb.sheet_index(&n).ok_or_else(|| INVALID.to_string())?, None => default_sheet, }; let sh = wb.sheet(si).ok_or_else(|| INVALID.to_string())?; let r = if r.is_full_cols() || r.is_full_rows() { let used = sh.used_range().ok_or_else(|| INVALID.to_string())?; r.intersection(&used).ok_or_else(|| INVALID.to_string())? } else { r }; return Ok((si, r)); } if sheet.is_none() && let Some(n) = wb.name(t, default_sheet) { let f = n.formula.clone(); return resolve_inner(wb, &f, default_sheet, depth + 1); } Err(INVALID.into()) } const FIELD_NAME_ERR: &str = "The PivotTable field name is not valid. To create a PivotTable, you must use data that is organized as a list with labeled columns."; /// Reads the source list. pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result { let (si, r) = resolve_source(wb, text, default_sheet)?; let sh = wb.sheet(si).ok_or("The reference isn't valid.")?; if r.count() > MAX_SOURCE_CELLS { return Err("The source range is too large for a PivotTable.".into()); } let mut headers: Vec = Vec::new(); // Names taken so far (lower-case), and the next suffix to try for each repeated header, so // that thousands of equal headers don't take cubic time. let mut taken: HashSet = HashSet::new(); let mut next_suffix: HashMap = HashMap::new(); let mut formats = Vec::new(); for c in r.start.col..=r.end.col { let h = crate::display::cell_text(wb, sh, CellRef::new(r.start.row, c)).trim().to_string(); if h.is_empty() { return Err(FIELD_NAME_ERR.into()); } let key = h.to_ascii_lowercase(); let mut n = h.clone(); let mut k = next_suffix.get(&key).copied().unwrap_or(2); while taken.contains(&n.to_ascii_lowercase()) { n = format!("{h}{k}"); k += 1; } next_suffix.insert(key, k); taken.insert(n.to_ascii_lowercase()); headers.push(n); let fmt = if r.end.row > r.start.row { wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).num_fmt.as_str().to_string() } else { "General".into() }; formats.push(fmt); } let w = headers.len(); let mut rows = Vec::new(); let mut numeric = vec![true; w]; let mut any = vec![false; w]; if r.end.row > r.start.row { for row in r.start.row + 1..=r.end.row { let rec: Vec = (r.start.col..=r.end.col).map(|c| sh.value(CellRef::new(row, c)).scalar()).collect(); if rec.iter().all(Value::is_empty) { continue; } for (i, v) in rec.iter().enumerate() { if !v.is_empty() { if let Some(a) = any.get_mut(i) { *a = true; } if !v.is_number() && let Some(n) = numeric.get_mut(i) { *n = false; } } } rows.push(rec); } } for (n, a) in numeric.iter_mut().zip(any.iter()) { *n = *n && *a; } let dates = numeric.iter().zip(formats.iter()).map(|(n, f)| *n && crate::display::number_format(f).is_date()).collect(); Ok(Source { sheet: si, range: r, headers, formats, numeric, dates, rows }) } /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; compute_from(wb, pt, &src, None) } /// Recomputes a pivot and writes its report. With `replace` false, refuses when the target area /// holds other data. Returns the written area. pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Result { let pt = wb.sheet(sheet).and_then(|s| s.pivots.get(idx)).cloned().ok_or("There's no such PivotTable.")?; let out = compute(wb, &pt, sheet)?; let h = out.height().max(1); let w = out.width.max(1); let k = out.body_offset; let origin_row = if pt.anchor.row >= k { pt.anchor.row - k } else { pt.anchor.row }; let origin = CellRef::new(origin_row, pt.anchor.col); let end_row = origin.row as u64 + h as u64 - 1; let end_col = origin.col as u64 + w as u64 - 1; if end_row >= MAX_ROWS as u64 || end_col >= MAX_COLS as u64 { return Err("The PivotTable report will not fit on the sheet. Choose a different location.".into()); } let area = RangeRef::new(origin, CellRef::new(end_row as u32, end_col as u32)); let sh = wb.sheet(sheet).ok_or("There's no such sheet.")?; for (j, other) in sh.pivots.iter().enumerate() { if j != idx && other.last_range.is_some_and(|r| r.intersects(&area)) { return Err("A PivotTable report cannot overlap another PivotTable report.".into()); } } if sh.is_protected() { return Err("The cell or chart you're trying to change is on a protected sheet.".into()); } if let Ok((ssi, sr)) = resolve_source(wb, &pt.source, sheet) && ssi == sheet && sr.intersects(&area) { return Err("The PivotTable report can't overlap its source data. Choose a different location.".into()); } if sh.tables.iter().any(|t| t.range.intersects(&area)) { return Err("A PivotTable report cannot overlap a table.".into()); } if !replace { for (c, cell) in sh.cells.iter_range(area) { if cell.has_content() && !pt.last_range.is_some_and(|r| r.contains(c)) { return Err(format!( "There's already data in {}!{}. Do you want to replace it? (Run again with replace: true to overwrite.)", quote_sheet(&sh.name), c.a1() )); } } if let Some(c) = sh.spill.keys().find(|c| area.contains(**c) && !pt.last_range.is_some_and(|r| r.contains(**c))) { return Err(format!( "There's already data in {}!{}. Do you want to replace it? (Run again with replace: true to overwrite.)", quote_sheet(&sh.name), c.a1() )); } } // Styles first (the style table is workbook-wide). let lk = look(&pt.style); let header_last = out.row_kinds.iter().rposition(|k| *k == RowKind::Header); let mut cache: HashMap = HashMap::new(); let mut cells: Vec<(CellRef, Cell)> = Vec::new(); for (ri, row) in out.grid.iter().enumerate() { let rk = out.row_kinds.get(ri).copied().unwrap_or(RowKind::Blank); if rk == RowKind::Blank { continue; } for (ci, oc) in row.iter().enumerate() { if rk == RowKind::Filter && oc.value.is_empty() { continue; } if rk == RowKind::Placeholder && oc.value.is_empty() { continue; } let flag = match rk { RowKind::Header => Some(ri) == header_last, RowKind::Filter => ci == 0, _ => false, }; let st = cell_style(&lk, rk, oc, flag); let id = match cache.get(&st) { Some(id) => *id, None => { let id = wb.styles.intern(st.clone()); cache.insert(st, id); id } }; if oc.value.is_empty() && id == StyleId::default() { continue; } cells.push(( CellRef::new(origin.row + ri as u32, origin.col + ci as u32), Cell { value: oc.value.clone(), formula: None, style: id, py: None }, )); } } // Column widths that fit the labels. let mut widths: HashMap = HashMap::new(); for (c, cell) in &cells { if cell.value.is_empty() { continue; } let st = wb.styles.get(cell.style); let text = crate::display::format(&cell.value, st.num_fmt.as_str(), wb).text; let wpt = crate::cmd::format::approx_text_width(&text, st.font.size, st.font.bold) + st.align.indent as f32 * 9.0 + 14.0; let e = widths.entry(c.col).or_insert(0.0); *e = e.max(wpt.min(320.0)); } let shm = wb.sheet_mut(sheet).ok_or("There's no such sheet.")?; if let Some(old) = pt.last_range { shm.take_cells(old); } shm.take_cells(area); for (c, cell) in cells { shm.set_cell(c, cell); } for (col, wpt) in widths { if wpt > shm.col_width(col) && !shm.is_col_hidden(col) { shm.cols.entry(col).or_default().size = Some(wpt); } } if let Some(p) = shm.pivots.get_mut(idx) { p.last_range = Some(area); } let sheet_name = shm.name.clone(); rebind_charts(wb, &sheet_name, &pt.name, origin, &out); Ok(area) } #[cfg(test)] mod fit_tests { use super::data_cols_exceed_sheet; #[test] fn column_budget_counts_value_fields_and_grand_total() { assert!(!data_cols_exceed_sheet(64, true, 128)); assert!(data_cols_exceed_sheet(64, true, 256)); assert!(!data_cols_exceed_sheet(64, false, 256)); assert!(!data_cols_exceed_sheet(16_384, false, 0)); assert!(data_cols_exceed_sheet(16_385, false, 0)); } }