From 383bb7ba593a9b337ecebdeee31a9e05f6ede68a Mon Sep 17 00:00:00 2001 From: Neil Cawse Date: Sun, 18 Oct 2026 19:02:18 +0000 Subject: [PATCH] fix(xlsx): read and write PivotTable grand totals in Excel's direction rowGrandTotals is the Grand Total row under the report and colGrandTotals the Grand Total column at its right. Both were mapped the other way round; a GridCraft round trip hid it, but an Excel file with only one grand total opened with the other one. --- crates/xlsx/src/pivot.rs | 10 ++++++---- crates/xlsx/src/tests/pivot_tests.rs | 23 +++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 609ae530e..fd00d0234 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -816,10 +816,12 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if !pt.show_headers { s.push_str(" showHeaders=\"0\""); } - if !pt.grand_totals_rows { + // `rowGrandTotals` is the grand total row below the report (Excel's "On for Columns"), + // `colGrandTotals` the grand total column at its right ("On for Rows"). + if !pt.grand_totals_cols { s.push_str(" rowGrandTotals=\"0\""); } - if !pt.grand_totals_cols { + if !pt.grand_totals_rows { s.push_str(" colGrandTotals=\"0\""); } s.push_str(" multipleFieldFilters=\"0\">"); @@ -1262,8 +1264,8 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option"# + ) + }; + for (attr, rows, cols) in [("rowGrandTotals=\"0\"", true, false), ("colGrandTotals=\"0\"", false, true), ("", true, true)] { + let (wb, _) = read_xlsx(&with_pivot(&table(attr), Some(GOOD_CACHE))).unwrap(); + let pt = &wb.sheet(0).unwrap().pivots[0]; + assert_eq!((pt.grand_totals_rows, pt.grand_totals_cols), (rows, cols), "{attr}"); + } +} + #[test] fn data_on_rows_with_one_value_field_reads_without_warning() { let table = r#""#; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 158149fb3..61df43deb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |