Merge PR #1568: calc: bounded references up to 1,048,576 cells keep their full shape

This commit is contained in:
aiongg
2026-10-22 02:35:30 +00:00
3 changed files with 69 additions and 7 deletions
+19 -6
View File
@@ -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)]
@@ -154,8 +158,13 @@ impl<'h> Evaluator<'h> {
Ok(self.resolve_sheet(&r.sheet)?.into_iter().map(|sheet| Area { sheet, range }).collect())
}
/// Dereferences: one cell → its value; a block → an array (trimmed to the used range);
/// several areas → `#VALUE!`.
/// Dereferences: one cell → its value; a block → an array; several areas → `#VALUE!`.
///
/// A block keeps its full shape (blanks included) when it holds at most `FULL_SIZE_CELLS`
/// cells, or when it holds at most `FULL_SIZE_BOUNDED_CELLS` cells (one full column's worth)
/// and does not run to the sheet's last row or column. Every other block (larger, or a
/// bigger one that runs to the sheet's edge, such as `A:A` or `1:1`) is trimmed to the
/// sheet's used range before its values are read. An array over `MAX_CELLS` is `#NUM!`.
pub fn deref(&mut self, ev: Ev) -> Value {
match ev {
Ev::V(v) => v,
@@ -174,7 +183,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);
+48
View File
@@ -1412,6 +1412,54 @@ fn deep_loop_is_found_where_it_closes() {
assert_eq!(t.num("B1"), -1.0);
}
#[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}");
}
}
#[test]
fn median_large_values_recalculate_dependents() {
let mut t = T::new();
+2 -1
View File
@@ -91,7 +91,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.
@@ -116,6 +116,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|---|---|---|
| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation |
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 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 |