From 5524e2520a2f9d303834c56455add045c247c58b Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:37 -0400 Subject: [PATCH 1/4] Functions: COUNTIF family returns #VALUE! for criteria over 255 characters Excel rejects a text criterion longer than 255 characters in COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS with #VALUE!; GridCraft matched it anyway. Found by the calc oracle on an Excel-saved report where a 268-character concatenated criterion caches #VALUE!. Co-Authored-By: Claude Opus 5.5 --- crates/functions/src/criteria.rs | 13 +++++++++++++ crates/functions/src/math.rs | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..db010fc 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,6 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; +/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. +const MAX_CRITERION_CHARS: usize = 255; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { Eq, @@ -48,6 +51,16 @@ impl Criterion { } } + /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: + /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + pub(crate) fn parse_arg(v: &Value) -> Result { + match v { + Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + other => Ok(Criterion::parse(other)), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index bab219e..1233a40 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -957,7 +957,7 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R { fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; num_val(range.iter().filter(|v| c.matches(v)).count() as f64) } @@ -1026,7 +1026,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { /// the evaluator does that resizing (it has the references), so cells line up one to one here. fn if_numbers(a: &[Arg]) -> R> { let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar()); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; let mut out = Vec::new(); for r in 0..range.rows { @@ -1643,4 +1643,16 @@ mod tests { is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); } + + #[test] + fn criteria_longer_than_255_chars_are_value_errors() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty), rf(cells), t(&long)]), CellError::Value); + } } From caf31932611bc2e774c294a1c9809836c5c77882 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:19:29 -0400 Subject: [PATCH 2/4] Functions: *IFS with two or more criteria pairs accept criteria over 255 characters, as in Excel --- crates/functions/src/criteria.rs | 5 +++-- crates/functions/src/math.rs | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index db010fc..2c87a9a 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -51,8 +51,9 @@ impl Criterion { } } - /// Parses the criteria argument of COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS and MINIFS: - /// like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS + /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 1233a40..75cea93 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,6 +950,10 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); + close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); } } From 4c2bcd637c97a6b065404edf44b4b042d2315cd9 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:34:18 -0400 Subject: [PATCH 3/4] Functions: *IFS with two or more criteria pairs match the first 255 characters of a longer criterion, as in Excel --- crates/functions/src/criteria.rs | 16 +++++++++++++++- crates/functions/src/math.rs | 21 ++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 2c87a9a..aa57177 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -53,7 +53,6 @@ impl Criterion { /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. - /// The *IFS functions with two or more criteria pairs use `parse`: Excel doesn't limit them. pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), @@ -62,6 +61,21 @@ impl Criterion { } } + /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel + /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and + /// MINIFS). + pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { + match v { + Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), + Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { + Some((end, _)) => Criterion::parse_text(&t[..end]), + None => Criterion::parse_text(t), + }, + other => Criterion::parse(other), + } + } + fn parse_text(t: &str) -> Criterion { let (op, rest) = if let Some(r) = t.strip_prefix(">=") { (Op::Ge, r) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 75cea93..8c67aa8 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -950,10 +950,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R R0")]), 1.0); - close(ev("COUNTIFS", vec![rf(qty.clone()), t(">0"), rf(cells.clone()), t(&long)]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty), t(">0")]), 2.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } } From e5b12dab06b0bff899b72d900022f6012fe6c516 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:52 -0400 Subject: [PATCH 4/4] Functions: COUNTIF family counts the 255 criterion limit in UTF-16 units, as in Excel --- crates/functions/src/criteria.rs | 93 ++++++++++++++++++++++---------- crates/functions/src/math.rs | 52 ++++++++++++++++++ 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index aa57177..2b4c488 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -5,8 +5,9 @@ use std::cmp::Ordering; use gridcraft_core::parse::parse_number_text; use gridcraft_core::{CellError, Value, compare_numbers, compare_text}; -/// Longest text criterion COUNTIF and its relatives accept; a longer one is `#VALUE!`. -const MAX_CRITERION_CHARS: usize = 255; +/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel +/// counts them (😀 is two units); a longer one is `#VALUE!`. +const MAX_CRITERION_UTF16_UNITS: usize = 255; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Op { @@ -25,6 +26,15 @@ enum Operand { Error(CellError), /// Text (lower-cased chars kept for wildcard matching). Text(String), + /// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the + /// complete characters before the cut, followed (in Excel) by a lone high surrogate. + /// + /// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as + /// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the + /// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above + /// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and + /// only matters for comparison criteria over 255 units. + CutText(String), /// Nothing after the operator (`"="`, `"<>"`) or an empty criterion. Blank, } @@ -52,49 +62,51 @@ impl Criterion { } /// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS - /// functions: like `parse`, but text longer than 255 characters is `#VALUE!`, as in Excel. + /// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in + /// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but + /// 256 units, and is `#VALUE!`). pub(crate) fn parse_arg(v: &Value) -> Result { match v { Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) if t.chars().nth(MAX_CRITERION_CHARS).is_some() => Err(CellError::Value), + // A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count. + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => { + Err(CellError::Value) + } other => Ok(Criterion::parse(other)), } } /// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel - /// doesn't reject a longer text criterion there: it matches its first 255 characters + /// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units /// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and - /// MINIFS). + /// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`. pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion { match v { Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)), - Value::Text(t) => match t.char_indices().nth(MAX_CRITERION_CHARS) { - Some((end, _)) => Criterion::parse_text(&t[..end]), - None => Criterion::parse_text(t), - }, + Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => { + let mut units = 0; + for (i, c) in t.char_indices() { + if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS { + if units == MAX_CRITERION_UTF16_UNITS { + return Criterion::parse_text(&t[..i]); + } + // The cut falls between the two halves of `c`. + let (op, rest) = split_op(&t[..i]); + return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false }; + } + units += c.len_utf16(); + } + Criterion::parse_text(t) + } other => Criterion::parse(other), } } fn parse_text(t: &str) -> Criterion { - let (op, rest) = if let Some(r) = t.strip_prefix(">=") { - (Op::Ge, r) - } else if let Some(r) = t.strip_prefix("<=") { - (Op::Le, r) - } else if let Some(r) = t.strip_prefix("<>") { - (Op::Ne, r) - } else if let Some(r) = t.strip_prefix('=') { - (Op::Eq, r) - } else if let Some(r) = t.strip_prefix('>') { - (Op::Gt, r) - } else if let Some(r) = t.strip_prefix('<') { - (Op::Lt, r) - } else { - if t.is_empty() { - return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; - } - (Op::Eq, t) - }; + if t.is_empty() { + return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true }; + } + let (op, rest) = split_op(t); let operand = if rest.is_empty() { Operand::Blank } else if let Some(n) = parse_number_text(rest) { @@ -168,10 +180,35 @@ impl Criterion { _ => false, }, }, + Operand::CutText(kept) => match self.op { + Op::Eq => false, + Op::Ne => true, + Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater), + Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater), + }, } } } +/// Splits a leading comparison operator (`=` when there is none) off a text criterion. +fn split_op(t: &str) -> (Op, &str) { + if let Some(r) = t.strip_prefix(">=") { + (Op::Ge, r) + } else if let Some(r) = t.strip_prefix("<=") { + (Op::Le, r) + } else if let Some(r) = t.strip_prefix("<>") { + (Op::Ne, r) + } else if let Some(r) = t.strip_prefix('=') { + (Op::Eq, r) + } else if let Some(r) = t.strip_prefix('>') { + (Op::Gt, r) + } else if let Some(r) = t.strip_prefix('<') { + (Op::Lt, r) + } else { + (Op::Eq, t) + } +} + fn cmp_op(op: Op, o: Ordering) -> bool { match op { Op::Eq => o == Ordering::Equal, diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 8c67aa8..505255c 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1668,4 +1668,56 @@ mod tests { close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); } + + #[test] + fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } + } + + #[test] + fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); + } }