diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..517f2fe9e 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -13,10 +13,14 @@ 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. +/// References up to this many cells (one whole row) are always read at their full size. const FULL_SIZE_CELLS: u64 = 16_384; +/// Bounded references up to this many cells (one column's worth) are read at their full size too, as +/// Excel does, so their shape lines up with the arrays next to them (`D3:AZ400*B3:B400` is 398 by +/// 49 even when the data ends at column F) and their blanks count. References that run to the edge +/// of the sheet (`A:A`, `A2:A1048576`, several whole rows) and larger blocks stop at the sheet's used +/// range, so that formulas over them stay fast. +const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576; const MAX_DEPTH: usize = 400; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -174,7 +178,11 @@ impl<'h> Evaluator<'h> { // 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 }; + // `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of + // the column", not for a block of a chosen size. + let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1; + let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS); + let range = if full_size { a.range } else { self.trim(a) }; 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 196c57de7..f623edbc0 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,51 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn large_bounded_references_keep_their_shape() { + // D3:AZ400 is 19,502 cells, past the size where a block used to be cut to the used range + // (which ends at column F here). Sliced that way it no longer lined up with the full-size + // operands next to it. Expected values observed in Excel 365 on the same layout. + let mut t = T::new(); + for (at, v) in [ + ("D1", "1"), + ("E1", "2"), + ("F1", "3"), + ("A3", "1"), + ("A4", "2"), + ("A5", "1"), + ("B3", "2"), + ("B4", "3"), + ("B5", "4"), + ("D3", "10"), + ("E3", "20"), + ("F3", "30"), + ("D4", "40"), + ("E4", "50"), + ("F4", "60"), + ("D5", "70"), + ("E5", "80"), + ("F5", "90"), + ] { + t.set(at, v); + } + let cases = [ + ("=SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(360.0)), + ("=ROWS(D3:AZ400*1)", Value::Number(398.0)), + ("=COLUMNS(D3:AZ400*1)", Value::Number(49.0)), + ("=SUMPRODUCT(D3:AZ400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(100.0)), + ("=SUMPRODUCT(D3:AZ400+0,D3:AZ401)", Value::Error(CellError::Value)), + ("=COUNTBLANK(D3:AZ400)", Value::Number(19493.0)), + ("=SUMPRODUCT(--(D3:AZ400=\"\"))", Value::Number(19493.0)), + ("=SUM(D3:AZ400*B3:B400)", Value::Number(1530.0)), + ("=COLUMNS(TRANSPOSE(D3:AZ400))", Value::Number(398.0)), + // Whole columns are still read up to the used range. + ("=SUMPRODUCT((A:A=1)*B:B)", Value::Number(6.0)), + ]; + for (i, (formula, want)) in cases.iter().enumerate() { + let at = format!("C{}", 1001 + i); + t.set(&at, formula); + assert_eq!(&t.get(&at), want, "{formula}"); + } +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..5d5e80409 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (bounded references up to 1,048,576 cells keep their full shape in array formulas, #1010) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -90,7 +90,7 @@ PIVOTBY, REGEXTEST…) are all present. **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many assertions each, but there is no conformance suite against Excel-computed results, and user reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range -(#137), ROUND at 15 digits and DATE(1900,2,29) (fixed 10-09), SUMIF sum-range sizing (fixed +(#137, and past 16,384 cells #1010, fixed 10-14 for bounded references), ROUND at 15 digits and DATE(1900,2,29) (fixed 10-09), SUMIF sum-range sizing (fixed 10-09). Statistical distributions and financial functions with iterative solvers (RATE, IRR, XIRR, YIELD) are the highest-risk for last-digit differences. @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |