Charts: PivotCharts (insert.pivotChart) (#311)

A chart linked to a PivotTable: series are built from the report
(innermost row items x column items/value fields, totals excluded) and
rebuilt on every refresh or layout change; c:pivotSource round trip;
chart series accept union references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:31:49 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit 1a7c16eb9a
17 changed files with 435 additions and 20 deletions
+1
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 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
| 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 -1
View File
@@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
Some((sh, r))
}
/// Evaluates a series formula to a flat list of values (row-major), capped.
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
fn union_parts(f: &str) -> Option<Vec<&str>> {
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
let mut parts = Vec::new();
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
for (i, c) in inner.char_indices() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth -= 1,
',' if !quoted && depth == 0 => {
parts.push(inner.get(start..i)?);
start = i + 1;
}
_ => {}
}
if depth < 0 {
return None;
}
}
parts.push(inner.get(start..)?);
(parts.len() > 1).then_some(parts)
}
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0)
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
}
if depth < 4
&& let Some(parts) = union_parts(f)
{
let mut out = Vec::new();
for p in parts {
if out.len() >= MAX_POINTS {
break;
}
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
}
return out;
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
v => vec![v],
+27
View File
@@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart {
y_title: Some("Revenue".into()),
source: None,
by_rows: false,
pivot: None,
}
}
@@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() {
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
}
#[test]
fn union_series_references() {
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() {
s.set_value(CellRef::new(r as u32, 0), Value::from(*l));
s.set_value(CellRef::new(r as u32, 1), Value::Number(*v));
}
}
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
name: Some("\"Sum of X\"".into()),
categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()),
values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(),
..Series::default()
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]);
assert_eq!(d.series[0].name, "Sum of X");
assert_eq!(d.categories, vec!["a", "b", "d"]);
// Deeply nested parentheses from a file are bounded, not a crash.
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
let _ = resolve(&wb, 0, &c);
}
+1
View File
@@ -606,6 +606,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
y_title: None,
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
by_rows,
pivot: None,
};
edit(s, |cx| {
cx.sheet_mut(sheet)?.charts.push(chart);
+165
View File
@@ -43,6 +43,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
summarize_table
),
cmd!(
"insert.pivotChart",
"PivotChart",
["Insert", "Charts"],
None,
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
has_doc,
insert_pivot_chart
),
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),
@@ -1171,6 +1180,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = find_pivot(s, p)?;
edit(s, |cx| {
// Its PivotCharts become ordinary charts.
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
if let Some((sheet, name)) = link {
for i in 0..cx.wb.sheets.len() {
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
c.pivot = None;
}
}
}
}
pv::clear_output(&mut cx.wb, si, pi);
let sh = cx.sheet_mut(si)?;
if pi < sh.pivots.len() {
@@ -1181,6 +1202,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
})
}
// ---------------------------------------------------------------- PivotChart
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
if let Some(t) = str_param(p, "pivot") {
let d = s.doc()?;
let (sheet, addr) = match t.rsplit_once('!') {
Some((sh, a)) => {
let sh = sh.trim();
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
(d.wb.sheet_index(&sh), a)
}
None => (Some(d.wb.active_sheet), t),
};
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
}
}
find_pivot(s, p)
}
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, pi) = chart_pivot(s, p)?;
let d = s.doc()?;
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
}
let series = pv::chart_series(&sh.name, area.start, &out);
let t = str_param(p, "type").unwrap_or("column");
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
let title = match p.get("title") {
Some(Json::String(t)) => Some(t.clone()),
Some(Json::Null) => None,
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
};
let id = d.wb.next_object_id();
let link = pv::chart_link(&sh.name, &pt.name);
let pivot_name = pt.name.clone();
let n = series.len();
let chart = Chart {
id,
kind,
anchor: Anchor {
cell: at,
dx: 0.0,
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
data_labels: false,
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
style: 1,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: Some(link),
};
edit(s, |cx| {
cx.sheet_mut(si)?.charts.push(chart);
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
})
}
// ---------------------------------------------------------------- recommended
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
@@ -1550,6 +1645,76 @@ mod tests {
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
}
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
}
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
let d = s.doc().unwrap();
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
data.series.into_iter().map(|x| x.values).collect()
}
#[test]
fn pivot_chart_follows_the_report() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
// Any cell of the report picks the PivotTable.
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
assert_eq!(ch.title.as_deref(), Some("Total"));
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
// A column field: one series per item, rebound by the layout change.
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
let ch = pivot_chart(&s);
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
// Nested row fields: the innermost items only, as a union around the parent rows.
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
let ch = pivot_chart(&s);
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
let pts = plotted(&s);
assert_eq!(pts[0].len(), 9);
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
// Survives an XLSX round trip as a PivotChart.
let wb = (*s.doc().unwrap().wb).clone();
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
let si = back.sheet_index("Sheet2").unwrap();
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
// Renaming the sheet keeps the link; a refresh still rebinds.
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
assert_eq!(pivot_chart(&s).series.len(), 3);
// Deleting the PivotTable leaves an ordinary chart.
s.execute("pivot.delete", json!({})).unwrap();
assert_eq!(pivot_chart(&s).pivot, None);
}
#[test]
fn pivot_chart_errors() {
let mut s = sales();
// No PivotTable yet.
assert!(s.execute("insert.pivotChart", json!({})).is_err());
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
// Not in the report; no values.
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
assert_eq!(r["series"], 1);
let ch = pivot_chart(&s);
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
assert_eq!(ch.title, None);
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
}
#[test]
fn replace_protection() {
let mut s = sales();
+6
View File
@@ -538,6 +538,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
se.categories = se.categories.as_deref().map(fix);
se.name = se.name.as_deref().map(fix);
}
// A PivotChart follows its PivotTable's sheet.
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
&& sheet.eq_ignore_ascii_case(&old)
{
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
}
}
}
for n in cx.wb.names.iter_mut() {
+163 -7
View File
@@ -503,6 +503,12 @@ pub struct Output {
/// Rows above the body (report filters plus a blank row).
pub body_offset: u32,
pub width: u32,
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
pub chart_rows: Vec<u32>,
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
pub chart_cols: Vec<(u32, String)>,
/// Grid column holding the innermost row labels (`None` without row fields).
pub label_col: Option<u32>,
}
impl Output {
@@ -546,6 +552,8 @@ enum LineKind {
struct Line {
kind: LineKind,
/// A category of a PivotChart: an innermost (or collapsed) item.
leaf: bool,
path: Vec<u32>,
values: bool,
/// (column, value, indent, number format)
@@ -587,14 +595,21 @@ impl Ctx<'_> {
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
out.push(Line {
kind: LineKind::Item(depth + 1 < n),
leaf: true,
path: prefix.clone(),
values: true,
labels: vec![(col, v, indent, fmt)],
});
} else {
let top = self.pt.subtotals && self.pt.subtotals_top;
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
self.flatten(&node.children, depth + 1, prefix, out);
if self.pt.subtotals && !self.pt.subtotals_top {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
@@ -621,12 +636,13 @@ impl Ctx<'_> {
labels.push((depth, v, 0, fmt));
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
if depth + 1 >= n || collapsed || node.children.is_empty() {
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
} else {
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
if self.pt.subtotals {
out.push(Line {
kind: LineKind::Subtotal,
leaf: false,
path: prefix.clone(),
values: true,
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
@@ -755,7 +771,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
let mut lines: Vec<Line> = Vec::new();
if rows.is_empty() {
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
} else {
if pt.layout == PivotLayout::Tabular {
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
@@ -763,7 +779,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
}
if pt.grand_totals_cols {
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
lines.push(Line {
kind: LineKind::Grand,
leaf: false,
path: vec![],
values: true,
labels: vec![(0, Value::text("Grand Total"), 0, None)],
});
}
}
let mut specs: Vec<ColSpec> = Vec::new();
@@ -829,7 +851,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
if empty {
let r = g.push_row(RowKind::Placeholder)?;
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
return Ok(Output {
width: g.width as u32,
grid: g.rows,
row_kinds: g.kinds,
body_offset,
chart_rows: vec![],
chart_cols: vec![],
label_col: None,
});
}
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
@@ -949,6 +979,33 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
}),
})
.collect();
let mut chart_rows: Vec<u32> = Vec::new();
let chart_cols: Vec<(u32, String)> = data_cols
.iter()
.enumerate()
.filter_map(|(j, (si, vi))| {
let spec = specs.get(*si)?;
if spec.kind != ColKind::Leaf {
return None;
}
let mut parts: Vec<String> =
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
if let Some(vi) = vi
&& (k > 1 || parts.is_empty())
{
parts.push(val_name(*vi));
}
Some(((l + j) as u32, parts.join(" - ")))
})
.filter(|_| k > 0)
.collect();
let label_col = if rows.is_empty() {
None
} else if compact {
Some(0)
} else {
Some(rows.len().saturating_sub(1) as u32)
};
for line in &lines {
let rk = match line.kind {
LineKind::Item(b) => RowKind::Item(b),
@@ -961,6 +1018,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
};
let r = g.push_row(rk)?;
if line.leaf && k > 0 {
chart_rows.push(r as u32);
}
for (c, v, indent, fmt) in &line.labels {
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
}
@@ -1015,7 +1075,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
}
}
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
}
// ---------------------------------------------------------------- styling and writing
@@ -1205,9 +1265,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
if let Some(p) = shm.pivots.get_mut(idx) {
p.last_range = Some(area);
}
let sheet_name = shm.name.clone();
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
Ok(area)
}
// ---------------------------------------------------------------- PivotCharts
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
pub fn chart_link(sheet: &str, pivot: &str) -> String {
format!("{}!{pivot}", quote_sheet(sheet))
}
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
let t = link.trim();
let t = match t.strip_prefix('[') {
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
None => t,
};
let (sheet, name) = t.rsplit_once('!')?;
let sheet = sheet.trim();
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
let name = name.trim();
if sheet.is_empty() || name.is_empty() {
return None;
}
Some((sheet, name.to_string()))
}
/// Whether a PivotChart link points at `pivot` on `sheet`.
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
}
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
let q = quote_sheet(sheet);
let c = origin.col.checked_add(col)?;
let letters = gridcraft_core::col_to_letters(c);
let mut parts: Vec<String> = Vec::new();
let mut i = 0;
while let Some(&start) = rows.get(i) {
let mut end = start;
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
end += 1;
i += 1;
}
i += 1;
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
}
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(format!("({})", parts.join(","))),
}
}
/// Most category rows a PivotChart plots.
const MAX_CHART_ROWS: usize = 10_000;
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
/// items, one series per item column (column items × value fields); totals are left out.
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
out.chart_cols
.iter()
.filter_map(|(c, name)| {
Some(Series {
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
categories: categories.clone(),
values: rows_ref(sheet, origin, *c, &rows)?,
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
})
})
.collect()
}
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
if hits.is_empty() {
return;
}
let series = chart_series(sheet, origin, out);
for i in hits {
let Some(sh) = wb.sheet_mut(i) else { continue };
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
ch.series = series.clone();
}
}
}
/// Clears a pivot's last output area.
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
let Some(shm) = wb.sheet_mut(sheet) else { return };
+4
View File
@@ -390,6 +390,10 @@ pub struct Chart {
/// Source range when created from a selection (for Switch Row/Column).
pub source: Option<String>,
pub by_rows: bool,
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
/// whenever the PivotTable is refreshed or its layout changes.
#[serde(default)]
pub pivot: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+5
View File
@@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
source: None,
by_rows: false,
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
})
}
@@ -465,6 +466,10 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
if (1..=48).contains(&ch.style) {
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
}
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
}
s.push_str("<c:chart>");
match &ch.title {
Some(t) if !t.is_empty() => {
+1
View File
@@ -134,6 +134,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
y_title: axis_title("1"),
source: None,
by_rows: false,
pivot: None,
})
}
+1
View File
@@ -418,6 +418,7 @@ fn chartex_parts_as_excel_expects_them() {
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
wb.sheets = vec![std::sync::Arc::new(sheet)];
let bytes = crate::write_xlsx(&wb).unwrap();
+1
View File
@@ -299,6 +299,7 @@ fn sample() -> Workbook {
y_title: Some("Amount".into()),
source: None,
by_rows: false,
pivot: None,
};
for kind in [
ChartKind::ColumnClustered,
@@ -33,6 +33,7 @@ fn smooth_line_setting_round_trips() {
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
let mut smooth_series = base_series.clone();
smooth_series.smooth = true;
@@ -50,6 +51,7 @@ fn smooth_line_setting_round_trips() {
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
wb.sheets = vec![Arc::new(s)];
let bytes = write_xlsx(&wb).unwrap();
+4 -3
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit of the chart model, renderer and XLSX chart parts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotCharts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
@@ -53,7 +53,7 @@ open PRs #158, #161, and issue #190.
| Recommended Charts | yes | partial (Insert ▸ Recommended) |
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
| Sparklines (line, column, win/loss, markers, axis) | yes | line, column, win/loss |
| PivotCharts | yes | **missing** |
| PivotCharts | yes | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only |
| Animation on data change | yes (Windows) | no |
## XLSX round-trip
@@ -72,11 +72,12 @@ back.
4. Trendlines and error bars (maths shared with LINEST/LOGEST/GROWTH): 6–10 h.
5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
7. Chart sheets, PivotCharts, templates: 6–10 h. Filled map: owner decision on boundary data.
7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
| 2026-10-10 | minor | Smooth lines (#62) and Switch Row/Column on file charts (#116) landed; chart resize bug (#190) |
| 2026-10-10 | major | Created from a source audit: 25 kinds (25 of ~60 subtypes), element gaps, round-trip loss |
+4 -3
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: PivotCharts 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 fields; unverified in Excel (F)
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
`insert.pivotChart`, `pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing.
`pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing. PivotCharts done (no field buttons).
- **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: PivotCharts 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 -4
View File
@@ -4,13 +4,13 @@
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 |
|---|---|---|---|
| File | 10 | 11 | 91% |
| Home | 94 | 94 | 100% |
| Insert | 15 | 25 | 60% |
| Insert | 16 | 25 | 64% |
| Draw | 4 | 4 | 100% |
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
@@ -142,7 +142,7 @@ Implemented:
- `edit.goToSpecial` Go To Special (Editing)
- `home.addIns` Add-ins (Add-ins)
## Insert (15 / 25, 60%)
## Insert (16 / 25, 64%)
Implemented:
@@ -154,6 +154,7 @@ Implemented:
- `insert.icons` Icons (Illustrations)
- `insert.recommendedCharts` Recommended Charts (Charts)
- `insert.chart` Charts (Charts)
- `insert.pivotChart` PivotChart (Charts)
- `insert.sparkline` Sparklines (Sparklines)
- `insert.link` Link (Links)
- `review.newComment` Comment (Comments)
@@ -166,7 +167,6 @@ Missing:
- `insert.smartArt` SmartArt (Illustrations)
- `insert.screenshot` Screenshot (Illustrations)
- `insert.pivotChart` PivotChart (Charts)
- `insert.map` Maps (Charts)
- `insert.slicer` Slicer (Filters)
- `insert.timeline` Timeline (Filters)
+3 -2
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: PivotCharts) · **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, PivotCharts, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, 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: PivotCharts (`insert.pivotChart`) |
| 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 |