From ed2bfdf17e692a5c915e867eef194aa275ef30f0 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:41:01 -0400 Subject: [PATCH 01/17] PivotTables: slicers from XLSX files are shown, filter their PivotTables and are saved A slicer can now target a PivotTable field as well as a table column (`SlicerSource::Pivot`). PivotTable slicers are read from the drawing frame (the a14 choice of the markup-compatibility block, not its fallback), the slicer part, the slicer cache (tabular items with their selected flags, sort order, crossFilter, showMissing) and the sheet/workbook x14 ext lists, so they no longer warn as a skipped embedded object. Real SmartArt and OLE objects still warn. The tiles are the pivot field's items; the selection is the PivotTables' item filter on the field (their hidden items), so the two always agree. Clicking a tile sets that filter on every connected PivotTable and refreshes them; a field outside Rows and Columns gets a hidden filter with no report filter cell, as in Excel. Saving writes the slicer cache, slicer part, an sle:slicer graphicFrame in an mc:AlternateContent block with a fallback shape, the workbook x14:slicerCaches and sheet x14:slicerList ext entries, content types and relationships. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 86 ++++- crates/engine/src/cmd/sheet.rs | 8 + crates/engine/src/cmd/slicer.rs | 217 ++++++++---- crates/engine/src/pivot.rs | 89 ++++- crates/engine/src/tests.rs | 4 +- crates/model/src/features.rs | 101 +++++- crates/ui-egui/src/chartview.rs | 29 +- crates/xlsx/src/drawing.rs | 10 + crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/pivot.rs | 101 +++++- crates/xlsx/src/read.rs | 4 + crates/xlsx/src/sheet_read.rs | 5 +- crates/xlsx/src/sheet_write.rs | 18 +- crates/xlsx/src/slicer.rs | 481 +++++++++++++++++++++++++++ crates/xlsx/src/tests/pivot_tests.rs | 106 +++++- crates/xlsx/src/write.rs | 13 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 11 +- docs/target-app-parity.md | 5 +- 19 files changed, 1169 insertions(+), 125 deletions(-) create mode 100644 crates/xlsx/src/slicer.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b4..87757f5921 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -479,8 +479,8 @@ fn add_to_area( take_from_axes(pt, &field); let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); let entry = match existing { - Some(i) => pt.filters.remove(i), - None => PivotFilter { source_col: field, selected: None }, + Some(i) => PivotFilter { hidden: false, ..pt.filters.remove(i) }, + None => PivotFilter { source_col: field, selected: None, hidden: false }, }; let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); pt.filters.insert(at, entry); @@ -517,7 +517,7 @@ fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> let i = pt .filters .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) + .position(|f| f.source_col.eq_ignore_ascii_case(field) && !f.hidden && !in_axes(pt, &f.source_col)) .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; Ok(pt.filters.remove(i).source_col) } @@ -747,7 +747,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), - "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), + "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !f.hidden && !in_axes(pt, &f.source_col)})).collect::>(), "layout": layout_name(pt.layout), "grandTotalsRows": pt.grand_totals_rows, "grandTotalsCols": pt.grand_totals_cols, @@ -782,7 +782,7 @@ fn fields(s: &mut Session, p: &Json) -> Result { if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { areas.push("values"); } - if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { + if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h) && !f.hidden) && !in_axes(pt, h) { areas.push("filters"); } json!({ @@ -1111,7 +1111,7 @@ fn filter(s: &mut Session, p: &Json) -> Result { let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); match (i, selected) { (Some(i), None) => { - if in_axes(pt, &field) { + if in_axes(pt, &field) || pt.filters.get(i).is_some_and(|f| f.hidden) { pt.filters.remove(i); } else if let Some(f) = pt.filters.get_mut(i) { f.selected = None; @@ -1122,7 +1122,7 @@ fn filter(s: &mut Session, p: &Json) -> Result { f.selected = Some(sel); } } - (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel), hidden: false }), (None, None) => {} } Ok(json!({"field": field})) @@ -1355,7 +1355,7 @@ 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. + // Its PivotCharts become ordinary charts, and its slicers lose it. 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() { @@ -1366,6 +1366,7 @@ fn delete(s: &mut Session, p: &Json) -> Result { } } } + super::slicer::drop_pivot(&mut cx.wb, &sheet, &name); } pv::clear_output(&mut cx.wb, si, pi); let sh = cx.sheet_mut(si)?; @@ -2308,4 +2309,73 @@ mod tests { // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); } + + #[test] + fn pivot_slicer_selects_items_of_its_pivot_tables() { + use gridcraft_model::{Anchor, PivotSort, Slicer, SlicerCrossFilter, SlicerPivot, SlicerSource}; + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + let slicer = |id: u32, field: &str, pivots: Vec| Slicer { + id, + source: SlicerSource::Pivot { + pivots, + field: field.into(), + sort: PivotSort::Asc, + cross_filter: SlicerCrossFilter::default(), + show_missing: true, + }, + caption: field.into(), + anchor: Anchor { width: 150.0, height: 200.0, ..Anchor::default() }, + columns: 1, + name: String::new(), + style: String::new(), + row_height: None, + }; + { + let d = s.doc_mut().unwrap(); + let si = d.wb.active_sheet; + let sheet = d.wb.sheets[si].name.clone(); + let sh = std::sync::Arc::make_mut(&mut d.wb).sheet_mut(si).unwrap(); + let target = vec![SlicerPivot { sheet: sheet.clone(), name: "PivotTable1".into() }]; + sh.slicers.push(slicer(1, "Region", target.clone())); + sh.slicers.push(slicer(2, "Product", target)); + // A slicer whose PivotTable is gone shows no tiles and ignores clicks. + sh.slicers.push(slicer(3, "Region", vec![SlicerPivot { sheet, name: "Missing".into() }])); + } + let tiles = |s: &Session, id: u32| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let sl = sh.slicers.iter().find(|x| x.id == id).unwrap(); + crate::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet, sl).into_iter().map(|t| (t.label, t.selected)).collect::>() + }; + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), true), ("West".into(), true)]); + assert!(tiles(&s, 3).is_empty()); + assert!(s.execute("slicer.toggle", json!({"slicer": 3, "value": "East"})).is_err()); + + // A field on Rows: clicking a tile selects that item of the field and refreshes the table. + s.execute("slicer.toggle", json!({"slicer": 1, "value": "East"})).unwrap(); + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), false), ("West".into(), false)]); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Region").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["East".to_string()]), false)); + assert!(find_row(&s, "East").is_some()); + assert!(find_row(&s, "North").is_none() && find_row(&s, "West").is_none()); + s.execute("slicer.clear", json!({"slicer": 1})).unwrap(); + assert!(pt(&s).filters.is_empty()); + assert!(find_row(&s, "North").is_some()); + + // A field the table doesn't show: a hidden filter, with no report filter cell. + let anchor = pt(&s).anchor; + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Apples"})).unwrap(); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Product").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["Apples".to_string()]), true)); + assert_eq!(pt(&s).anchor, anchor, "a hidden filter adds no filter rows"); + let total = find_row(&s, "Grand Total").map(|r| val(&s, &CellRef::new(r, anchor.col + 1).a1()).as_f64().unwrap()).unwrap(); + assert!(total > 0.0 && total < TOTAL, "{total}"); + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Pears", "multi": true})).unwrap(); + assert_eq!(tiles(&s, 2), vec![("Apples".into(), true), ("Pears".into(), true), ("Plums".into(), false)]); + s.execute("slicer.selectAll", json!({"slicer": 2})).unwrap(); + assert!(pt(&s).filters.is_empty()); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e9..6d2602e7cf 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -806,6 +806,14 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { ch.pivot = Some(crate::pivot::chart_link(&name, &pivot)); } } + // So does a slicer. + for sl in sh.slicers.iter_mut() { + if let gridcraft_model::SlicerSource::Pivot { pivots, .. } = &mut sl.source { + for p in pivots.iter_mut().filter(|p| p.sheet.eq_ignore_ascii_case(&old)) { + p.sheet = name.clone(); + } + } + } } for n in cx.wb.names.iter_mut() { if let Ok(e) = gridcraft_formula::parse(&n.formula) { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index 00342a5a9a..3e388b806f 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -1,12 +1,16 @@ -//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! Slicers: on-sheet widgets that filter a table column or a PivotTable field by clicking value +//! tiles. //! -//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a -//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and -//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / -//! `table.insertSlicer`) selects everything, i.e. applies no filter. +//! A slicer is a thin front-end over the existing filter machinery. A table slicer's selection is +//! written as a [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's +//! range) and [`super::data::apply_filter`] hides the rows. A PivotTable slicer's selection is each +//! connected PivotTable's [`PivotFilter`] on the field, and the PivotTables are refreshed. Creating +//! a slicer (`insert.slicer` / `table.insertSlicer`) selects everything, i.e. applies no filter. use gridcraft_core::CellRef; use gridcraft_model::*; + +use crate::pivot::{self as pv, SlicerTile}; use serde_json::{Value as Json, json}; use super::*; @@ -60,28 +64,95 @@ pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> out } -/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the -/// slicer has no filter (`selected == None`). -pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { - column_values(wb, sheet, &sl.table, &sl.column) - .into_iter() - .map(|v| { - let on = match &sl.selected { - None => true, - Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), - }; - (v, on) +/// A slicer's tiles, for the UI. Every tile is selected when the slicer has no filter. +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec { + match &sl.source { + SlicerSource::Table { table, column, selected } => column_values(wb, sheet, table, column) + .into_iter() + .map(|v| { + let on = match selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + SlicerTile { key: v.clone(), label: v, selected: on, has_data: true } + }) + .collect(), + SlicerSource::Pivot { pivots, field, sort, cross_filter, .. } => { + let Some((si, pi)) = slicer_pivots(wb, pivots).into_iter().next() else { return vec![] }; + let Some(pt) = wb.sheet(si).and_then(|sh| sh.pivots.get(pi)) else { return vec![] }; + pv::slicer_tiles(wb, pt, si, field, *sort, *cross_filter) + } + } +} + +/// The connected PivotTables that exist, as (sheet, index). +pub fn slicer_pivots(wb: &Workbook, pivots: &[SlicerPivot]) -> Vec<(usize, usize)> { + pivots + .iter() + .filter_map(|p| { + let si = wb.sheet_index(&p.sheet)?; + let pi = wb.sheet(si)?.pivots.iter().position(|t| t.name.eq_ignore_ascii_case(&p.name))?; + Some((si, pi)) }) .collect() } +/// Sets `field`'s item selection (`None`: everything) on each PivotTable and refreshes them. A field +/// outside Rows and Columns gets a hidden filter (no report filter cell), as an Excel slicer does. +fn set_pivot_selection(wb: &mut Workbook, pivots: &[(usize, usize)], field: &str, selected: Option>) -> Result<()> { + for &(si, pi) in pivots { + let Some(pt) = wb.sheet_mut(si).and_then(|sh| sh.pivots.get_mut(pi)) else { continue }; + let on_axis = pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)); + match (pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)), selected.clone()) { + (Some(i), None) => { + if on_axis || pt.filters.get(i).is_some_and(|f| f.hidden) { + pt.filters.remove(i); + } else if let Some(f) = pt.filters.get_mut(i) { + f.selected = None; + } + } + (Some(i), Some(sel)) => { + if let Some(f) = pt.filters.get_mut(i) { + f.selected = Some(sel); + } + } + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.to_string(), selected: Some(sel), hidden: !on_axis }), + (None, None) => {} + } + pv::refresh(wb, si, pi, false).map_err(EngineError::Other)?; + } + Ok(()) +} + +/// The selection after clicking `value` among `all`: a single click shows only that value (or, +/// on the sole selected value, clears the filter); `multi` toggles it. Everything selected is the +/// same as no filter. +fn next_selection(current: Option>, all: &[String], value: &str, multi: bool) -> Option> { + if multi { + let mut set: Vec = current.unwrap_or_else(|| all.to_vec()); + match set.iter().position(|x| x.eq_ignore_ascii_case(value)) { + Some(pos) => { + set.remove(pos); + } + None => set.push(value.to_string()), + } + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(value)) => None, + _ => Some(vec![value.to_string()]), + } + } +} + /// Writes the slicer's current selection into the sheet filter and re-applies it. fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { let info = wb.sheet(sheet).and_then(|sh| { let sl = sh.slicers.iter().find(|s| s.id == id)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; - Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + let SlicerSource::Table { table, column, selected } = &sl.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; + Some((t.range, t.range.start.col + ci as u32, selected.clone())) }); let Some((table_range, abs_col, selected)) = info else { return }; { @@ -127,12 +198,13 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result { let id = d.wb.next_object_id(); let slicer = Slicer { id, - table, - column: column.clone(), + source: SlicerSource::Table { table, column: column.clone(), selected: None }, caption: column, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, - selected: None, columns, + name: String::new(), + style: String::new(), + row_height: None, }; edit(s, |cx| { cx.sheet_mut(sheet)?.slicers.push(slicer); @@ -156,37 +228,29 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); let multi = bool_param(p, "multi").unwrap_or(false); let sheet = s.doc()?.wb.active_sheet; - let (table, column, current) = { - let d = s.doc()?; - let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; - (sl.table.clone(), sl.column.clone(), sl.selected.clone()) + let d = s.doc()?; + let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; + let tiles = slicer_items(&d.wb, sheet, sl); + let Some(tile) = tiles.iter().find(|t| t.label.eq_ignore_ascii_case(&value) || t.key.eq_ignore_ascii_case(&value)) else { + return Err(bad("slicer.toggle", "value is not one of the slicer's items")); }; - let all = column_values(&s.doc()?.wb, sheet, &table, &column); - if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { - return Err(bad("slicer.toggle", "value is not in the column")); - } - let new_selected: Option> = if multi { - let mut set: Vec = current.unwrap_or_else(|| all.clone()); - match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { - Some(pos) => { - set.remove(pos); - } - None => set.push(value.clone()), - } - // Everything selected is the same as no filter. - if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } - } else { - // Single click: show only this value; clicking the sole selected value clears the filter. - match ¤t { - Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, - _ => Some(vec![value.clone()]), - } + let all: Vec = tiles.iter().map(|t| t.key.clone()).collect(); + let current = (!tiles.iter().all(|t| t.selected)).then(|| tiles.iter().filter(|t| t.selected).map(|t| t.key.clone()).collect()); + let new_selected = next_selection(current, &all, &tile.key, multi); + let pivot = match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, }; edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = new_selected; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, new_selected)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) @@ -195,36 +259,69 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { fn slicer_select_all(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet; + let d = s.doc()?; + let pivot = d.wb.active().and_then(|sh| sh.slicers.get(idx)).and_then(|sl| match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, + }); edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = None; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, None)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) } +/// Deletes a slicer. A table slicer's filter is cleared; a PivotTable slicer leaves its +/// PivotTables filtered, as Excel does. fn slicer_delete(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet; - // Absolute column of the slicer, so its filter criterion can be removed too. + // Absolute column of a table slicer, so its filter criterion can be removed too. let abs_col = s.doc().ok().and_then(|d| { let sh = d.wb.active()?; - let sl = sh.slicers.get(idx)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; + let SlicerSource::Table { table, column, .. } = &sh.slicers.get(idx)?.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; Some(t.range.start.col + ci as u32) }); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; sh.slicers.retain(|x| x.id != id); - if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { - let off = abs.saturating_sub(af.range.start.col); - af.criteria.retain(|(o, _)| *o != off); + if let Some(abs) = abs_col { + if let Some(af) = sh.autofilter.as_mut() { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); } - super::data::apply_filter(&mut cx.wb, sheet); cx.structural = true; Ok(Json::Null) }) } + +/// Disconnects the PivotTable `name` on sheet `sheet` from its slicers; slicers left with no +/// PivotTable are deleted (used when the PivotTable is deleted). +pub(crate) fn drop_pivot(wb: &mut Workbook, sheet: &str, name: &str) { + let linked = |p: &SlicerPivot| p.sheet.eq_ignore_ascii_case(sheet) && p.name.eq_ignore_ascii_case(name); + let links = |sl: &Slicer| matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.iter().any(linked)); + for i in 0..wb.sheets.len() { + if !wb.sheet(i).is_some_and(|sh| sh.slicers.iter().any(links)) { + continue; + } + let Some(sh) = wb.sheet_mut(i) else { continue }; + for sl in sh.slicers.iter_mut() { + if let SlicerSource::Pivot { pivots, .. } = &mut sl.source { + pivots.retain(|p| !linked(p)); + } + } + sh.slicers.retain(|sl| !matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.is_empty())); + } +} diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 6146bd4f91..f44044f2b8 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -294,6 +294,89 @@ pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDate items.into_iter().take(limit).map(|i| i.label).collect() } +/// Whether a filter's selected labels take in a source value: by its label, or (outside a date +/// grouping) a number by its plain number text, which is how files name number and date items. +fn selects(sel: &[String], v: &Value, label: &str, group: PivotDateGroup) -> bool { + if sel.iter().any(|s| s.eq_ignore_ascii_case(label)) { + return true; + } + match v.scalar() { + Value::Number(n) if group == PivotDateGroup::None => sel.contains(&gridcraft_core::number_to_text(n)), + _ => false, + } +} + +/// A slicer tile: one item of a PivotTable field. +#[derive(Clone, Debug, PartialEq)] +pub struct SlicerTile { + /// The item as shown (numbers and dates in the source format). + pub label: String, + /// The label a [`PivotFilter`] selects the item by: the plain number text for numbers (as + /// files store them), else the label. + pub key: String, + pub selected: bool, + /// Some source row with this item passes the PivotTable's other filters. + pub has_data: bool, +} + +/// The items of `field` in `pt`'s source as slicer tiles: selection from the PivotTable's filter +/// on the field, in `sort` order (`None`: source order), and with `cross` the items without data +/// last or marked as Excel does. +pub fn slicer_tiles(wb: &Workbook, pt: &PivotTable, sheet: usize, field: &str, sort: PivotSort, cross: SlicerCrossFilter) -> Vec { + let Ok(src) = read_source(wb, &pt.source, sheet) else { return vec![] }; + let Some(col) = src.col(field) else { return vec![] }; + let fmt_of = |c: usize| src.formats.get(c).map(String::as_str).unwrap_or("General"); + let group_of = |name: &str| { + pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group).unwrap_or_default() + }; + let own = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(field)).and_then(|f| f.selected.as_deref()); + let others: Vec<(usize, &[String], PivotDateGroup)> = pt + .filters + .iter() + .filter(|f| !f.source_col.eq_ignore_ascii_case(field)) + .filter_map(|f| Some((src.col(&f.source_col)?, f.selected.as_deref()?, group_of(&f.source_col)))) + .collect(); + let mut fi = FieldItems::default(); + let mut data: Vec = Vec::new(); + for (i, r) in src.rows.iter().enumerate() { + let k = fi.intern(wb, r.get(col).unwrap_or(&Value::Empty), PivotDateGroup::None, fmt_of(col), i) as usize; + if data.len() <= k { + data.resize(k + 1, false); + } + if !data[k] + && others.iter().all(|(c, sel, g)| { + let v = r.get(*c).unwrap_or(&Value::Empty); + selects(sel, v, &item_label(wb, v, *g, fmt_of(*c)), *g) + }) + { + data[k] = true; + } + if fi.items.len() > 100_000 { + break; + } + } + let mut items: Vec<(Item, bool)> = fi.items.into_iter().zip(data).collect(); + match sort { + PivotSort::Asc => items.sort_by(|a, b| cmp_items(&a.0, &b.0)), + PivotSort::Desc => items.sort_by(|a, b| cmp_items(&b.0, &a.0)), + PivotSort::None => {} + } + if cross == SlicerCrossFilter::ShowItemsWithDataAtTop { + items.sort_by_key(|(_, d)| !*d); + } + items + .into_iter() + .map(|(it, has_data)| { + let key = match &it.value { + Value::Number(n) => gridcraft_core::number_to_text(*n), + _ => it.label.clone(), + }; + let selected = own.is_none_or(|sel| selects(sel, &it.value, &it.label, PivotDateGroup::None)); + SlicerTile { label: it.label, key, selected, has_data: has_data || cross == SlicerCrossFilter::None } + }) + .collect() +} + // ---------------------------------------------------------------- aggregation #[derive(Clone, Debug, Default)] @@ -864,7 +947,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = filters.iter().filter(|f| f.3).collect(); @@ -893,8 +976,8 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, } -/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles -/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, -/// so a slicer is just a visual front-end over the existing filter machinery. +/// A slicer: an on-sheet filter widget whose value tiles drive an existing filter, so a slicer is +/// just a visual front-end over the filter machinery (see [`SlicerSource`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Slicer { pub id: u32, - /// Name of the table this slicer filters. - pub table: String, - /// Header name of the filtered column within the table. - pub column: String, - /// Caption shown in the slicer header (defaults to the column name). + /// What the slicer filters, and where its selection lives. + #[serde(flatten)] + pub source: SlicerSource, + /// Caption shown in the slicer header (defaults to the column or field name). pub caption: String, pub anchor: Anchor, - /// Selected display values. `None` means every value (no filter from this slicer); an empty - /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". - pub selected: Option>, /// Number of button columns in the layout (Excel default 1). pub columns: u32, + /// The slicer's name (unique in a workbook; Excel's Slicer Settings "Name"). Empty: derived + /// from the caption when saved. + #[serde(default)] + pub name: String, + /// Slicer style name (`SlicerStyleLight1`…). Empty: the default style. + #[serde(default)] + pub style: String, + /// Button height in pixels; `None` uses the default. + #[serde(default)] + pub row_height: Option, +} + +impl Slicer { + /// The column or field the slicer filters. + pub fn field(&self) -> &str { + match &self.source { + SlicerSource::Table { column, .. } => column, + SlicerSource::Pivot { field, .. } => field, + } + } +} + +/// What a slicer filters. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SlicerSource { + /// One column of a table: the selection is written as that column's + /// `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applied. + Table { + /// Name of the table. + table: String, + /// Header name of the filtered column within the table. + column: String, + /// Selected display values. `None` means every value (no filter from this slicer); an + /// empty list means nothing is selected (all rows hidden), matching Excel's "clear all + /// buttons". + selected: Option>, + }, + /// One field of one or more PivotTables built from the same source. The selection is not + /// stored here: it is each PivotTable's [`PivotFilter`] on the field (its hidden items), so the + /// slicer and the PivotTables always agree. + Pivot { + /// The PivotTables it filters. + pivots: Vec, + /// Source field (column header) name. + field: String, + /// Tile order: ascending, descending, or data source order (`PivotSort::None`). + #[serde(default)] + sort: PivotSort, + #[serde(default)] + cross_filter: SlicerCrossFilter, + /// Whether items no longer in the source are shown (Excel's "Show items deleted from the + /// data source"; kept for the file). + #[serde(default = "default_true")] + show_missing: bool, + }, +} + +fn default_true() -> bool { + true +} + +/// A PivotTable a slicer filters: its sheet and name. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlicerPivot { + pub sheet: String, + pub name: String, +} + +/// How a slicer shows items that have no data under its PivotTables' other filters (Excel's +/// "Visually indicate items with no data" options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SlicerCrossFilter { + /// Items with no data aren't marked. + None, + /// Items with data first (the default). + #[default] + ShowItemsWithDataAtTop, + /// Items with no data stay in their place. + ShowItemsWithNoData, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -972,6 +1047,10 @@ pub struct PivotFilter { pub source_col: String, /// Item labels shown; `None` = all. pub selected: Option>, + /// For a field outside Rows and Columns: it filters the PivotTable without being a report + /// filter (no filter cell above the table). Set by a slicer on a field the PivotTable doesn't + /// show, and read from files as hidden items of such a field. + pub hidden: bool, } /// A PivotTable: its definition plus where its output was last written. The output itself is diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 180f15c21a..23faa7af50 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -399,11 +399,12 @@ pub struct SlicerLayout { pub tiles: Vec, } -pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { +/// `row_height`: the slicer's button height in pixels (`None`: the default). +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32, row_height: Option) -> SlicerLayout { let header_h = 26.0 * z; let pad = 6.0 * z; let gap = 3.0 * z; - let tile_h = 22.0 * z; + let tile_h = row_height.filter(|h| h.is_finite()).unwrap_or(22.0).clamp(8.0, 400.0) * z; let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); let cb = 16.0 * z; let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); @@ -431,7 +432,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz continue; } let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); let radius = 4.0 * geo.z; // Card + soft shadow. p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); @@ -440,22 +441,28 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz // Header. p.rect_filled(lay.header, radius, tk.accent_soft); p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); - let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + let cap = if sl.caption.is_empty() { sl.field().to_string() } else { sl.caption.clone() }; p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); // Clear-filter button (tinted when a filter is active). - let filtered = sl.selected.is_some(); + let filtered = items.iter().any(|t| !t.selected); if let Some(icon) = crate::icons::from_name("filter") { crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); } // Value tiles. - for ((val, on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + // Items with no data under the PivotTables' other filters are dimmed. + let (fill, fg) = match (tile.selected, tile.has_data) { + (true, true) => (tk.accent, Color32::WHITE), + (true, false) => (tk.accent_soft, tk.text_dim), + (false, true) => (tk.hover, tk.text_dim), + (false, false) => (tk.window, tk.text_dim), + }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); - cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, &tile.label, theme::ui_font(12.0 * geo.z), fg); } if app.selected_chart == Some(sl.id) { selection_frame(p, r); @@ -481,13 +488,13 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> continue; } let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); if lay.clear.contains(p) { return Some((sl.id, SlicerAction::Clear)); } - for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if r.contains(tr.left_top()) && tr.contains(p) { - return Some((sl.id, SlicerAction::Toggle(val.clone()))); + return Some((sl.id, SlicerAction::Toggle(tile.key.clone()))); } } return None; // inside this (topmost) slicer, but on its header/background → select/drag diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 6dbd17e7e2..d2e6b07a7c 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -175,6 +175,8 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( None => cx.warn(format!("chart {target} has no supported plot and was skipped")), } } + } else if crate::slicer::read_frame(cx, &sheet.name, gd, anchor) { + // A slicer: joined with its slicer part and cache once the sheets are read. } else if uri.contains("chartex") || uri.contains("chartEx") { cx.warn("a histogram, waterfall, treemap… chart without its chart part was skipped"); } else { @@ -311,6 +313,8 @@ pub enum Obj<'a> { Image(&'a Image, String), Chart(&'a gridcraft_model::Chart, String), Shape(&'a Shape), + /// A PivotTable slicer and its (unique) name. + Slicer(&'a gridcraft_model::Slicer, String), } /// The drawing part for a sheet's objects (relationship ids already assigned). @@ -359,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { xfrm(&ch.anchor, "xdr:xfrm") ); } + Obj::Slicer(sl, name) => { + let (open, close) = anchor_open(sheet, &sl.anchor); + close_tag = close; + s.push_str(&open); + s.push_str(&crate::slicer::frame_xml(id, name, &xfrm(&sl.anchor, "xdr:xfrm"), &xfrm(&sl.anchor, "a:xfrm"))); + } Obj::Shape(sh) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 3300f97fe5..186ef11b26 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -28,6 +28,7 @@ mod read; mod richdata; mod sheet_read; mod sheet_write; +mod slicer; mod styles; mod tables; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 609ae530e1..f65994f1f1 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -40,6 +40,8 @@ const MAX_ITEMS: usize = 1_048_576; const MAX_FIELDS: usize = 16_384; /// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank). const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}"; +/// Extension carrying a cache's id for slicer caches. +const EXT_X14_CACHE_ID: &str = "{725AE2AE-9491-48be-B2B4-4EB974FC3084}"; const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -257,6 +259,18 @@ struct Cache { axis_map: Vec>, source_xml: String, records: Option<(u64, String)>, + /// Fields slicers filter (written with items even off the axes). + slicer_fields: Vec, + /// The cache's id for slicer caches (`x14:pivotCacheDefinition pivotCacheId`). + slicer_id: Option, +} + +/// What a slicer cache needs from a written PivotTable. +pub struct PivotSlicerInfo { + /// The cache's `pivotCacheId`. + pub cache_id: u32, + /// Slicer fields: name and the captions its shared item indices refer to. + pub fields: Vec<(String, Vec)>, } /// PivotTables being written; the tables themselves are finished after the styles part (their @@ -267,6 +281,8 @@ pub struct PivotWriter { caches: Vec<(u32, String)>, pending: Vec, tables: u32, + /// (sheet, pivot index) → slicer data of PivotTables that slicers filter. + slicer_info: HashMap<(usize, usize), PivotSlicerInfo>, } struct Pending { @@ -283,8 +299,14 @@ impl PivotWriter { pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { let Some(sheet) = wb.sheet(si) else { return }; for (idx, pt) in sheet.pivots.iter().enumerate() { - let Some(cache) = build_cache(wb, pt, si) else { continue }; + let slicer_fields = out.slicers.fields_for(si, idx); + let Some(mut cache) = build_cache(wb, pt, si, &slicer_fields) else { continue }; let n = self.caches.len() as u32 + 1; + if !cache.slicer_fields.is_empty() { + cache.slicer_id = Some(n); + let fields = cache.slicer_fields.iter().filter_map(|&i| cache.fields.get(i)).map(|f| (f.name.clone(), f.item_labels())).collect(); + self.slicer_info.insert((si, idx), PivotSlicerInfo { cache_id: n, fields }); + } let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); let rec_rid = if let Some((count, xml)) = &cache.records { let mut r = Rels::default(); @@ -308,6 +330,11 @@ impl PivotWriter { } } + /// Slicer data of the PivotTable `idx` on sheet `si`, when it was written for a slicer. + pub fn slicer_info(&self, si: usize, idx: usize) -> Option<&PivotSlicerInfo> { + self.slicer_info.get(&(si, idx)) + } + /// Adds the workbook relationships to the caches; returns the `` element (empty /// when there are none). pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { @@ -412,7 +439,16 @@ fn header_text(v: &Value) -> String { } } -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { +/// Whether a slicer on `field` of `pt` can be written: the PivotTable's cache can be built and its +/// source has the field. +pub(crate) fn slicer_field_ok(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, field: &str) -> bool { + let Some((si, r, _)) = resolve_source(wb, &pt.source, pivot_sheet, 0) else { return false }; + let Some(sh) = wb.sheet(si) else { return false }; + r.count() <= MAX_SOURCE_CELLS && (r.start.col..=r.end.col).any(|c| same(&header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()), field)) +} + +/// `slicer_fields`: fields slicers filter, which need shared items. +fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, slicer_fields: &[String]) -> Option { let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; if r.count() > MAX_SOURCE_CELLS { return None; @@ -567,6 +603,12 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option = slicer_fields.iter().filter_map(|n| find(&fields, n)).collect(); + for &i in &slicer_idx { + if let Some(f) = fields.get_mut(i) { + f.itemized = true; + } + } for f in fields.iter_mut() { if f.group.is_some() { f.itemized = false; @@ -630,7 +672,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option Vec { @@ -761,7 +803,14 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { } s.push_str(""); } - s.push_str(""); + s.push_str(""); + if let Some(id) = c.slicer_id { + let _ = write!( + s, + "" + ); + } + s.push_str(""); s } @@ -770,7 +819,8 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); - // Filters: on an axis field → hidden items there; otherwise a page field. + // Filters: on an axis field → hidden items there; a hidden filter (a slicer's) → hidden items + // of a field in no area; otherwise a page field. let mut hidden: HashMap = HashMap::new(); let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); for f in &pt.filters { @@ -781,6 +831,11 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn hidden.insert(*i, f); } } + None if f.hidden => { + if let Some(i) = find_db(&f.source_col) { + hidden.insert(i, f); + } + } None => { if let Some(i) = find_db(&f.source_col) && !pages.iter().any(|(j, _)| *j == i) @@ -908,7 +963,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if !pt.subtotals_top { s.push_str(" subtotalTop=\"0\""); } - if axis.is_some() { + if axis.is_some() || (f.itemized && (hidden.contains_key(&i) || c.slicer_fields.contains(&i))) { let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); let count = labels.len() + usize::from(pt.subtotals); let _ = write!(s, ">"); @@ -1052,6 +1107,8 @@ impl FieldIn { struct CacheIn { source: String, fields: Vec, + /// `x14:pivotCacheDefinition pivotCacheId`: how slicer caches name the cache. + slicer_id: Option, } /// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. @@ -1076,6 +1133,13 @@ impl CachesIn { } CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } } + + /// Item captions of field `field` in the cache a slicer cache names by `pivotCacheId`, in + /// shared item order (what the slicer cache's `` indices refer to). + pub fn slicer_labels(&self, cache_id: u32, field: &str) -> Option<&[String]> { + let cache = self.parsed.values().flatten().find(|c| c.slicer_id == Some(cache_id))?; + Some(cache.fields.iter().find(|f| f.database && same(&f.name, field))?.labels()) + } } fn item_label_el(e: &El, sys: DateSystem) -> String { @@ -1151,7 +1215,9 @@ fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option cx.warn("a PivotTable's cache has no fields; kept as plain cells"); return None; } - Some(CacheIn { source, fields }) + let slicer_id = + x.child("extLst").and_then(|l| l.kids("ext").find_map(|e| e.child("pivotCacheDefinition"))).and_then(|d| d.attr_u32("pivotCacheId")); + Some(CacheIn { source, fields, slicer_id }) } /// Reads the PivotTables related to a worksheet part. @@ -1300,7 +1366,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); if !filters.iter().any(|ff| same(&ff.source_col, &name)) { - filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown), hidden: false }); } } } @@ -1327,7 +1393,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option