Merge pull request #1882 from neilcawse:fix/pivot-grand-totals

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
Brandon Thomas
2026-10-19 12:22:07 -04:00
3 changed files with 31 additions and 4 deletions
+6 -4
View File
@@ -820,10 +820,12 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
if !pt.show_headers { if !pt.show_headers {
s.push_str(" showHeaders=\"0\""); 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\""); s.push_str(" rowGrandTotals=\"0\"");
} }
if !pt.grand_totals_cols { if !pt.grand_totals_rows {
s.push_str(" colGrandTotals=\"0\""); s.push_str(" colGrandTotals=\"0\"");
} }
s.push_str(" multipleFieldFilters=\"0\">"); s.push_str(" multipleFieldFilters=\"0\">");
@@ -1266,8 +1268,8 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option<Pivo
pt.subtotals = f.flag("defaultSubtotal", true); pt.subtotals = f.flag("defaultSubtotal", true);
pt.subtotals_top = f.flag("subtotalTop", true); pt.subtotals_top = f.flag("subtotalTop", true);
} }
pt.grand_totals_rows = x.flag("rowGrandTotals", true); pt.grand_totals_rows = x.flag("colGrandTotals", true);
pt.grand_totals_cols = x.flag("colGrandTotals", true); pt.grand_totals_cols = x.flag("rowGrandTotals", true);
pt.show_headers = x.flag("showHeaders", true); pt.show_headers = x.flag("showHeaders", true);
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string(); 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() { 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(); let t2 = String::from_utf8(parts.get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
assert!(t2.contains("pivotShowAs=\"rankDescending\"")); assert!(t2.contains("pivotShowAs=\"rankDescending\""));
assert!(t2.contains("showDataAs=\"runTotal\"")); 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. // The custom value format is registered in the styles part.
let styles = String::from_utf8(parts.get("xl/styles.xml").unwrap().clone()).unwrap(); let styles = String::from_utf8(parts.get("xl/styles.xml").unwrap().clone()).unwrap();
assert!(styles.contains("formatCode=\"0.0&quot; u&quot;\"")); assert!(styles.contains("formatCode=\"0.0&quot; u&quot;\""));
@@ -640,6 +644,25 @@ fn values_ahead_of_column_fields_read_last() {
assert_eq!(pt.values_position, None); 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] #[test]
fn data_on_rows_with_one_value_field_reads_without_warning() { 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>"#; 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
View File
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (`.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (`.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; 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 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; 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
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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 (CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; 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. 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** Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -92,6 +93,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
| 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged | | 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged |
| 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) | | 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) |
| 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-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 | 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) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |