From 2b1f851d0ae97d12f355a35732e7f6369c5a537a Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:07:16 -0500 Subject: [PATCH 1/3] Conditional formatting: apply every matching rule's format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the first matching rule's format was used, so with a light red fill for >300 and a red border for >500, 510 showed only the border. Excel applies every matching rule; where two rules set the same part (fill, font colour, a border edge…), the rule with the higher priority wins, and Stop If True still ends the list. --- crates/engine/src/cf.rs | 70 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index a102863..f7d3623 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -281,10 +281,12 @@ impl CfCache { }; if let Some(st) = matched_style { any = true; - out.style = Some(match out.style.take() { - None => st.clone(), - Some(prev) => prev, - }); + // Every matching rule applies; where two set the same part, the rule with the + // higher priority (seen first) wins. + match &mut out.style { + None => out.style = Some(st.clone()), + Some(prev) => layer_under(prev, st), + } if cf.stop_if_true { break; } @@ -294,6 +296,39 @@ impl CfCache { } } +/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts +/// are unset). +fn layer_under(style: &mut Style, lower: &Style) { + if style.fill.pattern == PatternType::None { + style.fill = lower.fill; + } + if style.font.color == Color::Auto { + style.font.color = lower.font.color; + } + style.font.bold |= lower.font.bold; + style.font.italic |= lower.font.italic; + style.font.strike |= lower.font.strike; + if style.font.underline == Underline::None { + style.font.underline = lower.font.underline; + } + let b = &mut style.borders; + for (dst, src) in [ + (&mut b.left, lower.borders.left), + (&mut b.right, lower.borders.right), + (&mut b.top, lower.borders.top), + (&mut b.bottom, lower.borders.bottom), + (&mut b.diag_down, lower.borders.diag_down), + (&mut b.diag_up, lower.borders.diag_up), + ] { + if dst.is_none() { + *dst = src; + } + } + if style.num_fmt.as_str() == "General" { + style.num_fmt = lower.num_fmt.clone(); + } +} + /// Shifts a rule formula written for the top-left cell of the range to `c`. fn relative(f: &str, first: CellRef, c: CellRef) -> String { if first == c { @@ -306,3 +341,30 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String { Err(_) => f.to_string(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn every_matching_rule_applies() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap(); + let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}}); + s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap(); + s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + assert!(look("A1").is_none()); + let only_fill = look("A2").unwrap(); + assert_eq!(only_fill.fill.pattern, PatternType::Solid); + assert!(only_fill.borders.top.is_none()); + let both = look("A3").unwrap(); + assert_eq!(both.fill, only_fill.fill); + assert!(!both.borders.top.is_none()); + } +} From 4e7379bd6801b935448183f052109993bc6cea06 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:55:17 -0500 Subject: [PATCH 2/3] Conditional formatting: cell value rules on blank and error cells Excel compares a blank cell as 0 (or empty text) in Cell Value rules, so "less than 5" or "equal to 0" also formats blank cells, and a cell holding an error never matches (the comparison is an error). GridCraft skipped blank cells and matched errors as if they sorted above every number ("greater than 5" formatted #N/A). The No Blanks rule also counted a cell holding only spaces as not blank, while the Blanks rule (like Excel's LEN(TRIM())) counted it as blank, so such a cell matched both. --- crates/engine/src/cf.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index f7d3623..6640c7a 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -144,7 +144,8 @@ impl CfCache { CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, }; - (ok && !v.is_empty()).then_some(style.as_ref()) + // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. + (ok && !v.is_error()).then_some(style.as_ref()) } CfRule::Expression { formula, style } => { gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) @@ -154,7 +155,7 @@ impl CfCache { CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()), CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), CfRule::Duplicate { style } => { @@ -367,4 +368,28 @@ mod tests { assert_eq!(both.fill, only_fill.fill); assert!(!both.borders.top.is_none()); } + + #[test] + fn cell_value_rules_on_blanks_and_errors() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap(); + let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r}); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap(); + s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"}))) + .unwrap(); + let d = s.doc().unwrap(); + let mut cache = CfCache::new(); + let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style); + let fill = |st: Option