PivotTables: calculated fields (pivot.calculatedField) (#304)
A value field can be a calculated field whose formula is evaluated on the sums of the fields it names for each report cell (Excel's documented semantics). Command to add/change/delete, dialog from the field pane, XLSX cacheField formula round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f896c1249
commit
400ab591cb
|
|
@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
summarize_table
|
||||
),
|
||||
cmd!(
|
||||
"pivot.calculatedField",
|
||||
"Calculated Field",
|
||||
["PivotTable Analyze", "Calculations"],
|
||||
None,
|
||||
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
|
||||
has_doc,
|
||||
calculated_field
|
||||
),
|
||||
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
|
||||
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
|
||||
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
|
||||
|
|
@ -320,6 +329,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
|
|||
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
|
||||
}
|
||||
|
||||
/// The calculated field named `field`, if any.
|
||||
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
|
||||
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
|
||||
}
|
||||
|
||||
/// The canonical name of a source or calculated field.
|
||||
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
|
||||
match calc_field(pt, field) {
|
||||
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
|
||||
_ => canonical(cmd, src, field),
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
|
||||
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
|
||||
if !taken(base) {
|
||||
|
|
@ -343,7 +365,11 @@ fn add_to_area(
|
|||
name: Option<&str>,
|
||||
position: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let field = canonical(cmd, src, field)?;
|
||||
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
|
||||
if is_calc && area != "values" {
|
||||
return Err(other("A calculated field can only be placed in the Values area."));
|
||||
}
|
||||
let field = canonical_any(cmd, pt, src, field)?;
|
||||
match area {
|
||||
"rows" | "columns" => {
|
||||
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
|
||||
|
|
@ -369,7 +395,7 @@ fn add_to_area(
|
|||
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
|
||||
}
|
||||
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
|
||||
let base = match name {
|
||||
Some(n) if !n.trim().is_empty() => {
|
||||
|
|
@ -663,6 +689,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
|||
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
|
||||
"style": pt.style,
|
||||
"showHeaders": pt.show_headers,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -702,6 +729,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
.collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let mut list = list;
|
||||
for c in &pt.calculated_fields {
|
||||
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
|
||||
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
|
||||
}
|
||||
if let Some(m) = out.as_object_mut() {
|
||||
m.insert("fields".into(), Json::Array(list));
|
||||
if let Ok(src) = &src {
|
||||
|
|
@ -743,7 +775,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
|
||||
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
|
||||
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
|
||||
})
|
||||
}
|
||||
|
||||
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const C: &str = "pivot.calculatedField";
|
||||
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
|
||||
// Menu-style call: the Insert Calculated Field dialog.
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
|
||||
let pivot = pt.name.clone();
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
|
||||
return ok();
|
||||
};
|
||||
if name.is_empty() || name.chars().count() > 255 {
|
||||
return Err(bad(C, "`name` must be 1 to 255 characters"));
|
||||
}
|
||||
let delete = bool_param(p, "delete").unwrap_or(false);
|
||||
let formula = str_param(p, "formula").map(|f| {
|
||||
let f = f.trim();
|
||||
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
|
||||
});
|
||||
let to_values = bool_param(p, "values").unwrap_or(true);
|
||||
if !delete && formula.is_none() {
|
||||
return Err(bad(C, "missing `formula`"));
|
||||
}
|
||||
update(s, p, |pt, src, _| {
|
||||
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
|
||||
if delete {
|
||||
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
|
||||
let gone = pt.calculated_fields.remove(i);
|
||||
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
|
||||
return Ok(json!({"name": gone.name, "deleted": true}));
|
||||
}
|
||||
let formula = formula.clone().unwrap_or_default();
|
||||
if src.col(&name).is_some() {
|
||||
return Err(other("PivotTable field name already exists."));
|
||||
}
|
||||
let canon = match existing {
|
||||
Some(i) => {
|
||||
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
c.formula = formula.clone();
|
||||
c.name.clone()
|
||||
}
|
||||
None => {
|
||||
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
|
||||
}
|
||||
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
|
||||
name.clone()
|
||||
}
|
||||
};
|
||||
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
|
||||
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
if let Some(e) = set.error(i) {
|
||||
return Err(bad(C, e.to_string()));
|
||||
}
|
||||
if set.is_cyclic(i) {
|
||||
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
|
||||
}
|
||||
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
|
||||
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"name": canon,
|
||||
"formula": formula,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1280,8 +1381,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use serde_json::json;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
|
|
@ -1550,6 +1651,63 @@ mod tests {
|
|||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_on_sums() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
|
||||
assert_eq!(r["formula"], "=Sales / Units");
|
||||
assert_eq!(text(&s, "C3"), "Sum of Price");
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
// East: 415 sales over 33 units, from the sums.
|
||||
let east = val(&s, "C4").as_f64().unwrap();
|
||||
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
|
||||
// The grand total applies the formula to the totals, not the sum of the rows.
|
||||
assert_eq!(text(&s, "A7"), "Grand Total");
|
||||
let total = val(&s, "C7").as_f64().unwrap();
|
||||
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
|
||||
// Quoted names, functions, and another calculated field.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
|
||||
assert_eq!(val(&s, "D4"), Value::Number(25.2));
|
||||
// Division by zero shows #DIV/0! cells.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
|
||||
// Change and delete.
|
||||
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Number(66.0));
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
|
||||
assert_eq!(pt(&s).values.len(), 3);
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// The field list shows calculated fields.
|
||||
let f = s.execute("pivot.fields", json!({})).unwrap();
|
||||
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_errors() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
|
||||
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
|
||||
assert!(e(json!({"name": "Bad"})).contains("formula"));
|
||||
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
|
||||
// A cycle is refused.
|
||||
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
|
||||
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
|
||||
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
|
||||
assert!(err.contains("itself"), "{err}");
|
||||
// Calculated fields only go to Values; nothing changed after the failures.
|
||||
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// Without a name the dialog opens (menu-style call).
|
||||
s.ui_requests.clear();
|
||||
s.execute("pivot.calculatedField", json!({})).unwrap();
|
||||
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_protection() {
|
||||
let mut s = sales();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod display;
|
|||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial;
|
|||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
/// Largest source block read (cells).
|
||||
pub const MAX_SOURCE_CELLS: u64 = 10_000_000;
|
||||
/// Largest report written (cells).
|
||||
|
|
@ -666,13 +668,44 @@ fn flatten_cols(
|
|||
|
||||
type AggMap = HashMap<(Vec<u32>, Vec<u32>), Vec<Acc>>;
|
||||
|
||||
/// Where a value field's numbers come from.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValSrc {
|
||||
/// A source column.
|
||||
Col(usize),
|
||||
/// A calculated field (index into the PivotTable's calculated fields).
|
||||
Calc(usize),
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
|
||||
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect();
|
||||
// Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`).
|
||||
let calc = CalcSet::new(pt, &src.headers);
|
||||
let vals: Vec<(ValSrc, &PivotValue)> = pt
|
||||
.values
|
||||
.iter()
|
||||
.filter_map(|v| match src.col(&v.source_col) {
|
||||
Some(c) => Some((ValSrc::Col(c), v)),
|
||||
None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)),
|
||||
})
|
||||
.collect();
|
||||
// Source columns summed for the calculated fields, accumulated after the value fields.
|
||||
let mut extra: Vec<usize> = Vec::new();
|
||||
for (vs, _) in &vals {
|
||||
if let ValSrc::Calc(i) = vs {
|
||||
for name in calc.source_refs(*i) {
|
||||
if let Some(c) = src.col(&name)
|
||||
&& !extra.contains(&c)
|
||||
{
|
||||
extra.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
|
||||
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
|
||||
}
|
||||
|
|
@ -728,8 +761,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
}
|
||||
for a in 0..=rp.len() {
|
||||
for b in 0..=cp.len() {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k]);
|
||||
for (acc, (c, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]);
|
||||
for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
if let ValSrc::Col(c) = vs {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
|
|
@ -915,8 +953,21 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
|
||||
// ---- body
|
||||
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
|
||||
let (_, v) = vals.get(vi)?;
|
||||
aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func))
|
||||
let (vs, v) = vals.get(vi)?;
|
||||
let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?;
|
||||
match vs {
|
||||
ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)),
|
||||
ValSrc::Calc(i) => {
|
||||
let sum = |name: &str| -> Value {
|
||||
src.col(name)
|
||||
.and_then(|c| extra.iter().position(|x| *x == c))
|
||||
.and_then(|j| accs.get(k + j))
|
||||
.map(|a| a.result(PivotFunc::Sum))
|
||||
.unwrap_or(Value::Number(0.0))
|
||||
};
|
||||
Some(calc.value(wb, *i, &sum))
|
||||
}
|
||||
}
|
||||
};
|
||||
let num = |v: &Option<Value>| v.as_ref().and_then(Value::as_f64);
|
||||
let ratio = |a: Value, b: Option<Value>| -> Value {
|
||||
|
|
@ -935,7 +986,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
};
|
||||
let data_fmt: Vec<Option<String>> = vals
|
||||
.iter()
|
||||
.map(|(c, v)| match v.show_as {
|
||||
.map(|(vs, v)| match v.show_as {
|
||||
PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => {
|
||||
Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into()))
|
||||
}
|
||||
|
|
@ -943,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
_ => v.number_format.clone().or_else(|| match v.func {
|
||||
PivotFunc::Count | PivotFunc::CountNumbers => None,
|
||||
_ => {
|
||||
let f = fmt_of(*c);
|
||||
let f = match vs {
|
||||
ValSrc::Col(c) => fmt_of(*c),
|
||||
ValSrc::Calc(_) => "General".to_string(),
|
||||
};
|
||||
(f != "General").then_some(f)
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`,
|
||||
//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`).
|
||||
//!
|
||||
//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names
|
||||
//! for each report cell (items, subtotals and grand totals alike), not record by record: for
|
||||
//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written
|
||||
//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not
|
||||
//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{PivotTable, Workbook};
|
||||
|
||||
/// Longest formula accepted.
|
||||
pub const MAX_FORMULA_LEN: usize = 8_192;
|
||||
/// Most calculated fields per PivotTable.
|
||||
pub const MAX_CALC_FIELDS: usize = 256;
|
||||
/// Deepest chain of calculated fields using calculated fields.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const PREFIX: &str = "__pcf";
|
||||
|
||||
fn placeholder(i: usize) -> String {
|
||||
format!("{PREFIX}{i}")
|
||||
}
|
||||
|
||||
/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the
|
||||
/// order of `refs`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Compiled {
|
||||
pub body: Expr,
|
||||
/// Canonical names of the fields used.
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a
|
||||
/// source or calculated field.
|
||||
pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<Compiled, String> {
|
||||
let text = formula.trim();
|
||||
let text = text.strip_prefix('=').unwrap_or(text).trim();
|
||||
if text.is_empty() {
|
||||
return Err("The formula is empty.".into());
|
||||
}
|
||||
if text.len() > MAX_FORMULA_LEN {
|
||||
return Err("The formula is too long.".into());
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let at = |i: usize| chars.get(i).copied();
|
||||
let mut out = String::with_capacity(text.len() + 16);
|
||||
let mut refs: Vec<String> = Vec::new();
|
||||
let slot = |name: String, refs: &mut Vec<String>| -> String {
|
||||
let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| {
|
||||
refs.push(name);
|
||||
refs.len() - 1
|
||||
});
|
||||
placeholder(i)
|
||||
};
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
match c {
|
||||
'"' => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated text value.".into()),
|
||||
Some('"') if at(i + 1) == Some('"') => {
|
||||
out.push_str("\"\"");
|
||||
i += 2;
|
||||
}
|
||||
Some('"') => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
out.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
let mut name = String::new();
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated field name.".into()),
|
||||
Some('\'') if at(i + 1) == Some('\'') => {
|
||||
name.push('\'');
|
||||
i += 2;
|
||||
}
|
||||
Some('\'') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
name.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?;
|
||||
out.push_str(&slot(canon, &mut refs));
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
if matches!(at(i), Some('e' | 'E')) {
|
||||
let sign = usize::from(matches!(at(i + 1), Some('+' | '-')));
|
||||
if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) {
|
||||
for _ in 0..=sign {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c if c.is_alphabetic() || c == '_' => {
|
||||
let start = i;
|
||||
while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default();
|
||||
let mut j = i;
|
||||
while at(j).is_some_and(char::is_whitespace) {
|
||||
j += 1;
|
||||
}
|
||||
let is_call = at(j) == Some('(');
|
||||
match field(&word) {
|
||||
Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)),
|
||||
_ => out.push_str(&word),
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?;
|
||||
let mut problem: Option<String> = None;
|
||||
body.walk(&mut |e| {
|
||||
if problem.is_some() {
|
||||
return;
|
||||
}
|
||||
match e {
|
||||
Expr::Name(n) if n.starts_with(PREFIX) => {}
|
||||
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
|
||||
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
match problem {
|
||||
Some(p) => Err(p),
|
||||
None => Ok(Compiled { body, refs }),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(v: &Value) -> Expr {
|
||||
match v {
|
||||
Value::Number(n) => Expr::Number(*n),
|
||||
Value::Error(e) => Expr::Error(*e),
|
||||
Value::Text(t) => Expr::Text(t.clone()),
|
||||
Value::Bool(b) => Expr::Bool(*b),
|
||||
Value::Empty => Expr::Number(0.0),
|
||||
Value::Array(_) => literal(&v.scalar()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates a compiled formula with the given field values (in `refs` order).
|
||||
pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value {
|
||||
let expr = if c.refs.is_empty() {
|
||||
c.body.clone()
|
||||
} else {
|
||||
let mut call = Vec::with_capacity(c.refs.len() * 2 + 1);
|
||||
for i in 0..c.refs.len() {
|
||||
call.push(Expr::Name(placeholder(i)));
|
||||
call.push(literal(args.get(i).unwrap_or(&Value::Empty)));
|
||||
}
|
||||
call.push(c.body.clone());
|
||||
Expr::Call("LET".into(), call)
|
||||
};
|
||||
match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() {
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PivotTable's calculated fields, compiled against its source headers.
|
||||
pub struct CalcSet {
|
||||
names: Vec<String>,
|
||||
compiled: Vec<Result<Compiled, String>>,
|
||||
}
|
||||
|
||||
impl CalcSet {
|
||||
pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet {
|
||||
let names: Vec<String> = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect();
|
||||
let lookup = |n: &str| -> Option<String> {
|
||||
let n = n.trim();
|
||||
headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned()
|
||||
};
|
||||
let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect();
|
||||
CalcSet { names, compiled }
|
||||
}
|
||||
|
||||
pub fn index(&self, name: &str) -> Option<usize> {
|
||||
self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The compile error of a calculated field, if any.
|
||||
pub fn error(&self, i: usize) -> Option<&str> {
|
||||
self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Source fields a calculated field needs, directly or through other calculated fields.
|
||||
pub fn source_refs(&self, i: usize) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack = vec![i];
|
||||
while let Some(j) = stack.pop() {
|
||||
if !seen.get(j).copied().unwrap_or(true) {
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
for r in &c.refs {
|
||||
match self.index(r) {
|
||||
Some(k) => stack.push(k),
|
||||
None => {
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) {
|
||||
out.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a calculated field reaches itself through the fields it uses.
|
||||
pub fn is_cyclic(&self, i: usize) -> bool {
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack: Vec<usize> = match self.compiled.get(i) {
|
||||
Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
while let Some(j) = stack.pop() {
|
||||
if j == i {
|
||||
return true;
|
||||
}
|
||||
if seen.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
stack.extend(c.refs.iter().filter_map(|r| self.index(r)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The value of calculated field `i` for one report cell; `sum` gives a source field's sum
|
||||
/// over that cell's records.
|
||||
pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value {
|
||||
let mut memo: Vec<Option<Value>> = vec![None; self.names.len()];
|
||||
let mut active = vec![false; self.names.len()];
|
||||
self.value_in(wb, i, sum, &mut memo, &mut active, 0)
|
||||
}
|
||||
|
||||
fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option<Value>], active: &mut [bool], depth: usize) -> Value {
|
||||
if let Some(Some(v)) = memo.get(i) {
|
||||
return v.clone();
|
||||
}
|
||||
if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) {
|
||||
return Value::Error(CellError::Ref);
|
||||
}
|
||||
let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) };
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = true;
|
||||
}
|
||||
let args: Vec<Value> = c
|
||||
.refs
|
||||
.iter()
|
||||
.map(|r| match self.index(r) {
|
||||
Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1),
|
||||
None => sum(r),
|
||||
})
|
||||
.collect();
|
||||
let v = eval(wb, c, &args);
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = false;
|
||||
}
|
||||
if let Some(m) = memo.get_mut(i) {
|
||||
*m = Some(v.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fields(n: &str) -> Option<String> {
|
||||
["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiles_field_names() {
|
||||
let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]);
|
||||
// A field named like a cell reference is still a field; functions stay functions.
|
||||
let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Q1"]);
|
||||
let wb = Workbook::new();
|
||||
assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0));
|
||||
assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus"));
|
||||
assert!(compile("=A1*2", &fields).is_err());
|
||||
assert!(compile("='Nope'*2", &fields).is_err());
|
||||
assert!(compile("=Sales*(", &fields).is_err());
|
||||
assert!(compile("=", &fields).is_err());
|
||||
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_and_cyclic_chains_are_bounded() {
|
||||
// 300 calculated fields, each using the previous one, and a two-field cycle.
|
||||
let mut pt = PivotTable::default();
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() });
|
||||
for i in 1..300 {
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) });
|
||||
}
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&pt, &["Sales".to_string()]);
|
||||
let wb = Workbook::new();
|
||||
let sum = |_: &str| Value::Number(10.0);
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0));
|
||||
assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0));
|
||||
// Past the depth bound (the list itself is capped at MAX_CALC_FIELDS).
|
||||
assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields");
|
||||
// A small cycle within the cap.
|
||||
let mut small = PivotTable::default();
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&small, &["Sales".to_string()]);
|
||||
assert!(set.is_cyclic(0));
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.source_refs(0), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
|
@ -699,6 +699,18 @@ pub struct PivotTable {
|
|||
pub show_headers: bool,
|
||||
/// Area written by the last refresh.
|
||||
pub last_range: Option<RangeRef>,
|
||||
/// Calculated fields, usable in the Values area by name.
|
||||
pub calculated_fields: Vec<PivotCalcField>,
|
||||
}
|
||||
|
||||
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
|
||||
/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PivotCalcField {
|
||||
pub name: String,
|
||||
/// Formula text, starting with `=`.
|
||||
pub formula: String,
|
||||
}
|
||||
|
||||
impl Default for PivotTable {
|
||||
|
|
@ -720,6 +732,7 @@ impl Default for PivotTable {
|
|||
style: "PivotStyleLight16".into(),
|
||||
show_headers: true,
|
||||
last_range: None,
|
||||
calculated_fields: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,6 +480,13 @@ impl Dialog {
|
|||
d
|
||||
}
|
||||
"goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
|
||||
"pivotCalculatedField" => Dialog::form(
|
||||
"pivotCalculatedField",
|
||||
"Insert Calculated Field",
|
||||
"pivot.calculatedField",
|
||||
vec![Text { key: "name", label: "Name:" }, Text { key: "formula", label: "Formula:" }],
|
||||
json!({"pivot": p("pivot"), "name": p("name"), "formula": p("formula")}),
|
||||
),
|
||||
"goToSpecial" => Dialog::form(
|
||||
"goToSpecial",
|
||||
"Go To Special",
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if ui.button("Refresh").clicked() {
|
||||
app.run_or_alert("pivot.refresh", json!({"pivot": name}));
|
||||
}
|
||||
if ui.button("Calculated Field…").clicked() {
|
||||
app.run_or_alert("pivot.calculatedField", json!({"pivot": name}));
|
||||
}
|
||||
egui::ComboBox::from_id_salt("pv_layout").selected_text("Report Layout").show_ui(ui, |ui| {
|
||||
for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] {
|
||||
if ui.button(l).clicked() {
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ fn dialogs_open_and_close() {
|
|||
"commandSearch",
|
||||
"spelling",
|
||||
"goTo",
|
||||
"pivotCalculatedField",
|
||||
"about",
|
||||
] {
|
||||
h.state_mut().open_dialog(name, json!({}));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ use std::fmt::Write as _;
|
|||
use gridcraft_core::date::datetime_from_serial;
|
||||
use gridcraft_core::{CellRef, DateSystem, RangeRef, Value, number_to_text};
|
||||
use gridcraft_formula::quote_sheet;
|
||||
use gridcraft_model::{PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Workbook};
|
||||
use gridcraft_model::{
|
||||
PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Workbook,
|
||||
};
|
||||
|
||||
use crate::IoError;
|
||||
use crate::read::Ctx;
|
||||
|
|
@ -234,6 +236,8 @@ struct CField {
|
|||
index: HashMap<Key, u32>,
|
||||
itemized: bool,
|
||||
group: Option<Group>,
|
||||
/// A calculated field's formula (without `=`); such fields aren't database fields.
|
||||
formula: Option<String>,
|
||||
}
|
||||
|
||||
impl CField {
|
||||
|
|
@ -439,6 +443,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
|||
index: HashMap::new(),
|
||||
itemized: false,
|
||||
group: None,
|
||||
formula: None,
|
||||
});
|
||||
}
|
||||
let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name));
|
||||
|
|
@ -528,6 +533,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
|||
index: HashMap::new(),
|
||||
itemized: false,
|
||||
group: Some(make(*by)),
|
||||
formula: None,
|
||||
});
|
||||
prev = new;
|
||||
}
|
||||
|
|
@ -606,6 +612,24 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
|||
} else {
|
||||
None
|
||||
};
|
||||
// Calculated fields come after the database and grouping fields.
|
||||
for cf in &pt.calculated_fields {
|
||||
let f = cf.formula.trim();
|
||||
let f = f.strip_prefix('=').unwrap_or(f).trim();
|
||||
if cf.name.trim().is_empty() || f.is_empty() || fields.iter().any(|x| same(&x.name, &cf.name)) {
|
||||
continue;
|
||||
}
|
||||
fields.push(CField {
|
||||
name: cf.name.clone(),
|
||||
col: None,
|
||||
stats: Stats::default(),
|
||||
items: vec![],
|
||||
index: HashMap::new(),
|
||||
itemized: false,
|
||||
group: None,
|
||||
formula: Some(f.to_string()),
|
||||
});
|
||||
}
|
||||
Some(Cache { fields, axis_map, source_xml, records })
|
||||
}
|
||||
|
||||
|
|
@ -665,6 +689,9 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String {
|
|||
for f in &c.fields {
|
||||
let fmt = if f.group.is_some() { 14 } else { 0 };
|
||||
let _ = write!(s, "<cacheField name=\"{}\" numFmtId=\"{fmt}\"", esc_attr(&f.name));
|
||||
if let Some(formula) = &f.formula {
|
||||
let _ = write!(s, " formula=\"{}\"", esc_attr(formula));
|
||||
}
|
||||
if f.col.is_none() {
|
||||
s.push_str(" databaseField=\"0\"");
|
||||
}
|
||||
|
|
@ -764,7 +791,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
|||
}
|
||||
}
|
||||
}
|
||||
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| find_db(&v.source_col).map(|i| (i, v))).collect();
|
||||
let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name));
|
||||
let vals: Vec<(usize, &PivotValue)> =
|
||||
pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect();
|
||||
let axis_field = |i: usize| -> Option<&PivotField> {
|
||||
let p = c.axis_map.iter().position(|x| *x == Some(i))?;
|
||||
pt.rows.iter().chain(pt.columns.iter()).nth(p)
|
||||
|
|
@ -830,6 +859,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
|||
if vals.iter().any(|(j, _)| *j == i) {
|
||||
s.push_str(" dataField=\"1\"");
|
||||
}
|
||||
if f.formula.is_some() {
|
||||
s.push_str(" dragToRow=\"0\" dragToCol=\"0\" dragToPage=\"0\"");
|
||||
}
|
||||
match pt.layout {
|
||||
PivotLayout::Compact => {}
|
||||
PivotLayout::Outline => s.push_str(" compact=\"0\""),
|
||||
|
|
@ -990,7 +1022,8 @@ struct FieldIn {
|
|||
items: Vec<String>,
|
||||
/// (base field, grouping, group item captions); grouping `None` = unsupported grouping.
|
||||
group: Option<(Option<usize>, Option<PivotDateGroup>, Vec<String>)>,
|
||||
formula: bool,
|
||||
/// A calculated field's formula.
|
||||
formula: Option<String>,
|
||||
}
|
||||
|
||||
impl FieldIn {
|
||||
|
|
@ -1097,7 +1130,7 @@ fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option<CacheIn>
|
|||
database: f.flag("databaseField", true),
|
||||
items,
|
||||
group,
|
||||
formula: f.attr("formula").is_some(),
|
||||
formula: f.attr("formula").map(str::to_string),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1154,7 +1187,7 @@ fn items_of(e: &El) -> Vec<&El> {
|
|||
/// (source column name, date grouping) of a cache field used on an axis.
|
||||
fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> {
|
||||
let f = cache.fields.get(i)?;
|
||||
if f.formula {
|
||||
if f.formula.is_some() {
|
||||
cx.warn("calculated PivotTable fields are not supported");
|
||||
return None;
|
||||
}
|
||||
|
|
@ -1292,10 +1325,6 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
|
|||
for d in df.kids("dataField") {
|
||||
let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue };
|
||||
let Some(f) = cache.fields.get(i) else { continue };
|
||||
if f.formula {
|
||||
cx.warn("calculated PivotTable fields are not supported");
|
||||
continue;
|
||||
}
|
||||
let func = match d.attr("subtotal") {
|
||||
None => PivotFunc::Sum,
|
||||
Some(s) => func_from(s).unwrap_or_else(|| {
|
||||
|
|
@ -1334,6 +1363,18 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
|
|||
}
|
||||
}
|
||||
|
||||
// Calculated fields of the cache (each PivotTable on it can use them).
|
||||
for f in &cache.fields {
|
||||
if let Some(formula) = &f.formula
|
||||
&& !f.name.trim().is_empty()
|
||||
&& !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name))
|
||||
{
|
||||
let formula = formula.trim();
|
||||
let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") };
|
||||
pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula });
|
||||
}
|
||||
}
|
||||
|
||||
pt.anchor = loc.start;
|
||||
let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 };
|
||||
pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end));
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ fn workbook() -> Workbook {
|
|||
style: "PivotStyleMedium9".into(),
|
||||
show_headers: false,
|
||||
last_range: Some(rr("A2:H12")),
|
||||
calculated_fields: vec![],
|
||||
};
|
||||
// Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page
|
||||
// filter and a hidden-items filter on a column field.
|
||||
|
|
@ -163,6 +164,7 @@ fn workbook() -> Workbook {
|
|||
style: "PivotStyleDark3".into(),
|
||||
show_headers: true,
|
||||
last_range: Some(rr("K2:Z30")),
|
||||
calculated_fields: vec![],
|
||||
};
|
||||
// Pivot 3: defaults, defined-name source, compact.
|
||||
let p3 = PivotTable {
|
||||
|
|
@ -479,3 +481,29 @@ fn cache_found_by_workbook_cache_id() {
|
|||
assert_eq!(pts[0].values[0].func, PivotFunc::Average);
|
||||
assert_eq!(pts[0].rows[0].source_col, "K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_fields_round_trip() {
|
||||
let mut wb = workbook();
|
||||
{
|
||||
let p = wb.sheet_mut(1).unwrap().pivots.get_mut(0).unwrap();
|
||||
p.calculated_fields.push(PivotCalcField { name: "Price".into(), formula: "=Sales/Units".into() });
|
||||
p.calculated_fields.push(PivotCalcField { name: "Unit Bonus".into(), formula: "='Price'*0.1".into() });
|
||||
p.values.push(PivotValue { source_col: "Price".into(), name: "Sum of Price".into(), ..PivotValue::default() });
|
||||
}
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
let c1 = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition1.xml").unwrap().clone()).unwrap();
|
||||
assert!(c1.contains("<cacheField name=\"Price\" numFmtId=\"0\" formula=\"Sales/Units\" databaseField=\"0\">"), "{c1}");
|
||||
assert!(c1.contains("formula=\"'Price'*0.1\"") || c1.contains("formula=\"'Price'*0.1\""), "{c1}");
|
||||
let t1 = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap();
|
||||
assert!(t1.contains("<dataField name=\"Sum of Price\" fld=\"6\""), "{t1}");
|
||||
assert!(t1.contains("dataField=\"1\" dragToRow=\"0\""), "{t1}");
|
||||
let (back, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(report.warnings.iter().all(|w| !w.to_lowercase().contains("pivot")), "{:?}", report.warnings);
|
||||
let orig = &wb.sheet(1).unwrap().pivots;
|
||||
let read = &back.sheet(1).unwrap().pivots;
|
||||
for (a, b) in orig.iter().zip(read.iter()) {
|
||||
assert_eq!(a, b, "pivot {} differs", a.name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§8: calculated fields landed) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
|
|
@ -70,9 +70,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
routine in business.
|
||||
- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F)
|
||||
### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F)
|
||||
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
|
||||
`insert.pivotChart`, `pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing.
|
||||
`insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done).
|
||||
- **Impact:** pivots are the analyst's main tool.
|
||||
- **Estimate:** 20–30 h for beta (25–40 h full).
|
||||
|
||||
|
|
@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §8: calculated fields added |
|
||||
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
|
||||
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Overall: 258 / 290 commands implemented (89.0%).**
|
||||
**Overall: 259 / 290 commands implemented (89.3%).**
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
|
|
@ -21,7 +21,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
|
|||
| Table Design | 12 | 13 | 92% |
|
||||
| Chart Design | 8 | 8 | 100% |
|
||||
| Format | 3 | 3 | 100% |
|
||||
| PivotTable Analyze | 12 | 13 | 92% |
|
||||
| PivotTable Analyze | 13 | 13 | 100% |
|
||||
| PivotTable Design | 4 | 4 | 100% |
|
||||
|
||||
## File (10 / 11, 91%)
|
||||
|
|
@ -380,13 +380,14 @@ Implemented:
|
|||
- `chart.shapeFill` Shape Fill (Shape Styles)
|
||||
- `chart.shapeOutline` Shape Outline (Shape Styles)
|
||||
|
||||
## PivotTable Analyze (12 / 13, 92%)
|
||||
## PivotTable Analyze (13 / 13, 100%)
|
||||
|
||||
Implemented:
|
||||
|
||||
- `pivot.fieldList` Field List (Show)
|
||||
- `pivot.refresh` Refresh (Data)
|
||||
- `pivot.changeSource` Change Data Source (Data)
|
||||
- `pivot.calculatedField` Fields, Items & Sets (Calculations)
|
||||
- `pivot.addField` Add Field (Fields)
|
||||
- `pivot.removeField` Remove Field (Fields)
|
||||
- `pivot.moveField` Move Field (Fields)
|
||||
|
|
@ -397,10 +398,6 @@ Implemented:
|
|||
- `pivot.collapse` Collapse Field (Active Field)
|
||||
- `pivot.delete` Clear All (Actions)
|
||||
|
||||
Missing:
|
||||
|
||||
- `pivot.calculatedField` Fields, Items & Sets (Calculations)
|
||||
|
||||
## PivotTable Design (4 / 4, 100%)
|
||||
|
||||
Implemented:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap detail
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; milestones moved here from ROADMAP.md, beta gates added) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTable calculated fields) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work
|
||||
list is [`gaps.md`](gaps.md); numbers and method are in
|
||||
|
|
@ -27,7 +27,7 @@ workflow, would make GridCraft pre-alpha.
|
|||
| Build a model: enter data and formulas, recalc, save XLSX, reopen | yes | 492 working functions, dynamic arrays, LET/LAMBDA, names, tables; XLSX round-trip tests (`crates/xlsx/src/tests/roundtrip.rs`). Edge-case bugs (#57 15-digit equality, stale spills #133/#139) give wrong results in specific cases, not in the workflow | 0 (fixes are beta work, gap #6) |
|
||||
| Open a colleague's XLSX, edit, save, hand it back | partial, not blocking | Typical workbooks open and save with cells, styles, CF, validation, tables, names and pivots; unmodelled parts are dropped with a warning (gap #3); Excel-side repair prompts unverified (gap #1); ~100 MB files fail (#175) | 0 for alpha (gaps #1–#3 are beta gates) |
|
||||
| Format and present: number formats, styles, conditional formatting, print or PDF | yes | Full number-format language, 47 cell styles, all CF kinds, Page Break Preview, PDF export and printing. PDF can't carry non-Latin text (gap #5) | 0 |
|
||||
| Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Slicers and calculated fields missing (gap #8) | 0 |
|
||||
| Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Calculated fields done; slicers missing (gap #8) | 0 |
|
||||
| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter and combo charts are created, edited and survive save and reopen; histogram, box, waterfall, funnel, treemap, sunburst and stock lose their kind in XLSX (fix in PRs #158, #161); no axis options or trendlines (gap #4) | 0 for alpha (2–4 h to merge the kind fixes) |
|
||||
| Import and export CSV | yes | Encoding and delimiter detection on read, CSV/TSV write; exports raw rather than displayed values (gap #19) | 0 |
|
||||
|
||||
|
|
@ -112,6 +112,7 @@ to check our files, and Windows machines with the reported GPU drivers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotTable calculated fields landed |
|
||||
| 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges |
|
||||
| 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) |
|
||||
| 2026-10-10 | major | Created: current focus, beta gates (~160–240 h), milestones moved from ROADMAP.md, post-beta ranking |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTables: calculated fields) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
|
|
@ -84,7 +84,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
|||
| Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) |
|
||||
| Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes |
|
||||
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
|
||||
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
|
||||
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
|
||||
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
|
||||
|
|
@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotTables: calculated fields (`pivot.calculatedField`) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
|
||||
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
|
||||
|
|
|
|||
Loading…
Reference in New Issue