mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 12:29:58 -06:00
A Python cell whose cached value is empty (a file's PY formula with no cached value) was taken for an empty cell by six checks, which use the value and the formula only: - the Analysis ToolPak report and the PivotTable report replaced it without asking for overwrite or replace (CodeRabbit, on the report); - Insert Cells and Insert Table without headers pushed it off the end of the sheet; - the data region (Ctrl+A, AutoSum, sort) ended at it; - pasting it into part of a legacy array was not refused. They now use Cell::has_content, which counts Python code. Test: python_code_with_an_empty_value_counts_as_content; reverting any one of the six changes makes it fail.
333 lines
13 KiB
Rust
333 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.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<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, py: None },
|
|
));
|
|
}
|
|
}
|
|
// 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));
|
|
}
|
|
}
|