From 374ab0f127e4d1e08ffb32d86dacfeea6c537d18 Mon Sep 17 00:00:00 2001
From: neilcawse <8085959+neilcawse@users.noreply.github.com>
Date: Mon, 12 Oct 2026 07:50:52 -0400
Subject: [PATCH 01/12] XLSX: PivotCharts plot their cached points; deleted
automatic titles stay off
Excel often saves a PivotChart series with formulas that only approximate
its PivotTable: every formula pointing at the table corner cell, or a block
that takes in the grand total row. Excel draws the chart from the
PivotTable, so the cached points are what it shows; GridCraft evaluated the
formulas and drew empty charts or a stray grand total bar. When a PivotChart
series references don't cover their cached points (numCache, strCache or the
innermost multiLvlStrCache level), the series now plots the cached points
and cached name as array constants. Refreshing the PivotTable rebinds the
chart to live cells as before.
A chart with and no title now opens with its
automatic title off, instead of showing the lone series name ("Series1").
Array-constant series also survive a save: literals are read by index and
ptCount (gaps stay #N/A instead of shifting later points), and written by
parsing the array constant, so text with quotes, commas or semicolons is
kept.
Literal values keep their number format: Series gains number_format, read
from the numLit or numCache formatCode and written as the numLit
formatCode (it was always General). The renderer uses it only for array
constants; references keep using their cells' format. Cached PivotChart
points now label as "$185" or "5.36%", as in Excel.
Co-Authored-By: Claude Opus 5.5
---
crates/chart/src/resolve.rs | 5 +-
crates/chart/src/tests.rs | 10 +
crates/engine/src/cmd/forecast.rs | 1 +
crates/engine/src/cmd/insert.rs | 2 +
crates/engine/src/pivot.rs | 1 +
crates/engine/src/sample.rs | 1 +
crates/model/src/features.rs | 4 +
crates/xlsx/src/chart.rs | 179 +++++++++++++++---
crates/xlsx/src/tests/chart_read_tests.rs | 113 +++++++++++
.../src/tests/literal_category_roundtrip.rs | 1 +
crates/xlsx/src/tests/mod.rs | 1 +
crates/xlsx/src/tests/roundtrip.rs | 1 +
crates/xlsx/src/tests/smooth_roundtrip.rs | 1 +
crates/xlsx/src/tests/trendline_roundtrip.rs | 1 +
docs/chart-parity.md | 5 +-
15 files changed, 301 insertions(+), 25 deletions(-)
create mode 100644 crates/xlsx/src/tests/chart_read_tests.rs
diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs
index c007e3e90..c3f8b8ed5 100644
--- a/crates/chart/src/resolve.rs
+++ b/crates/chart/src/resolve.rs
@@ -236,7 +236,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);
diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs
index 01f1aac49..de8f47285 100644
--- a/crates/chart/src/tests.rs
+++ b/crates/chart/src/tests.rs
@@ -229,6 +229,8 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ // References use their cells' format, whatever the series says.
+ number_format: Some("0%".into()),
},
Series {
name: None,
@@ -240,6 +242,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
},
Series {
name: Some("Literal".into()),
@@ -251,6 +254,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: Some("0.0%".into()),
},
];
let d = resolve(&wb, 0, &c);
@@ -270,6 +274,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);
@@ -289,6 +294,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
},
Series {
name: None,
@@ -300,6 +306,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
},
];
let d = resolve(&wb, 0, &c);
@@ -316,6 +323,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
@@ -333,6 +341,7 @@ fn resolve_from_workbook() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
}];
let d = resolve(&wb, 7, &c);
assert_eq!(d.series.len(), 1);
@@ -1003,6 +1012,7 @@ fn pie_points_follow_workbook_theme() {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
}];
let d = resolve(&wb, 0, &c);
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs
index d25b367ba..fbed59aad 100644
--- a/crates/engine/src/cmd/forecast.rs
+++ b/crates/engine/src/cmd/forecast.rs
@@ -413,6 +413,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result {
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
})
.collect();
sheet.charts.push(Chart {
diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs
index d878e4469..f6ab29591 100644
--- a/crates/engine/src/cmd/insert.rs
+++ b/crates/engine/src/cmd/insert.rs
@@ -605,6 +605,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
});
}
} else {
@@ -620,6 +621,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
});
}
}
diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs
index 6146bd4f9..cc167fb7e 100644
--- a/crates/engine/src/pivot.rs
+++ b/crates/engine/src/pivot.rs
@@ -1569,6 +1569,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
})
})
.collect()
diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs
index 4d4822445..f3dee6489 100644
--- a/crates/engine/src/sample.rs
+++ b/crates/engine/src/sample.rs
@@ -110,6 +110,7 @@ fn sales(s: &mut Session) -> bool {
kind: None,
smooth: false,
trendlines: Vec::new(),
+ number_format: None,
}];
}
}
diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs
index b51f7bc98..4ed6bdb7c 100644
--- a/crates/model/src/features.rs
+++ b/crates/model/src/features.rs
@@ -424,6 +424,10 @@ pub struct Series {
/// Trendlines fitted to this series (Excel allows several per series).
#[serde(default)]
pub trendlines: Vec,
+ /// 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,
}
/// Regression a trendline fits (`c:trendlineType`).
diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs
index dee33c83d..363c83793 100644
--- a/crates/xlsx/src/chart.rs
+++ b/crates/xlsx/src/chart.rs
@@ -72,18 +72,111 @@ fn data_ref(e: Option<&El>) -> Option {
// Literal values become an array constant.
for k in ["numLit", "strLit"] {
if let Some(l) = e.child(k) {
- let pts: Vec = l
- .kids("pt")
- .filter_map(|p| p.child("v"))
- .map(|v| if k == "numLit" { v.text.trim().to_string() } else { format!("\"{}\"", v.text.replace('"', "\"\"")) })
- .collect();
- return Some(format!("{{{}}}", pts.join(",")));
+ return points_literal(l, l, k == "numLit").map(|(_, lit)| lit);
}
}
None
}
-fn read_series(s: &El) -> Series {
+/// Cells a series reference covers: `Sheet!$A$1:$A$9`, or a union `(Sheet!$A$1:$A$3,Sheet!$A$5)`.
+fn ref_cells(f: &str) -> Option {
+ let f = f.trim();
+ let inner = f.strip_prefix('(').and_then(|x| x.strip_suffix(')')).unwrap_or(f);
+ let mut parts = Vec::new();
+ let (mut quoted, mut start) = (false, 0);
+ for (i, c) in inner.char_indices() {
+ match c {
+ '\'' => quoted = !quoted,
+ ',' if !quoted => {
+ parts.push(inner.get(start..i)?);
+ start = i + 1;
+ }
+ _ => {}
+ }
+ }
+ parts.push(inner.get(start..)?);
+ let mut total = 0u64;
+ for p in parts {
+ let addr = p.rsplit_once('!').map_or(p, |(_, a)| a).trim();
+ let r = gridcraft_core::RangeRef::parse(addr)?;
+ total = total.checked_add(u64::from(r.width()) * u64::from(r.height()))?;
+ }
+ Some(total)
+}
+
+/// Most cached points a PivotChart series keeps (the chart renderer's own cap).
+const MAX_CACHED_POINTS: u64 = 100_000;
+
+/// The points of a cache or literal (`c` holds `ptCount`, `pts` the ``s) as an array
+/// constant, with the point count. Missing points become `#N/A` (numbers) or `""` (text) so
+/// positions line up. Without a `ptCount`, the highest index sets the count.
+fn points_literal(c: &El, pts: &El, numeric: bool) -> Option<(u64, String)> {
+ let idx = |p: &El| p.attr("idx").and_then(|v| v.trim().parse::().ok());
+ let count = match c.child_val("ptCount") {
+ Some(v) => v.trim().parse::().ok()?,
+ None => pts.kids("pt").filter_map(idx).max().map_or(0, |m| m as u64 + 1),
+ };
+ if !(1..=MAX_CACHED_POINTS).contains(&count) {
+ return None;
+ }
+ let mut vals: Vec