From eb30ab8f3fdbdbde4b4f2fe7580b4bef3d3d13f6 Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Fri, 16 Oct 2026 12:56:33 -0300 Subject: [PATCH] Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/pivot.rs | 16 ++++++++- crates/xlsx/src/tests/pivot_tests.rs | 50 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 743a746e7..17c49ec1c 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -40,6 +40,7 @@ const MAX_ITEMS: usize = 1_048_576; const MAX_FIELDS: usize = 16_384; /// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank). const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}"; +const EXT_X14_PIVOT_TABLE: &str = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}"; const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -850,7 +851,14 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn } else { row_levels.max(1) }; - let _ = write!(s, ""); + if hide_values_row { + let _ = write!( + s, + "" + ); + } s.push_str(""); s } diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index 9ec1f687d..8bc941a0e 100644 --- a/crates/xlsx/src/tests/pivot_tests.rs +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -674,3 +674,53 @@ fn data_on_rows_with_one_value_field_reads_without_warning() { let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert!(back.sheet(0).unwrap().pivots[0].values_on_rows); } + +/// A one-sheet-of-data workbook with a pivot on "Report": `columns` are the source columns +/// (header, values), `pt` the pivot built on `Data!A1:`. +fn small_pivot(columns: &[(&str, Vec)], pt: impl FnOnce(&mut PivotTable)) -> HashMap { + let mut wb = Workbook::new(); + let data = wb.sheet_mut(0).unwrap(); + data.name = "Data".into(); + let mut rows = 0; + for (c, (h, vals)) in columns.iter().enumerate() { + data.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h))); + for (r, v) in vals.iter().enumerate() { + if *v != Value::Empty { + data.cells.set(CellRef::new(r as u32 + 1, c as u32), Cell::value(v.clone())); + } + } + rows = rows.max(vals.len()); + } + let last = format!("{}{}", gridcraft_core::col_to_letters(columns.len() as u32 - 1), rows + 1); + let mut table = PivotTable { id: 1, name: "PT".into(), source: format!("Data!$A$1:{last}"), anchor: at("A3"), ..PivotTable::default() }; + pt(&mut table); + let mut report = Sheet::new("Report"); + report.pivots = vec![table]; + wb.sheets.push(Arc::new(report)); + unzip(&write_xlsx(&wb).unwrap()).into_iter().map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned())).collect() +} + +/// #1447: two value fields and no column field: Excel 365 adds a `Values` row unless told not to. +#[test] +fn two_value_fields_without_columns_hide_the_values_row() { + let cols = [ + ("Region", vec![Value::text("East"), Value::text("West"), Value::text("East")]), + ("Sales", vec![Value::number(10.0), Value::number(20.0), Value::number(5.0)]), + ("Qty", vec![Value::number(1.0), Value::number(2.0), Value::number(3.0)]), + ]; + let value = |c: &str| PivotValue { source_col: c.into(), name: format!("Sum of {c}"), ..PivotValue::default() }; + let two = small_pivot(&cols, |p| { + p.rows = vec![field("Region")]; + p.values = vec![value("Sales"), value("Qty")]; + }); + let t = &two["xl/pivotTables/pivotTable1.xml"]; + assert!(t.contains("firstHeaderRow=\"0\" firstDataRow=\"1\""), "{t}"); + assert!(t.contains(""), "{t}"); + let one = small_pivot(&cols, |p| { + p.rows = vec![field("Region")]; + p.values = vec![value("Sales")]; + }); + let t = &one["xl/pivotTables/pivotTable1.xml"]; + assert!(t.contains("firstHeaderRow=\"1\""), "{t}"); + assert!(!t.contains("hideValuesRow"), "{t}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..7ca15449b 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-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; 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; 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; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX PivotTables with two value fields keep their layout in Excel (#1447); .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; 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; 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; HTML export keeps right-to-left) · **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** @@ -91,6 +91,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 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-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | +| 2026-10-16 | minor | XLSX PivotTables: two value fields without a column field are written with `firstHeaderRow="0"` and `hideValuesRow`, so Excel 365 doesn't add a `Values` row that can turn the report into `#SPILL!` (#1447, item 6) | | 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) |