mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 22:26:57 -06:00
330 lines
13 KiB
Rust
330 lines
13 KiB
Rust
//! 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<String>,
|
|
pub formats: Vec<String>,
|
|
/// Every non-blank value in the column is a number.
|
|
pub numeric: Vec<bool>,
|
|
/// Numeric with a date number format.
|
|
pub dates: Vec<bool>,
|
|
pub rows: Vec<Vec<Value>>,
|
|
}
|
|
|
|
impl Source {
|
|
pub fn col(&self, name: &str) -> Option<usize> {
|
|
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<Source, String> {
|
|
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<String> = 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<String> = HashSet::new();
|
|
let mut next_suffix: HashMap<String, usize> = 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<Value> = (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<Output, String> {
|
|
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<RangeRef, String> {
|
|
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.value.is_empty() || cell.formula.is_some()) && !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<Style, StyleId> = 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 }));
|
|
}
|
|
}
|
|
// Column widths that fit the labels.
|
|
let mut widths: HashMap<u32, f32> = 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));
|
|
}
|
|
}
|