From 62b17379e308dcc500e62a4249ce330201ff5a46 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:00:57 -0500 Subject: [PATCH 1/2] Ranges keep their size past the used range A multi-cell reference was read only up to the sheet's used range, so the array it gave depended on what else was on the sheet: ROWS(A1:A10*1), COUNTBLANK(A1:A10) and SUMPRODUCT(--(A1:A10="")) came out smaller than in Excel, and changed as cells were entered elsewhere without the formula recalculating. References of up to 16,384 cells now keep their size; larger ones (A:A, ranges reserved for data to come such as $A$2:$A$50000) still stop at the used range, so that formulas over them stay fast. --- crates/calc/src/eval.rs | 9 ++++++++- crates/calc/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54ffd..d09da091c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -13,6 +13,10 @@ use gridcraft_model::Workbook; /// Largest array a reference may expand to. pub const MAX_CELLS: u64 = 16_000_000; +/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges +/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that +/// formulas over them stay fast. +const FULL_SIZE_CELLS: u64 = 16_384; const MAX_DEPTH: usize = 400; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -129,7 +133,10 @@ impl<'h> Evaluator<'h> { if a.range.is_single() { return self.host.cell_value(a.sheet, a.range.start); } - let range = self.trim(a); + // A reference keeps its size, so the array has its shape and its blanks count + // (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`). + let cells = u64::from(a.range.height()) * u64::from(a.range.width()); + let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range }; let (h, w) = (range.height() as usize, range.width() as usize); if (h as u64) * (w as u64) > MAX_CELLS { return Value::Error(CellError::Num); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db0830d..9ab26179e 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -158,6 +158,24 @@ fn spills() { assert_eq!(t.num("D2"), 40.0); } +#[test] +fn references_keep_their_size_past_the_used_range() { + let mut t = T::new(); + t.set("A1", "5"); + t.set("A2", "6"); + t.set("C1", "=ROWS(A1:A10*1)"); + t.set("C2", "=SUMPRODUCT(--(A1:A10=\"\"))"); + t.set("C3", "=COUNTBLANK(A1:A10)"); + t.set("C4", "=COUNTIF(A1:A10,\"\")"); + t.set("C5", "=COLUMNS(TRANSPOSE(A1:A10))"); + for (at, want) in [("C1", 10.0), ("C2", 8.0), ("C3", 8.0), ("C4", 8.0), ("C5", 10.0)] { + assert_eq!(t.num(at), want, "{at}"); + } + // Whole columns are still read up to the used range. + t.set("D1", "=ROWS(FILTER(A:A,A:A<>\"\"))"); + assert_eq!(t.num("D1"), 2.0); +} + #[test] fn let_lambda() { let mut t = T::new(); From e5af1fcbff1fb89fb582330238a873741264ad4c Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:01:35 -0500 Subject: [PATCH 2/2] Spilled blank cells show 0 A formula's result that is a blank cell shows 0, but blanks inside an array result spilled as blank cells: =A1:A3, or FILTER or SORT of a range with blanks, left empty cells that ISBLANK saw as blank and COUNT skipped. Excel shows 0 in each. GROUPBY and PIVOTBY keep the blank cells of their own layout. --- crates/calc/src/recalc.rs | 15 +++++++++++++-- crates/calc/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c41c..70cda2629 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -207,10 +207,21 @@ impl PassHost<'_> { self.depth -= 1; self.in_progress.remove(&k); self.pending.remove(&k); + // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, + // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells + // of their own, which stay blank. + let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); let v = match v { - Value::Array(a) if a.rows == 1 && a.cols == 1 => a.data.first().cloned().unwrap_or_default(), Value::Array(a) => { - self.spills.insert(k, a.clone()); + let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { + a + } else { + let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); + Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) + }; + if a.rows > 1 || a.cols > 1 { + self.spills.insert(k, a.clone()); + } a.data.first().cloned().unwrap_or_default() } Value::Empty => Value::Number(0.0), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 9ab26179e..3d976344a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -176,6 +176,22 @@ fn references_keep_their_size_past_the_used_range() { assert_eq!(t.num("D1"), 2.0); } +#[test] +fn spilled_blank_cells_show_zero() { + let mut t = T::new(); + t.set("A1", "5"); + t.set("A3", "7"); + t.set("C1", "=A1:A4"); + assert_eq!(t.get("C2"), Value::Number(0.0)); + assert_eq!(t.get("C4"), Value::Number(0.0)); + t.set("D1", "=COUNT(C1:C4)"); + assert_eq!(t.num("D1"), 4.0); + t.set("E1", "=A2:A3"); + assert_eq!(t.get("E1"), Value::Number(0.0)); + t.set("F1", "=SORT(A1:A3)"); + assert_eq!(t.get("F3"), Value::Number(0.0)); +} + #[test] fn let_lambda() { let mut t = T::new();