From be8e379ca034e9124d7687c4289f28ad3e99f6df Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:06:09 -0500 Subject: [PATCH 1/2] Recalc: formulas over a blocked spill see #SPILL! A dynamic array that couldn't spill was only rejected when results were written back, after formulas evaluated later in the same pass had read the array as if it had spilled. With 1s in A1:C3 except B2 = SEQUENCE(2,2,9) (blocked), =SUM(A1:C3) gave 47 after a full recalculation (opening a file, Calculate Now) instead of #SPILL!; a reader through OFFSET kept the wrong value even when entered one cell at a time. The spill area is now checked as soon as the array is computed, so the anchor is #SPILL! for everything after it. --- crates/calc/src/recalc.rs | 28 ++++++++++++++++++++++++++++ crates/calc/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c41c..de9e3902a 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -184,6 +184,26 @@ struct PassHost<'a> { depth: usize, } +/// Whether an array result at `k` can't spill: its area runs off the sheet, overlaps merged cells, +/// holds other cells, or another formula's spill (unless that formula is recalculated in this +/// pass, and so may spill elsewhere now). +fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { + let Some(sh) = host.wb.sheet(sheet) else { return false }; + let end = + CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); + let range = RangeRef::new(anchor, end); + let own = sh.spill_ranges.get(&anchor); + let spilled_over = |c: CellRef| { + !own.is_some_and(|o| o.contains(c)) + && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) + }; + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.merges.iter().any(|m| m.intersects(&range)) + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) + || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) +} + impl PassHost<'_> { fn compute(&mut self, k: Key) -> Value { if let Some(v) = self.results.get(&k) { @@ -216,6 +236,14 @@ impl PassHost<'_> { Value::Empty => Value::Number(0.0), v => v, }; + // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; self.results.insert(k, v.clone()); v } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db0830d..e911ef989 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -105,6 +105,45 @@ fn operators() { assert_eq!(t.num("A7"), 0.0); } +#[test] +fn blocked_spill_reads_as_spill_error() { + // Calculated in the same pass, a formula over a blocked spill read the array that couldn't + // spill. + let mut t = T::new(); + let s = t.wb.sheet_mut(0).unwrap(); + for a in ["A1", "B1", "C1", "A2", "C2", "A3", "B3", "C3"] { + s.cells.set(c(a), Cell::value(Value::Number(1.0))); + } + s.cells.set(c("B2"), Cell::formula(Formula::new("=SEQUENCE(2,2,9)"))); + s.cells.set(c("E1"), Cell::formula(Formula::new("=SUM(A1:C3)"))); + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.get("B2"), Value::Error(CellError::Spill)); + assert_eq!(t.get("E1"), Value::Error(CellError::Spill)); + assert_eq!(t.num("C3"), 1.0); +} + +#[test] +fn blocked_spill_read_through_offset() { + // The reader's reference is only known while evaluating, so it isn't in the dependency graph. + let mut t = T::new(); + t.set("E4", "5"); + t.set("E8", "=SUM(OFFSET(E2,0,0,2,2))"); + t.set("E3", "=SEQUENCE(2,2,3)"); + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.get("E3"), Value::Error(CellError::Spill)); + assert_eq!(t.get("E8"), Value::Error(CellError::Spill)); +} + +#[test] +fn spill_into_the_old_area_of_a_blocked_neighbour() { + // C7's array becomes blocked by C8, whose array then takes over C7's old area. + let mut t = T::new(); + t.set("C7", "=SEQUENCE(2,2)"); + t.set("C8", "=SEQUENCE(2,2)"); + assert_eq!(t.get("C7"), Value::Error(CellError::Spill)); + assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]); +} + #[test] fn ifs_and_lazy() { let mut t = T::new(); From e5d34fd63a0f8c2208bfe85354466083e1b015ac Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:40:00 -0500 Subject: [PATCH 2/2] Recalc: dynamic arrays don't spill in tables Excel doesn't support spilled arrays in tables: a formula such as =SEQUENCE(2) entered in a table cell shows #SPILL! ("Spill range in table"). GridCraft spilled it over the table's cells. An array whose formula is in a table is now blocked, both when it is computed and when it is written back. --- crates/calc/src/recalc.rs | 10 ++++++---- crates/calc/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index de9e3902a..e2add5284 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -184,9 +184,9 @@ struct PassHost<'a> { depth: usize, } -/// Whether an array result at `k` can't spill: its area runs off the sheet, overlaps merged cells, -/// holds other cells, or another formula's spill (unless that formula is recalculated in this -/// pass, and so may spill elsewhere now). +/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, +/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is +/// recalculated in this pass, and so may spill elsewhere now). fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { let Some(sh) = host.wb.sheet(sheet) else { return false }; let end = @@ -200,6 +200,7 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { end.row >= gridcraft_core::MAX_ROWS || end.col >= gridcraft_core::MAX_COLS || sh.merges.iter().any(|m| m.intersects(&range)) + || sh.tables.iter().any(|t| t.range.contains(anchor)) || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) } @@ -542,7 +543,8 @@ impl Calc { let blocked = end.row >= gridcraft_core::MAX_ROWS || end.col >= gridcraft_core::MAX_COLS || range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c))) - || sheet.merges.iter().any(|m| m.intersects(&range)); + || sheet.merges.iter().any(|m| m.intersects(&range)) + || sheet.tables.iter().any(|t| t.range.contains(k.1)); if blocked { if let Some(cell) = sheet.cells.get_mut(k.1) { cell.value = Value::Error(CellError::Spill); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e911ef989..daebfd23f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -144,6 +144,33 @@ fn spill_into_the_old_area_of_a_blocked_neighbour() { assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]); } +#[test] +fn arrays_do_not_spill_in_tables() { + // Excel: spilled array formulas aren't supported in tables. + let mut t = T::new(); + t.wb.sheet_mut(0).unwrap().tables.push(gridcraft_model::Table { + id: 1, + name: "Table1".into(), + range: gridcraft_core::RangeRef::parse("B3:C6").unwrap(), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + }); + t.set("C4", "=SEQUENCE(2)"); + t.set("E1", "=SEQUENCE(4)"); + t.set("H1", "=SUM(C4:C5)"); + assert_eq!(t.get("C4"), Value::Error(CellError::Spill)); + assert_eq!(t.get("C5"), Value::Empty); + assert_eq!(t.get("H1"), Value::Error(CellError::Spill)); + assert_eq!(t.num("E4"), 4.0); +} + #[test] fn ifs_and_lazy() { let mut t = T::new();