diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs
index 78e1afaae..98946f6ac 100644
--- a/crates/xlsx/src/sheet_write.rs
+++ b/crates/xlsx/src/sheet_write.rs
@@ -76,10 +76,29 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let nstyles = wb.styles.len() as u32;
let sid = |s: gridcraft_model::StyleId| if s.0 < nstyles { s.0 } else { 0 };
+ // A table's dropdown filter is kept in `sheet.autofilter`, not in `Table::filter` (the
+ // engine's only live filter state, which the reader also fills). Where the two coincide,
+ // fold the sheet filter's criteria into the table so they reach the file: the stored
+ // `colId` offsets are relative to the range's first column, and both ranges start at the
+ // same column, so they carry over verbatim. A filter that only partially covers a table
+ // has nowhere valid to go, so it keeps being suppressed below.
+ let folded: Vec
= match &sheet.autofilter {
+ Some(af) if !af.criteria.is_empty() => sheet
+ .tables
+ .iter()
+ .cloned()
+ .map(|mut t| {
+ if t.header_row && t.filter_button && t.filter.is_empty() && t.range.contains_range(&af.range) {
+ t.filter = af.criteria.clone();
+ }
+ t
+ })
+ .collect(),
+ _ => sheet.tables.clone(),
+ };
// Table header cells must hold the column names.
let mut overrides: BTreeMap = BTreeMap::new();
- let tables: Vec<(&Table, Vec)> = sheet
- .tables
+ let tables: Vec<(&Table, Vec)> = folded
.iter()
.filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32))
.map(|t| (t, table_columns(t)))
@@ -341,7 +360,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
s.push_str(&protected_ranges_xml(sheet));
// autoFilter (a sheet filter overlapping a table would be invalid).
if let Some(af) = &sheet.autofilter
- && !sheet.tables.iter().any(|t| t.range.intersects(&af.range))
+ && !folded.iter().any(|t| t.range.contains_range(&af.range) && t.header_row && t.filter_button)
{
s.push_str(&autofilter_xml(af.range, &af.criteria, out));
}
diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs
index 7c7c19056..e1105bf1b 100644
--- a/crates/xlsx/src/tests/roundtrip.rs
+++ b/crates/xlsx/src/tests/roundtrip.rs
@@ -1165,3 +1165,60 @@ fn doc_props_keep_boundary_whitespace() {
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.props, wb.props);
}
+
+/// A table's dropdown filter lives in `sheet.autofilter` (the engine's only live filter state),
+/// not in `Table::filter`. Writing used to suppress the sheet `` because a table
+/// intersects it and write the table part from `Table::filter`, which was empty: the criteria
+/// were lost on every save, leaving the rows hidden with no filter to clear on reopen.
+#[test]
+fn a_table_filter_criteria_survive_a_round_trip() {
+ let mut wb = Workbook::new();
+ let s = wb.sheet_mut(0).unwrap();
+ for (r, v) in [(0u32, "Region"), (1, "East"), (2, "West"), (3, "East")] {
+ s.set_value(at(&format!("A{}", r + 1)), Value::text(v));
+ }
+ s.tables.push(Table {
+ id: 1,
+ name: "Sales".into(),
+ range: rr("A1:A4"),
+ header_row: true,
+ totals_row: false,
+ columns: vec![TableColumn {
+ name: "Region".into(),
+ totals: TotalsFn::None,
+ totals_label: None,
+ formula: None,
+ unique_name: None,
+ xml: None,
+ }],
+ style: "TableStyleMedium2".into(),
+ banded_rows: true,
+ banded_cols: false,
+ first_col: false,
+ last_col: false,
+ filter_button: true,
+ filter: vec![],
+ table_type: None,
+ connection_id: None,
+ });
+ // The dropdown of a table's header cell installs the sheet AutoFilter over the table's
+ // range, and `data.filterBy` stores its criteria there.
+ s.autofilter = Some(AutoFilter {
+ range: rr("A1:A4"),
+ criteria: vec![(0, FilterCriterion::Values { values: vec!["East".into()], blanks: false })],
+ });
+ s.rows.entry(1).or_default().hidden = true;
+
+ let (back, rep) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
+ assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
+ let b = back.sheet(0).unwrap();
+ let t = b.tables.first().expect("the table survived");
+ let (_, crit) = t.filter.first().expect("the table's filter criteria were kept on save");
+ assert!(
+ matches!(crit, FilterCriterion::Values { values, .. } if values == &["East".to_string()]),
+ "the criteria were dropped: {:?}",
+ t.filter
+ );
+ assert!(b.is_row_hidden(1), "the row the filter hid is still hidden");
+ assert!(b.autofilter.is_none(), "a table's filter belongs to the table part, not the sheet");
+}