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:
vangel76 2026-10-11 12:24:50 +02:00
parent 9f896c1249
commit 400ab591cb
15 changed files with 704 additions and 35 deletions

View File

@ -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 |

View File

@ -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();

View File

@ -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;

View File

@ -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)
}
}),

View File

@ -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());
}
}

View File

@ -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![],
}
}
}

View File

@ -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",

View File

@ -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() {

View File

@ -227,6 +227,7 @@ fn dialogs_open_and_close() {
"commandSearch",
"spelling",
"goTo",
"pivotCalculatedField",
"about",
] {
h.state_mut().open_dialog(name, json!({}));

View File

@ -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));

View File

@ -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=\"&apos;Price&apos;*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);
}
}

View File

@ -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 |

View File

@ -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:

View File

@ -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 |

View File

@ -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 |