Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions crates/chart/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ fn value_text(v: &Value) -> String {
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
/// has one entry per category, non-empty where a group starts, as the cells are.
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, innermost: bool) -> (Vec<String>, Vec<Vec<String>>) {
let vals = eval_list(wb, sheet, formula);
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
Expand Down Expand Up @@ -168,12 +168,21 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
let outer = levels.outer as usize;
if levels.by_columns {
let value_count = eval_list(wb, sheet, values).len();
let by_columns = if value_count == h && w > 1 && h != w {
true
} else if value_count == w && h > 1 && h != w {
false
} else {
levels.by_columns
};
let outer = if by_columns { w.saturating_sub(1).min(3) } else { h.saturating_sub(1).min(3) };
let result = if by_columns {
(column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
} else {
(row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
}
};
if innermost { (result.0, vec![]) } else { result }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
Expand Down Expand Up @@ -242,7 +251,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
if categories.is_none()
&& let Some(f) = &s.categories
{
let (c, outer) = category_table(wb, sheet, f, &s.values);
let (c, outer) = category_table(wb, sheet, f, &s.values, s.innermost_categories);
if !c.is_empty() {
categories = Some(c);
category_levels = outer;
Expand All @@ -268,7 +277,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
secondary: s.secondary,
smooth: s.smooth,
trendlines,
number_format: ref_format(wb, sheet, &s.values),
number_format: match &s.number_format {
Some(code) if s.values.trim_start().starts_with('{') => code.clone(),
_ => ref_format(wb, sheet, &s.values),
},
});
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
Expand Down
75 changes: 75 additions & 0 deletions crates/chart/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
// References use their cells' format, whatever the series says.
number_format: Some("0%".into()),
innermost_categories: false,
},
Series {
name: None,
Expand All @@ -244,6 +247,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
},
Series {
name: Some("Literal".into()),
Expand All @@ -256,6 +261,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: Some("0.0%".into()),
innermost_categories: false,
},
];
let d = resolve(&wb, 0, &c);
Expand All @@ -275,6 +282,7 @@ fn resolve_from_workbook() {
assert!(s1.secondary);
assert_eq!(d.series[2].name, "Literal");
assert_eq!(d.series[2].values.len(), 4);
assert_eq!(d.series[2].number_format, "0.0%");

// Default categories.
c.series.remove(0);
Expand All @@ -295,6 +303,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
},
Series {
name: None,
Expand All @@ -307,6 +317,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
},
];
let d = resolve(&wb, 0, &c);
Expand All @@ -324,6 +336,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
Expand All @@ -342,6 +356,8 @@ fn resolve_from_workbook() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
}];
let d = resolve(&wb, 7, &c);
assert_eq!(d.series.len(), 1);
Expand Down Expand Up @@ -1043,6 +1059,8 @@ fn pie_points_follow_workbook_theme() {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
}];
let d = resolve(&wb, 0, &c);
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
Expand Down Expand Up @@ -1098,6 +1116,47 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}

#[test]
fn a_multi_level_category_block_draws_its_innermost_level() {
// A PivotChart's two levels of row labels in A1:B4, the outer one (country) on the left:
// one label per point, from the innermost column.
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "Graph".into();
for (r, (outer, inner)) in [("Canada", "Ottawa"), ("", "Toronto"), ("USA", "Boston"), ("", "NYC")].iter().enumerate() {
if !outer.is_empty() {
s.set_value(CellRef::new(r as u32, 0), Value::from(*outer));
}
s.set_value(CellRef::new(r as u32, 1), Value::from(*inner));
s.set_value(CellRef::new(r as u32, 2), Value::Number(r as f64 + 1.0));
s.set_value(CellRef::new(5, r as u32), Value::from(*inner));
s.set_value(CellRef::new(6, r as u32), Value::from(*outer));
}
}
let mut c = chart(ChartKind::ColumnClustered);
c.series = vec![Series {
categories: Some("Graph!$A$1:$B$4".into()),
values: "Graph!$C$1:$C$4".into(),
innermost_categories: true,
..Series::default()
}];
let data = resolve(&wb, 0, &c);
assert_eq!(data.categories, vec!["Ottawa", "Toronto", "Boston", "NYC"]);
assert!(data.category_levels.is_empty(), "innermost PivotChart must not expose outer labels");
// A block with a column per point: its last row is the innermost level.
c.series[0].categories = Some("Graph!$A$6:$D$7".into());
let data = resolve(&wb, 0, &c);
assert_eq!(data.categories, vec!["Canada", "", "USA", ""]);
assert!(data.category_levels.is_empty(), "innermost PivotChart must not expose outer labels");
// Without the flag, every cell is a label, as for any other chart.
c.series[0].categories = Some("Graph!$A$1:$B$4".into());
c.series[0].innermost_categories = false;
let data = resolve(&wb, 0, &c);
assert_eq!(data.categories, vec!["Ottawa", "Toronto", "Boston", "NYC"]);
assert_eq!(data.category_levels, vec![vec!["Canada", "", "USA", ""]]);
}

