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.
This commit is contained in:
Neil Cawse
2026-10-18 19:02:18 +00:00
parent 6e984bb84d
commit 383bb7ba59
3 changed files with 31 additions and 5 deletions
+6 -4
View File
@@ -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<Pivo
pt.subtotals = f.flag("defaultSubtotal", true);
pt.subtotals_top = f.flag("subtotalTop", true);
}
pt.grand_totals_rows = x.flag("rowGrandTotals", true);
pt.grand_totals_cols = x.flag("colGrandTotals", true);
pt.grand_totals_rows = x.flag("colGrandTotals", true);
pt.grand_totals_cols = x.flag("rowGrandTotals", true);
pt.show_headers = x.flag("showHeaders", true);
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string();
if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() {
+23
View File
@@ -355,6 +355,10 @@ fn pivot_xml_details() {
let t2 = String::from_utf8(parts.get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
assert!(t2.contains("pivotShowAs=\"rankDescending\""));
assert!(t2.contains("showDataAs=\"runTotal\""));
// Pivot 1 has no Grand Total column (`colGrandTotals`), pivot 2 no Grand Total row
// (`rowGrandTotals`).
assert!(t1.contains(" colGrandTotals=\"0\"") && !t1.contains("rowGrandTotals"), "{t1}");
assert!(t2.contains(" rowGrandTotals=\"0\"") && !t2.contains("colGrandTotals"), "{t2}");
// The custom value format is registered in the styles part.
let styles = String::from_utf8(parts.get("xl/styles.xml").unwrap().clone()).unwrap();
assert!(styles.contains("formatCode=\"0.0&quot; u&quot;\""));
@@ -579,6 +583,25 @@ fn values_ahead_of_column_fields_read_last() {
assert_eq!(pt.values_position, None);
}
// As Excel saves them: `rowGrandTotals="0"` drops the Grand Total row under the report (leaving
// Excel's "On for Rows Only"), `colGrandTotals="0"` the Grand Total column at its right ("On for
// Columns Only"). LibreOffice's Pivot2_Row_Subtotals.xlsx (`rowGrandTotals="0"`) shows no Grand
// Total row; ClosedXML's TemplateWithTableSourcePivotTables.xlsx pivotTable5 (`colGrandTotals="0"`,
// row and column fields) shows a Grand Total row and no Grand Total column.
#[test]
fn grand_totals_read_in_excel_direction() {
let table = |attr: &str| {
format!(
r#"<pivotTableDefinition name="GT" cacheId="1" {attr}><location ref="C1:F4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Sum of V" fld="1"/></dataFields></pivotTableDefinition>"#
)
};
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#"<pivotTableDefinition name="OneValue" cacheId="1" dataOnRows="1"><location ref="C1:D4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Total" fld="1"/></dataFields></pivotTableDefinition>"#;
+2 -1
View File
@@ -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) |