From c5a53a90ac2908408625838a8dc24469ab30f98c Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 21:41:43 -0400 Subject: [PATCH] #1447: booleans and errors count as strings in pivot cache flags Excel counts TRUE/FALSE and error values as strings in a cache field's . GridCraft wrote containsString="0" (and counted booleans and errors as separate kinds for containsMixedTypes) for such columns, which made the file unopenable in Excel, even when the column wasn't used in the pivot. Add Stats::strings() (text, booleans, errors) and use it for containsSemiMixedTypes, containsString and containsMixedTypes. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/pivot.rs | 12 ++++-- crates/xlsx/src/tests/pivot_tests.rs | 61 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 609ae530e..920d12ae7 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -208,8 +208,13 @@ impl Stats { Value::Array(_) => self.add(&v.scalar()), } } + /// Excel counts booleans and errors as strings in the cache field flags. + fn strings(&self) -> bool { + self.text || self.boolean || self.error + } + /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers. fn kinds(&self) -> usize { - [self.text, self.number, self.boolean, self.error].iter().filter(|b| **b).count() + usize::from(self.strings()) + usize::from(self.number) } } @@ -709,11 +714,10 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { } let _ = g; } else { - let semi = st.text || st.blank || st.boolean || st.error; - if !semi { + if !st.strings() && !st.blank { s.push_str(" containsSemiMixedTypes=\"0\""); } - if !st.text { + if !st.strings() { s.push_str(" containsString=\"0\""); } if st.blank { diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index 99a15d6e7..bb0adacc5 100644 --- a/crates/xlsx/src/tests/pivot_tests.rs +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -402,6 +402,67 @@ fn large_cache_is_written_without_records() { assert_eq!(back.sheet(0).unwrap().pivots.len(), 1); } +/// #1447 item 1: Excel counts booleans and errors as strings in ``; writing +/// `containsString="0"` for them makes a file Excel can't open. +#[test] +fn boolean_and_error_cache_flags_match_excel() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + let na = || Value::Error(gridcraft_core::CellError::NA); + let cols: [(&str, [Value; 3]); 7] = [ + ("K", [Value::text("a"), Value::text("b"), Value::text("a")]), + ("BoolOnly", [Value::Bool(true), Value::Bool(false), Value::Bool(true)]), + ("NumBool", [Value::number(1.0), Value::Bool(true), Value::number(2.5)]), + ("ErrOnly", [na(), na(), na()]), + ("NumErr", [Value::number(1.0), na(), Value::number(3.0)]), + ("TextBool", [Value::text("x"), Value::Bool(true), Value::text("y")]), + ("BoolBlank", [Value::Bool(true), Value::Empty, Value::Bool(false)]), + ]; + for (c, (h, vals)) in cols.iter().enumerate() { + s.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h))); + for (r, v) in vals.iter().enumerate() { + if !matches!(v, Value::Empty) { + s.cells.set(CellRef::new(r as u32 + 1, c as u32), Cell::value(v.clone())); + } + } + } + s.pivots.push(PivotTable { + id: 1, + source: "Sheet1!$A$1:$G$4".into(), + anchor: at("J1"), + rows: vec![field("K")], + values: vec![PivotValue { source_col: "NumBool".into(), name: "Sum of NumBool".into(), ..PivotValue::default() }], + last_range: Some(rr("J1:K4")), + ..PivotTable::default() + }); + let bytes = write_xlsx(&wb).unwrap(); + let parts = unzip(&bytes); + let c = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition1.xml").unwrap().clone()).unwrap(); + let start = |name: &str| -> String { + let i = c.find(&format!("').unwrap(); + c[j..=k].trim_end_matches('>').trim_end_matches('/').to_string() + }; + let flags = |name: &str| -> String { + let t = start(name); + // Drop the trailing count attribute, which depends on itemization. + match t.find(" count=") { + Some(i) => t[..i].to_string(), + None => t, + } + }; + assert_eq!(flags("BoolOnly"), ""#; const PT_CACHE_RELS: &str = r#""#;