#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
Expand Down Expand Up @@ -1211,6 +1270,22 @@ fn two_level_categories_resolve_and_draw() {
assert!(d.category_levels.is_empty());
}

#[test]
fn square_category_blocks_keep_their_declared_orientation_on_ties() {
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
s.name = "Data".into();
for (i, value) in ["A1", "B1", "A2", "B2", "A3", "B3"].iter().enumerate() {
s.set_value(CellRef::parse(value).unwrap(), Value::from(*value));
s.set_value(CellRef::new(3, i as u32), Value::Number(i as f64 + 1.0));
}
let mut c = chart(ChartKind::Line);
c.series = vec![Series { categories: Some("Data!$A$1:$B$2".into()), values: "Data!$A$3:$B$3".into(), ..Series::default() }];
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, vec!["A2", "B2"]);
assert_eq!(d.category_levels, vec![vec!["A1", "B1"]]);
}

#[test]
fn a_huge_trendline_forecast_does_not_collapse_the_columns() {
let m = ApproxMeasure;
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/forecast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
})
.collect();
sheet.charts.push(Chart {
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/cmd/insert/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
});
}
} else {
Expand All @@ -98,6 +100,8 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/pivot/charts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
})
})
.collect()
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/sample.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ fn sales(s: &mut Session) -> bool {
quartiles: Default::default(),
smooth: false,
trendlines: Vec::new(),
number_format: None,
innermost_categories: false,
}];
}
}
Expand Down
49 changes: 48 additions & 1 deletion crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4490,6 +4490,54 @@ fn paste_special_validation_moves_relative_references() {
assert!(ok(&mut s, "C3", "20"));
}

#[test]
fn a_multi_level_pivot_chart_draws_one_label_per_point_after_saves() {
use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Workbook};
// Two levels of row labels in A4:B7, the outer one (country) on the left, as a PivotChart
// opened from a file keeps them when the innermost column holds its cache.
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.name = "Graph".into();
sh.set_value(CellRef::new(2, 2), Value::from("Total"));
for (i, (outer, inner)) in [("Canada", "Ottawa"), ("", "Toronto"), ("USA", "Boston"), ("", "NYC")].iter().enumerate() {
let r = 3 + i as u32;
if !outer.is_empty() {
sh.set_value(CellRef::new(r, 0), Value::from(*outer));
}
sh.set_value(CellRef::new(r, 1), Value::from(*inner));
sh.set_value(CellRef::new(r, 2), Value::Number(i as f64 + 1.0));
}
sh.charts.push(Chart {
id: 1,
kind: ChartKind::ColumnClustered,
anchor: Anchor { cell: CellRef::new(0, 5), width: 480.0, height: 300.0, ..Anchor::default() },
title: None,
series: vec![Series {
name: Some("Graph!$C$3".into()),
categories: Some("Graph!$A$4:$B$7".into()),
values: "Graph!$C$4:$C$7".into(),
innermost_categories: true,
..Series::default()
}],
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 2,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: Some("Graph!PivotTable1".into()),
});
let labels = ["Ottawa", "Toronto", "Boston", "NYC"];
for _ in 0..2 {
wb = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap().0;
let ch = &wb.sheet(0).unwrap().charts[0];
let s = &ch.series[0];
assert_eq!((s.name.as_deref(), s.categories.as_deref(), s.innermost_categories), (Some("Graph!$C$3"), Some("Graph!$A$4:$B$7"), true));
assert_eq!(gridcraft_chart::resolve(&wb, 0, ch).categories, labels);
}
}
#[test]
fn editing_quote_prefixed_text_keeps_it_text() {
let mut s = s();
Expand All @@ -4512,7 +4560,6 @@ fn editing_quote_prefixed_text_keeps_it_text() {
let d = s.doc().unwrap();
assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("B1").unwrap()), "hello");
}

#[test]
fn csv_as_text_keeps_identifier_columns_as_written() {
let csv = "gtin,zip,flag,day\n8435392892278,01234,TRUE,2026-03-04\n";
Expand Down
10 changes: 10 additions & 0 deletions crates/model/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,16 @@ pub struct Series {
/// Trendlines fitted to this series (Excel allows several per series).
#[serde(default)]
pub trendlines: Vec<Trendline>,
/// Number format of literal values (an array constant, such as a PivotChart's cached
/// points). Values that reference cells use the cells' format instead.
#[serde(default)]
pub number_format: Option<String>,
/// The categories are a multi-level block (a PivotChart's `multiLvlStrRef`): a column (or
/// row) of cells per level, the innermost last. Only the innermost level is drawn, one
/// label per point. The reference is kept, and XLSX saves it as a `multiLvlStrRef` with a
/// cache of every level; a PivotChart reads that back with this flag set again.
#[serde(default)]
pub innermost_categories: bool,
}

/// Regression a trendline fits (`c:trendlineType`).
Expand Down
Loading
Loading