diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 9d10ea87c..c8ddf7460 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -188,6 +188,12 @@ impl RangeRef { end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)), } } + /// The top-left cell of a list of areas: the lowest row and the lowest column across all of + /// them, whatever order they are listed in. Excel writes the relative formulas of a + /// multi-area validation or conditional format for this cell. `None` for no areas. + pub fn top_left(ranges: &[RangeRef]) -> Option { + ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start) + } /// Row-major iterator over the cells. Callers must bound huge ranges themselves. pub fn iter(&self) -> impl Iterator + '_ { let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col); @@ -307,6 +313,16 @@ mod tests { Ok(()) } + #[test] + fn top_left_spans_every_area_in_any_order() { + let areas: Vec = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26")); + // Lowest row and lowest column, even when they come from different areas. + let areas: Vec = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect(); + assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2")); + assert_eq!(RangeRef::top_left(&[]), None); + } + #[test] fn sheet_quoting_follows_excel() { for quoted in [ diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 4c9325e36..38f5bb1fe 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -244,7 +244,7 @@ impl CfCache { let v = sh.value(c); let no_stats = RuleStats::default(); for (idx, cf) in rules { - let first = cf.ranges.first().map(|r| r.start).unwrap_or(c); + let first = RangeRef::top_left(&cf.ranges).unwrap_or(c); let st: &RuleStats = match stats_need(&cf.rule) { Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)), None => &no_stats, diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index ce0d7e3b8..ff8a93d61 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -156,7 +156,7 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { }; return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); + let at = RangeRef::top_left(&dv.ranges).unwrap_or_default(); match gridcraft_calc::evaluate(wb, sheet, at, f) { Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), v if !v.is_empty() && !v.is_error() => vec![v.display()], @@ -175,9 +175,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - return None; } let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, + // The formulas are written for the top-left cell of all the areas and see the value being entered, // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(at); let entered; let wb = if sh.value(at) == v { wb diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index c9e296ba5..9506b4abf 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -248,7 +248,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); if let Some(mut dv) = rule { // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); + let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc); dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); dv.ranges = vec![RangeRef::new(dest, dest)]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5afcbf9fa..f3d08f863 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2743,6 +2743,30 @@ fn validation_formulas_are_relative_and_see_the_new_value() { assert!(!ok(&mut s, "C1", "50")); } +#[test] +fn multi_area_rule_formulas_are_relative_to_the_top_left_cell_of_all_areas() { + // Issue #3152: Excel writes the formula for the top-left cell of all the areas (H26), whatever + // order they are listed in, not for the first area listed (H62). + let mut s = s(); + s.execute("range.setValues", json!({"range": "F26:F27", "values": [[4], [5]]})).unwrap(); + s.execute("data.validation", json!({"range": "H62:H68,H26:H27,H93:H95", "type": "custom", "formula1": "=MOD($H26,$F26)=0"})).unwrap(); + let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true; + assert!(ok(&mut s, "H26", "4")); + assert!(ok(&mut s, "H26", "8")); + assert!(!ok(&mut s, "H26", "3")); + assert!(ok(&mut s, "H27", "10")); + assert!(!ok(&mut s, "H27", "4")); + + // Conditional formats anchor on the same cell: `=D2>0` on D5:D6,D2:D3 checks D2 at D2. + s.execute("range.setValues", json!({"range": "D2:D6", "values": [[1], [0], [0], [1], [0]]})).unwrap(); + s.execute("home.conditionalFormat", json!({"range": "D5:D6,D2:D3", "rule": {"type": "expression", "formula": "=D2>0", "preset": "greenFill"}})) + .unwrap(); + let wb = s.doc().unwrap().wb.clone(); + let mut cache = crate::cf::CfCache::new(); + let mut styled = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).is_some(); + assert_eq!([styled("D2"), styled("D3"), styled("D5"), styled("D6")], [true, false, true, false]); +} + #[test] fn samples_build() { for (name, _) in crate::sample::SAMPLES { diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs index 821ec4cef..584c28c01 100644 --- a/crates/xlsx/src/sheet_write/rules.rs +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -53,7 +53,7 @@ pub(super) fn x14_cfvo(v: &CfValueKind) -> String { } pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { - let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); + let tl = RangeRef::top_left(&cf.ranges).map(|c| c.a1()).unwrap_or_else(|| "A1".into()); let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); let f = |x: &str| format!("{}", esc(&text_to_file(x))); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 1cbd845c9..e194a307d 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -1678,3 +1678,21 @@ fn workbook_view_writer_clamps_out_of_range_model_values() { assert_eq!(back.view.tab_ratio, 1000); } } + +#[test] +fn generated_rule_formulas_use_the_top_left_cell_of_every_area() { + // Issue #3152: the areas aren't in top-to-bottom order; the formula is written for C2, not C5. + let mut wb = Workbook::new(); + let red = Box::new(Style::default()); + wb.sheet_mut(0).unwrap().cond_formats.push(CondFormat { + ranges: vec![rr("C5:C6"), rr("C2:C3")], + rule: CfRule::ContainsText { text: "x".into(), style: red }, + priority: 1, + stop_if_true: false, + }); + let bytes = write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + z.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains("NOT(ISERROR(SEARCH(\"x\",C2)))"), "{xml}"); +}