From 1a7c16eb9abc502107d696c8de82dc4ab3629388 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:31:49 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 1 + crates/chart/src/resolve.rs | 44 +++++- crates/chart/src/tests.rs | 27 ++++ crates/engine/src/cmd/insert.rs | 1 + crates/engine/src/cmd/pivot.rs | 165 +++++++++++++++++++++ crates/engine/src/cmd/sheet.rs | 6 + crates/engine/src/pivot.rs | 170 +++++++++++++++++++++- crates/model/src/features.rs | 4 + crates/xlsx/src/chart.rs | 5 + crates/xlsx/src/chartex.rs | 1 + crates/xlsx/src/tests/fixtures.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 1 + crates/xlsx/src/tests/smooth_roundtrip.rs | 2 + docs/chart-parity.md | 7 +- docs/gaps.md | 7 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 5 +- 17 files changed, 435 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..55b1ee0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index f3d517e..3d808e1 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -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> { + 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 { + eval_list_in(wb, sheet, formula, 0) +} + +fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec { 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], diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 09c46f8..24c1fd2 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -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); +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 4c96005..f882f2e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -606,6 +606,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result { 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); diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 27e0aab..4e62f13 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -43,6 +43,15 @@ pub fn specs() -> Vec { 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 { fn delete(s: &mut Session, p: &Json) -> Result { 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 { }) } +// ---------------------------------------------------------------- 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 { + 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 { @@ -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>> { + 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::(), 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(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e6268..36990ed 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -538,6 +538,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { 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() { diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index bc27317..daf6085 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -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, + /// 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, } 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, 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 = 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 = Vec::new(); @@ -829,7 +851,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result = 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 = + 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 (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 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 { + let q = quote_sheet(sheet); + let c = origin.col.checked_add(col)?; + let letters = gridcraft_core::col_to_letters(c); + let mut parts: Vec = 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 { + let rows: Vec = 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 = (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 }; diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b9fe2ce..2448e3e 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -390,6 +390,10 @@ pub struct Chart { /// Source range when created from a selection (for Switch Row/Column). pub source: Option, 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, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index ff566dc..3ad4035 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option { 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, "", 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, "{}", esc(link)); + } s.push_str(""); match &ch.title { Some(t) if !t.is_empty() => { diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index 5fac0c0..2ab8c22 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -134,6 +134,7 @@ pub fn read_chartex(root: &El) -> Option { y_title: axis_title("1"), source: None, by_rows: false, + pivot: None, }) } diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 4f0c6b7..5533e01 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -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(); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 12bba62..22cd52e 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -299,6 +299,7 @@ fn sample() -> Workbook { y_title: Some("Amount".into()), source: None, by_rows: false, + pivot: None, }; for kind in [ ChartKind::ColumnClustered, diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs index aa1c427..1461613 100644 --- a/crates/xlsx/src/tests/smooth_roundtrip.rs +++ b/crates/xlsx/src/tests/smooth_roundtrip.rs @@ -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(); diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 0577929..fcc5046 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8..349dfba 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d..01bb108 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01..7033273 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 |