From 11e8eddb34a82771917054904f12c53b910d70d8 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Sun, 4 Oct 2026 13:51:58 +0700 Subject: [PATCH 1/3] fix(wasm): recalculate a spill that read another spill too early Spills are calculated in cell order. A spill that reads cells of a spill placed later in the same pass read the previous values and was not calculated again, so it stayed one edit behind. The recalculation now logs the cells each spill changes, finds the spills that read them before the change, and calculates those again. A spill that keeps coming back reads its own output through other spills and gets #CYCLE!. --- .changeset/spill-read-order.md | 6 ++++ packages/wasm/src/eval/dependency.rs | 31 +++++++++++++++++ packages/wasm/src/eval/mod.rs | 52 +++++++++++++++++++++++++++- packages/wasm/src/tests.rs | 30 ++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 .changeset/spill-read-order.md diff --git a/.changeset/spill-read-order.md b/.changeset/spill-read-order.md new file mode 100644 index 00000000..719f67d5 --- /dev/null +++ b/.changeset/spill-read-order.md @@ -0,0 +1,6 @@ +--- +"@sheetwrite/wasm": patch +"@sheetwrite/formulas": patch +--- + +Fix a spill that reads another spill showing the previous values. When the spill it reads is placed later in the same recalculation, the reading spill is now calculated again. Spills that read each other show #CYCLE!. diff --git a/packages/wasm/src/eval/dependency.rs b/packages/wasm/src/eval/dependency.rs index c2e6e642..e9ca7dca 100644 --- a/packages/wasm/src/eval/dependency.rs +++ b/packages/wasm/src/eval/dependency.rs @@ -353,6 +353,37 @@ pub(super) fn collect_affected_formulas( affected } +/// Formulas that read any of `cells` directly, by cell or by range. Unlike +/// [`collect_affected_formulas`], this does not follow dependents further. +pub(super) fn direct_readers( + index: &DepIndex, + sheet: usize, + cells: &[(u32, u32)], +) -> HashSet { + let mut readers = HashSet::new(); + let mut range_matches: Vec = Vec::new(); + let mut row_matches: Vec = Vec::new(); + let mut range_marks = vec![0; index.range_groups.len()]; + let mut range_stamp = 1; + for &cell in cells { + let cell = AbsCellKey::from_local(sheet, cell); + if let Some(dependents) = index.exact_dependents.get(&cell) { + readers.extend(dependents.iter().copied()); + } + index.collect_matching_range_groups( + cell, + &mut range_matches, + &mut row_matches, + &mut range_marks, + &mut range_stamp, + ); + for &group in &range_matches { + readers.extend(index.range_groups[group].dependents.iter().copied()); + } + } + readers +} + fn formula_exists(sheets: &[SheetData], key: AbsCellKey) -> bool { sheets .get(key.sheet as usize) diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index dade8f24..29df74e8 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -33,7 +33,9 @@ use crate::types::{ use array::{ast_produces_array, dynamic_recompute_within_limit}; use criteria::{aggregate_if, extreme_if, Criterion, IfExtreme, IfSum}; pub(crate) use dependency::DepIndex; -use dependency::{build_dep_index, collect_affected_formulas, seed_dependency_depth_errors}; +use dependency::{ + build_dep_index, collect_affected_formulas, direct_readers, seed_dependency_depth_errors, +}; use functions::{apply_func, treats_cell_as_reference, FuncAccumulator, ReductionFold}; use lookup::{ clear_cached_lookups, find_match_index_indexed, integer_arg, list_reuse, positive_index, @@ -341,6 +343,13 @@ fn address_text( Ok(output) } +/// Cells that one spill install or clear changed, and when it happened. +struct SpillChange { + sheet: usize, + sequence: usize, + cells: Vec<(u32, u32)>, +} + impl CellStore { pub(crate) fn recompute_sheet(&mut self, sheet: usize) { self.recompute_seed_sheets(&[sheet]); @@ -458,6 +467,15 @@ impl CellStore { let mut seeded_sheets: HashSet = seeds.iter().copied().collect(); let mut spill_work = 0usize; + // Spills are evaluated in cell order, so a spill can read another + // spill before that one is installed. `evaluated_at` and the change + // log find readers that ran before a spill they read changed, and the + // next pass evaluates them again. A reader that keeps coming back + // reads its own output through other spills: that is a cycle. + let mut sequence = 0usize; + let mut evaluated_at: HashMap = HashMap::new(); + let mut requeued: HashMap = HashMap::new(); + let mut cyclic: HashSet = HashSet::new(); loop { let mut pending: Vec<(AbsCellKey, Ast)> = affected @@ -482,8 +500,11 @@ impl CellStore { pending.sort_unstable_by_key(|(key, _)| (key.sheet, key.row, key.col)); let mut changed_sheets = HashSet::new(); + let mut spill_changes: Vec = Vec::new(); for (key, ast) in pending { processed_arrays.insert(key); + sequence += 1; + evaluated_at.insert(key, sequence); let local = key.local(); let output_sheet = key.sheet as usize; let vacated_range = self.sheets[output_sheet] @@ -495,6 +516,11 @@ impl CellStore { .flatten(); let cleared = self.sheets[output_sheet].clear_spill(local); if !cleared.is_empty() { + spill_changes.push(SpillChange { + sheet: output_sheet, + sequence, + cells: cleared.clone(), + }); self.sheets[output_sheet].dirty_cells.extend(cleared); changed_sheets.insert(output_sheet); seeded_sheets.insert(output_sheet); @@ -516,6 +542,7 @@ impl CellStore { memo.remove(&key); let evaluated = match self.dynamic_array_bound(&ast, output_sheet) { + _ if cyclic.contains(&key) => Err(FormulaError::Cycle), Some(Ok(bound)) => match dynamic_recompute_within_limit(spill_work, bound) { Some(total) => { spill_work = total; @@ -537,6 +564,11 @@ impl CellStore { }; let changed = self.install_spill_result(key, evaluated, &mut memo); if !changed.is_empty() { + spill_changes.push(SpillChange { + sheet: output_sheet, + sequence, + cells: changed.clone(), + }); self.sheets[output_sheet].dirty_cells.extend(changed); changed_sheets.insert(output_sheet); seeded_sheets.insert(output_sheet); @@ -553,6 +585,24 @@ impl CellStore { index, )); } + for change in spill_changes { + for reader in direct_readers(index, change.sheet, &change.cells) { + let is_stale = evaluated_at + .get(&reader) + .is_some_and(|&evaluated| evaluated < change.sequence); + if !is_stale || cyclic.contains(&reader) { + continue; + } + let count = requeued.entry(reader).or_insert(0); + *count += 1; + // Without a cycle, a reader comes back at most once per + // spill in front of it. + if *count > affected.len() { + cyclic.insert(reader); + } + processed_arrays.remove(&reader); + } + } seed_dependency_depth_errors(&self.sheets, &affected, index, &mut memo); } diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index 40ca3ac5..37555c6d 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -3531,3 +3531,33 @@ fn rejected_packed_text_adds_nothing_to_the_string_pool() { assert_eq!(string(&store, sheet, 0, 0).as_deref(), Some("é")); assert_eq!(string(&store, sheet, 0, 1).as_deref(), Some("a")); } + +#[test] +fn spill_that_reads_a_later_spill_sees_its_current_values() { + // B1 is evaluated before C1 in cell order, but must read C1's new spill. + let mut store = CellStore::new(); + let sheet = store.add_sheet(6, 8); + store.set_number(sheet, 0, 0, 3.0, 0); + store.set_formula(sheet, 0, 2, "=SEQUENCE(A1)", 0); + store.set_formula(sheet, 0, 1, "=C1:C5", 0); + store.recompute(sheet); + let column_b = |store: &CellStore| -> Vec { + (0..5).map(|row| store.get_cell(sheet, row, 1).num()).collect() + }; + assert_eq!(column_b(&store), [1.0, 2.0, 3.0, 0.0, 0.0]); + store.set_number(sheet, 0, 0, 4.0, 0); + store.recompute(sheet); + assert_eq!(column_b(&store), [1.0, 2.0, 3.0, 4.0, 0.0]); +} + +#[test] +fn spills_that_read_each_other_end_as_a_cycle() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(6, 8); + store.set_formula(sheet, 0, 0, "=C1:C2", 0); + store.set_formula(sheet, 0, 2, "=A1:A2", 0); + store.recompute(sheet); + let shown = |col| store.get_cell(sheet, 0, col).string(); + assert_eq!(shown(0).as_deref(), Some("#CYCLE!")); + assert_eq!(shown(2).as_deref(), Some("#CYCLE!")); +} From e725d980a596c2c8bf9a8d46ea444bdc5f8e279b Mon Sep 17 00:00:00 2001 From: chh-ay Date: Sun, 4 Oct 2026 17:59:37 +0700 Subject: [PATCH 2/3] feat(formulas): support spill references such as A1# --- .changeset/spill-references.md | 8 ++ docs/public/llms-full.txt | 10 +- docs/src/content/docs/guides/formulas.md | 10 +- packages/core/src/a1.ts | 8 +- packages/core/test/a1.test.ts | 10 ++ packages/wasm/src/calc.rs | 52 ++++++++- packages/wasm/src/eval/array.rs | 70 +++++++++++- packages/wasm/src/eval/mod.rs | 49 +++++--- packages/wasm/src/sheet.rs | 9 ++ packages/wasm/src/tests.rs | 106 ++++++++++++++++++ packages/wasm/src/types.rs | 19 ++++ packages/xlsx/src/formula.ts | 34 ++++++ packages/xlsx/src/reader.ts | 4 +- packages/xlsx/src/writer.ts | 4 +- .../test/formula-source-preservation.test.ts | 20 +++- 15 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 .changeset/spill-references.md diff --git a/.changeset/spill-references.md b/.changeset/spill-references.md new file mode 100644 index 00000000..05048335 --- /dev/null +++ b/.changeset/spill-references.md @@ -0,0 +1,8 @@ +--- +"@sheetwrite/wasm": patch +"@sheetwrite/formulas": patch +"@sheetwrite/core": patch +"@sheetwrite/xlsx": patch +--- + +Support spill references such as `A1#`. A spill reference refers to the current spill of its anchor cell and follows it when the spill changes size. It returns `#REF!` when the anchor holds no spill. Row and column edits move the reference with its anchor. XLSX import and export convert it to and from `_xlfn.ANCHORARRAY`. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 98adfd27..821e848b 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2116,11 +2116,19 @@ Elements can be numbers with an optional sign, quoted text, `TRUE`, `FALSE`, or Functions that accept arrays can use these values directly, for example `=SUM({1,2,3})` returns `6` and `=INDEX({1,2;3,4},2,1)` returns `3`. Copy and structural edits preserve constants and their text elements. XLSX import and export preserve the formula source. +## Spill references + +Put `#` after a cell reference to refer to the whole spill of the formula in that cell. If `A1` contains `=SEQUENCE(D1)`, then `=SUM(A1#)` adds all the values in the spill, and `=SORT(A1#,1,-1)` spills them in reverse order. When the spill changes size, formulas that use `A1#` use the new range. A sheet name is permitted: `='Report'!B2#`. + +`A1#` returns `#REF!` when the cell does not hold a spill: for example, the cell contains a value, or a collision blocks the spill (`#SPILL!`). If you delete the row or column of the anchor cell, the reference becomes `#REF!`. Both engines support spill references. XLSX files store `A1#` as `_xlfn.ANCHORARRAY(A1)`; import and export convert between the two forms. + +Arithmetic on a whole spill, such as `=A1#*10`, returns `#VALUE!`, the same as `=A1:A3*10`. Use a function that accepts a range, such as `SUM`, `INDEX`, `SORT`, or `FILTER`. + ## Operators | Precedence, high to low | Operators | Associativity | | --- | --- | --- | -| Reference | `:` | — | +| Reference | `:`, postfix `#` (spill) | — | | Unary sign | unary `+`, unary `-` | right | | Percentage | postfix `%` | left | | Exponentiation | `^` | left | diff --git a/docs/src/content/docs/guides/formulas.md b/docs/src/content/docs/guides/formulas.md index cfbf3095..21638f80 100644 --- a/docs/src/content/docs/guides/formulas.md +++ b/docs/src/content/docs/guides/formulas.md @@ -118,11 +118,19 @@ Elements can be numbers with an optional sign, quoted text, `TRUE`, `FALSE`, or Functions that accept arrays can use these values directly, for example `=SUM({1,2,3})` returns `6` and `=INDEX({1,2;3,4},2,1)` returns `3`. Copy and structural edits preserve constants and their text elements. XLSX import and export preserve the formula source. +## Spill references + +Put `#` after a cell reference to refer to the whole spill of the formula in that cell. If `A1` contains `=SEQUENCE(D1)`, then `=SUM(A1#)` adds all the values in the spill, and `=SORT(A1#,1,-1)` spills them in reverse order. When the spill changes size, formulas that use `A1#` use the new range. A sheet name is permitted: `='Report'!B2#`. + +`A1#` returns `#REF!` when the cell does not hold a spill: for example, the cell contains a value, or a collision blocks the spill (`#SPILL!`). If you delete the row or column of the anchor cell, the reference becomes `#REF!`. Both engines support spill references. XLSX files store `A1#` as `_xlfn.ANCHORARRAY(A1)`; import and export convert between the two forms. + +Arithmetic on a whole spill, such as `=A1#*10`, returns `#VALUE!`, the same as `=A1:A3*10`. Use a function that accepts a range, such as `SUM`, `INDEX`, `SORT`, or `FILTER`. + ## Operators | Precedence, high to low | Operators | Associativity | | --- | --- | --- | -| Reference | `:` | — | +| Reference | `:`, postfix `#` (spill) | — | | Unary sign | unary `+`, unary `-` | right | | Percentage | postfix `%` | left | | Exponentiation | `^` | left | diff --git a/packages/core/src/a1.ts b/packages/core/src/a1.ts index 430304c1..5e2ee360 100644 --- a/packages/core/src/a1.ts +++ b/packages/core/src/a1.ts @@ -84,13 +84,14 @@ export function remapFormulaA1Refs( remap: (index: number) => number | null, ): string { return source.replace( - /"(?:[^"]|"")*"|'(?:[^']|'')*'|(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)/g, + /"(?:[^"]|"")*"|'(?:[^']|'')*'|(\$?)([A-Za-z]{1,3})(\$?)([0-9]+)(#?)/g, ( match: string, colAbsolute: string, letters: string, rowAbsolute: string, digits: string, + spill: string, offset: number, full: string, ): string => { @@ -100,10 +101,11 @@ export function remapFormulaA1Refs( const col = labelToCol(letters.toUpperCase()); const row = Number(digits) - 1; const mapped = remap(axis === "row" ? row : col); + // A deleted anchor also ends its spill reference: `A1#` becomes `#REF!`. if (mapped === null) return "#REF!"; return axis === "row" - ? `${colAbsolute}${colToA1(col)}${rowAbsolute}${mapped + 1}` - : `${colAbsolute}${colToA1(mapped)}${rowAbsolute}${row + 1}`; + ? `${colAbsolute}${colToA1(col)}${rowAbsolute}${mapped + 1}${spill}` + : `${colAbsolute}${colToA1(mapped)}${rowAbsolute}${row + 1}${spill}`; }, ); } diff --git a/packages/core/test/a1.test.ts b/packages/core/test/a1.test.ts index 2f9b9a65..da3792f6 100644 --- a/packages/core/test/a1.test.ts +++ b/packages/core/test/a1.test.ts @@ -37,6 +37,10 @@ describe("shiftA1Refs", () => { '=SUM({1,2;3,4})+B3&{"A1","a""B2"}', ); }); + + it("keeps the spill suffix of a filled reference", () => { + expect(shiftA1Refs("=SUM(A1#)+$B$2#", 1, 1)).toBe("=SUM(B2#)+$B$2#"); + }); }); describe("remapFormulaA1Refs", () => { @@ -46,6 +50,12 @@ describe("remapFormulaA1Refs", () => { expect(remapFormulaA1Refs("=A1+B1+C1", "column", removeColumn)).toBe("=A1+#REF!+B1"); }); + it("moves spill references with their anchor and drops the suffix when it is deleted", () => { + const removeRow = (row: number) => (row === 1 ? null : row > 1 ? row - 1 : row); + expect(remapFormulaA1Refs("=SUM(A3#)", "row", removeRow)).toBe("=SUM(A2#)"); + expect(remapFormulaA1Refs("=SUM(A2#)", "row", removeRow)).toBe("=SUM(#REF!)"); + }); + it("preserves array text while remapping adjacent references", () => { expect(remapFormulaA1Refs('=A1&{"A1","B2";"C3","D4"}', "row", (row) => row + 1)).toBe( '=A2&{"A1","B2";"C3","D4"}', diff --git a/packages/wasm/src/calc.rs b/packages/wasm/src/calc.rs index 73139098..8ef9fac6 100644 --- a/packages/wasm/src/calc.rs +++ b/packages/wasm/src/calc.rs @@ -198,6 +198,9 @@ pub enum Func { Rate, Ipmt, Ppmt, + /// `A1#`: the current spill of the anchor cell. Only `#` after a cell + /// reference makes it; the name is not in the function table. + AnchorArray, #[cfg(feature = "analysis")] Analysis(u16), } @@ -257,6 +260,7 @@ macro_rules! define_function_registry { fn func_name(func: Func) -> &'static str { match func { $(Func::$variant => $canonical,)+ + Func::AnchorArray => "ANCHORARRAY", #[cfg(feature = "analysis")] Func::Analysis(id) => crate::eval::analysis::name(id), } @@ -760,6 +764,8 @@ enum Tok<'a> { Bang, Cmp(CmpOp), Error(FormulaError), + /// The `#` after a cell reference: the whole spill of that anchor. + Hash, } /// Error constants a formula can contain. `#REF!` parses as an invalid @@ -944,6 +950,10 @@ fn tokenize(src: &str) -> Result>, String> { toks.push(Tok::Colon); i += 1; } + b'#' => { + toks.push(Tok::Hash); + i += 1; + } _ if !c.is_ascii() => { let ch = src[i..].chars().next().expect("valid UTF-8 source"); if ch.is_whitespace() { @@ -1273,7 +1283,7 @@ impl<'a> Parser<'a> { _ => Err("expected cell after :".into()), } } else { - Ok(Ast::Cell(row, col, flags)) + Ok(self.spill_suffix(Ast::Cell(row, col, flags))) } } } @@ -1298,7 +1308,18 @@ impl<'a> Parser<'a> { return Ok(Ast::SheetRange(qualifier, r0, c0, r1, c1, range_flags)); } - Ok(Ast::SheetCell(qualifier, row, col, flags)) + Ok(self.spill_suffix(Ast::SheetCell(qualifier, row, col, flags))) + } + + /// `A1#` refers to the whole spill of the formula in A1. It is stored as + /// `ANCHORARRAY(A1)`, the form that XLSX files use. + fn spill_suffix(&mut self, anchor: Ast) -> Ast { + if self.peek() == Some(&Tok::Hash) { + let _ = self.next(); + Ast::Func(Func::AnchorArray, vec![anchor]) + } else { + anchor + } } fn sheet_range_end( @@ -2091,6 +2112,13 @@ fn write_ast(ast: &Ast, out: &mut String) { out.push('}'); } Ast::LetSlot { expression, .. } => write_ast(expression, out), + Ast::Func(Func::AnchorArray, args) if args.len() == 1 => { + write_ast(&args[0], out); + // A deleted anchor writes as #REF!, which has no spill suffix. + if !matches!(args[0], Ast::InvalidRef) { + out.push('#'); + } + } Ast::Func(func, args) => { out.push_str(func_name(*func)); out.push('('); @@ -2214,6 +2242,26 @@ fn write_col(mut col: u32, out: &mut String) { mod tests { use super::*; + #[test] + fn spill_references_parse_and_serialize_with_a_hash_suffix() { + for (source, expected) in [ + ("=A1#", "=A1#"), + ("=sum($b$2#)", "=SUM($B$2#)"), + ("=ROWS('My Sheet'!C3#)", "=ROWS('My Sheet'!C3#)"), + ] { + assert_eq!(serialize(&parse(source).unwrap()), expected, "{source}"); + } + assert_eq!( + parse("=A1#").unwrap(), + Ast::Func(Func::AnchorArray, vec![Ast::Cell(0, 0, RefFlags::default())]) + ); + // `#` only follows a cell, and the function name cannot be typed. + for source in ["=A1:B2#", "=#", "=1#"] { + assert!(parse(source).is_err(), "{source}"); + } + assert!(matches!(parse("=ANCHORARRAY(A1)"), Ok(Ast::UnknownFunc(..)))); + } + #[test] fn parse_a1_accepts_absolute_markers() { assert_eq!(parse_a1("A1"), Some((0, 0, RefFlags::default()))); diff --git a/packages/wasm/src/eval/array.rs b/packages/wasm/src/eval/array.rs index 50a4dae5..df4d0595 100644 --- a/packages/wasm/src/eval/array.rs +++ b/packages/wasm/src/eval/array.rs @@ -5,7 +5,7 @@ use std::collections::{hash_map::DefaultHasher, HashMap}; use std::hash::{Hash, Hasher}; use std::mem::size_of; -use crate::calc::{Ast, Func}; +use crate::calc::{Ast, Func, RangeFlags, SheetRef}; use crate::store::CellStore; use crate::types::{AbsCellKey, EvalResult, FormulaError, Value}; @@ -32,6 +32,7 @@ fn static_integer(ast: Option<&Ast>) -> Option { pub(super) fn ast_produces_array(ast: &Ast) -> bool { match ast { + Ast::Func(Func::AnchorArray, _) => true, #[cfg(feature = "analysis")] Ast::BoundMatrix { .. } => true, #[cfg(feature = "analysis")] @@ -105,12 +106,70 @@ fn transformed_axis_bound(size: usize, requested: i64, drop: bool) -> Result Ast { + let (sheet, row, col) = match args { + [Ast::Cell(row, col, _)] => (formula_sheet, *row, *col), + [Ast::AbsCell(sheet, row, col, _)] => (sheet.handle as usize, *row, *col), + _ => return Ast::InvalidRef, + }; + let Some(range) = self + .sheets + .get(sheet) + .and_then(|data| data.installed_spill((row, col))) + else { + return Ast::InvalidRef; + }; + let sheet = SheetRef { + handle: sheet as u32, + name: String::new(), + quoted: false, + }; + Ast::AbsRange(sheet, row, col, range.row_end, range.col_end, RangeFlags::default()) + } + + /// A copy of `ast` in which every `A1#` is the range that its anchor + /// spills into now. Range functions such as SUMIF, INDEX and ROWS then + /// accept spill references without special handling. + pub(super) fn resolve_spill_refs(&self, ast: &Ast, sheet: usize) -> Ast { + let mut resolved = ast.clone(); + self.replace_spill_refs(&mut resolved, sheet); + resolved + } + + fn replace_spill_refs(&self, ast: &mut Ast, sheet: usize) { + match ast { + Ast::Func(Func::AnchorArray, args) => *ast = self.spill_reference(args, sheet), + Ast::Func(_, args) | Ast::UnknownFunc(_, args) => { + for arg in args { + self.replace_spill_refs(arg, sheet); + } + } + Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => { + self.replace_spill_refs(left, sheet); + self.replace_spill_refs(right, sheet); + } + Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) => { + self.replace_spill_refs(inner, sheet); + } + Ast::LetSlot { expression, .. } => self.replace_spill_refs(expression, sheet), + _ => {} + } + } + pub(super) fn dynamic_array_bound( &self, ast: &Ast, formula_sheet: usize, ) -> Option> { match ast { + Ast::Func(Func::AnchorArray, args) => { + let range = self.spill_reference(args, formula_sheet); + Some(self.matrix_shape(&range, formula_sheet).map(|(_, _, cells)| cells)) + } + // A deleted reference, or `A1#` when A1 holds no spill. + Ast::InvalidRef => Some(Err(FormulaError::Ref)), #[cfg(feature = "analysis")] Ast::BoundMatrix { rows, cols, .. } => Some(EvalMatrix::validate_shape(*rows, *cols, 1, 0)), #[cfg(feature = "analysis")] @@ -220,6 +279,11 @@ impl CellStore { depth: usize, ) -> Option> { let result = match ast { + Ast::Func(Func::AnchorArray, args) => { + let range = self.spill_reference(args, sheet); + return self.eval_dynamic_array(&range, sheet, affected, memo, visiting, depth); + } + Ast::InvalidRef => Err(FormulaError::Ref), #[cfg(feature = "analysis")] Ast::BoundMatrix { rows, cols, values } => { Ok(EvalMatrix::new(*rows, *cols, values.as_ref().clone())) @@ -319,6 +383,10 @@ impl CellStore { formula_sheet: usize, ) -> Result<(usize, usize, usize), FormulaError> { match ast { + Ast::Func(Func::AnchorArray, args) => { + return self.matrix_shape(&self.spill_reference(args, formula_sheet), formula_sheet); + } + Ast::InvalidRef => return Err(FormulaError::Ref), #[cfg(feature = "analysis")] Ast::BoundMatrix { rows, cols, .. } => { return Ok((*rows, *cols, EvalMatrix::validate_shape(*rows, *cols, 1, 0)?)); diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index 29df74e8..8c481a45 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -478,42 +478,41 @@ impl CellStore { let mut cyclic: HashSet = HashSet::new(); loop { - let mut pending: Vec<(AbsCellKey, Ast)> = affected + let mut pending: Vec<(AbsCellKey, Ast, bool)> = affected .iter() .filter(|key| !processed_arrays.contains(key)) .filter_map(|&key| { - let ast = self + let entry = self .sheets .get(key.sheet as usize)? .formulas - .get(&key.local())? - .ast - .as_ref()?; + .get(&key.local())?; + let ast = entry.ast.as_ref()?; self.dynamic_array_bound(ast, key.sheet as usize) .is_some() - .then(|| (key, ast.clone())) + .then(|| (key, ast.clone(), entry.has_spill_refs)) }) .collect(); if pending.is_empty() { break; } - pending.sort_unstable_by_key(|(key, _)| (key.sheet, key.row, key.col)); + pending.sort_unstable_by_key(|(key, ..)| (key.sheet, key.row, key.col)); let mut changed_sheets = HashSet::new(); let mut spill_changes: Vec = Vec::new(); - for (key, ast) in pending { + for (key, ast, has_spill_refs) in pending { + // Spill references read the spills installed so far in this pass. + let ast = if has_spill_refs { + self.resolve_spill_refs(&ast, key.sheet as usize) + } else { + ast + }; processed_arrays.insert(key); sequence += 1; evaluated_at.insert(key, sequence); let local = key.local(); let output_sheet = key.sheet as usize; - let vacated_range = self.sheets[output_sheet] - .spill_owner(local) - .and_then(|owner| { - (owner == local) - .then(|| self.sheets[output_sheet].spill_ranges.get(&local).copied()) - }) - .flatten(); + let vacated_range = self.sheets[output_sheet].installed_spill(local); let cleared = self.sheets[output_sheet].clear_spill(local); if !cleared.is_empty() { spill_changes.push(SpillChange { @@ -563,12 +562,21 @@ impl CellStore { None => Err(FormulaError::Value), }; let changed = self.install_spill_result(key, evaluated, &mut memo); - if !changed.is_empty() { + // Formulas that use `A1#` read only the anchor cell. The anchor + // counts as changed when its spill range or spilled cells + // changed. A new anchor value alone needs no notice: a reader + // evaluates the anchor formula on demand when it reads it. + let installed_range = self.sheets[output_sheet].installed_spill(local); + if installed_range != vacated_range || !changed.is_empty() { + let mut cells = changed.clone(); + cells.push(local); spill_changes.push(SpillChange { sheet: output_sheet, sequence, - cells: changed.clone(), + cells, }); + } + if !changed.is_empty() { self.sheets[output_sheet].dirty_cells.extend(changed); changed_sheets.insert(output_sheet); seeded_sheets.insert(output_sheet); @@ -906,7 +914,12 @@ impl CellStore { Some(entry) => match &entry.ast { Some(ast) => { let _origin = FormulaOriginGuard::push(local); - self.eval_ast(ast, sheet, affected, memo, visiting, depth + 1) + if entry.has_spill_refs { + let resolved = self.resolve_spill_refs(ast, sheet); + self.eval_ast(&resolved, sheet, affected, memo, visiting, depth + 1) + } else { + self.eval_ast(ast, sheet, affected, memo, visiting, depth + 1) + } } None => Value::Error(entry.error.unwrap_or(FormulaError::Value)), }, diff --git a/packages/wasm/src/sheet.rs b/packages/wasm/src/sheet.rs index 6e5ac59b..45fefb6a 100644 --- a/packages/wasm/src/sheet.rs +++ b/packages/wasm/src/sheet.rs @@ -1212,6 +1212,15 @@ impl SheetData { self.spill_owners.get(&cell).copied() } + /// The range that `anchor` spills into now. `spill_ranges` also keeps + /// blocked attempts; only an installed spill owns its anchor cell. + pub(crate) fn installed_spill(&self, anchor: CellKey) -> Option { + if self.spill_owner(anchor) != Some(anchor) { + return None; + } + self.spill_ranges.get(&anchor).copied() + } + /// Remove only cells still owned by `anchor`; a user-written obstruction that /// detached from the spill remains untouched. pub(crate) fn compact_spill_metadata(&mut self) { diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index 37555c6d..f74bba73 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -3561,3 +3561,109 @@ fn spills_that_read_each_other_end_as_a_cycle() { assert_eq!(shown(0).as_deref(), Some("#CYCLE!")); assert_eq!(shown(2).as_deref(), Some("#CYCLE!")); } + +#[test] +fn spill_references_follow_the_current_spill_of_their_anchor() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(12, 12); + store.set_number(sheet, 0, 9, 3.0, 0); + store.set_formula(sheet, 0, 0, "=SEQUENCE(J1)", 0); + store.set_formula(sheet, 0, 1, "=A1#", 0); + store.set_formula(sheet, 0, 2, "=SUM(A1#)", 0); + store.set_formula(sheet, 0, 3, "=ROWS(A1#)", 0); + store.set_formula(sheet, 0, 4, "=INDEX(A1#,2)", 0); + store.set_formula(sheet, 0, 5, "=COUNTIF(A1#,\">1\")", 0); + store.set_formula(sheet, 0, 6, "=SUM(J1#)", 0); + store.recompute(sheet); + let column_b = |store: &CellStore| -> Vec { + (0..6).map(|row| store.get_cell(sheet, row, 1).num()).collect() + }; + assert_eq!(column_b(&store), [1.0, 2.0, 3.0, 0.0, 0.0, 0.0]); + let results = |store: &CellStore| -> Vec { + (2..=5).map(|col| store.get_cell(sheet, 0, col).num()).collect() + }; + assert_eq!(results(&store), [6.0, 3.0, 2.0, 2.0]); + // J1 holds a value, not a spill. + assert_eq!(string(&store, sheet, 0, 6).as_deref(), Some("#REF!")); + + store.set_number(sheet, 0, 9, 5.0, 0); + store.recompute(sheet); + assert_eq!(column_b(&store), [1.0, 2.0, 3.0, 4.0, 5.0, 0.0]); + assert_eq!(results(&store), [15.0, 5.0, 2.0, 4.0]); +} + +#[test] +fn spill_references_shift_with_their_anchor_and_break_when_it_is_deleted() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(6, 12); + store.set_formula(sheet, 2, 0, "=SEQUENCE(2)", 0); + store.set_formula(sheet, 0, 2, "=SUM(A3#)", 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 0, 2), 3.0); + + store.remove_rows(sheet, 1, 1); + store.recompute(sheet); + assert_eq!(store.formula_source(sheet, 0, 2).as_deref(), Some("=SUM(A2#)")); + assert_close(number(&store, sheet, 0, 2), 3.0); + + store.remove_rows(sheet, 1, 1); + store.recompute(sheet); + assert_eq!(store.formula_source(sheet, 0, 2).as_deref(), Some("=SUM(#REF!)")); + assert_eq!(string(&store, sheet, 0, 2).as_deref(), Some("#REF!")); +} + +#[test] +fn a_spill_that_reads_a_later_anchor_follows_its_resize() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(10, 10); + store.set_number(sheet, 0, 9, 2.0, 0); + store.set_formula(sheet, 0, 1, "=SORT(A3#,1,-1)", 0); + store.set_formula(sheet, 2, 0, "=SEQUENCE(J1)", 0); + store.recompute(sheet); + let column_b = |store: &CellStore| -> Vec { + (0..5).map(|row| store.get_cell(sheet, row, 1).num()).collect() + }; + assert_eq!(column_b(&store), [2.0, 1.0, 0.0, 0.0, 0.0]); + + store.set_number(sheet, 0, 9, 4.0, 0); + store.recompute(sheet); + assert_eq!(column_b(&store), [4.0, 3.0, 2.0, 1.0, 0.0]); +} + +#[test] +fn a_spill_that_reads_a_later_one_cell_spill_sees_it() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(10, 10); + store.set_number(sheet, 0, 9, 1.0, 0); + store.set_formula(sheet, 0, 1, "=SORT(A3#,1,-1)", 0); + store.set_formula(sheet, 2, 0, "=SEQUENCE(J1)", 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 0, 1), 1.0); + + // Growing from one cell to two and shrinking back both reach the reader. + store.set_number(sheet, 0, 9, 2.0, 0); + store.recompute(sheet); + assert_eq!( + (number(&store, sheet, 0, 1), number(&store, sheet, 1, 1)), + (2.0, 1.0) + ); + store.set_number(sheet, 0, 9, 1.0, 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 0, 1), 1.0); + assert_eq!(store.get_cell(sheet, 1, 1).kind(), KIND_EMPTY); +} + +#[test] +fn a_spill_that_reads_a_later_one_cell_spill_sees_its_new_value() { + let mut store = CellStore::new(); + let sheet = store.add_sheet(10, 10); + store.set_number(sheet, 0, 9, 1.0, 0); + store.set_formula(sheet, 0, 1, "=SORT(A3#)", 0); + store.set_formula(sheet, 2, 0, "=SEQUENCE(1,1,J1)", 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 0, 1), 1.0); + + store.set_number(sheet, 0, 9, 2.0, 0); + store.recompute(sheet); + assert_close(number(&store, sheet, 0, 1), 2.0); +} diff --git a/packages/wasm/src/types.rs b/packages/wasm/src/types.rs index 7f2446df..2252a5af 100644 --- a/packages/wasm/src/types.rs +++ b/packages/wasm/src/types.rs @@ -285,6 +285,19 @@ fn ast_contains_let(ast: &Ast) -> bool { } } +fn ast_contains_spill_ref(ast: &Ast) -> bool { + match ast { + Ast::Func(Func::AnchorArray, _) => true, + Ast::Func(_, args) | Ast::UnknownFunc(_, args) => args.iter().any(ast_contains_spill_ref), + Ast::Bin(_, left, right) | Ast::Cmp(_, left, right) => { + ast_contains_spill_ref(left) || ast_contains_spill_ref(right) + } + Ast::Neg(inner) | Ast::Pos(inner) | Ast::Percent(inner) => ast_contains_spill_ref(inner), + Ast::LetSlot { expression, .. } => ast_contains_spill_ref(expression), + _ => false, + } +} + fn formula_metadata(ast: &Ast, formula_sheet: u32) -> (ReadSet, bool) { if !ast_contains_let(ast) { return (ReadSet::from_ast(ast, formula_sheet), ast_is_volatile(ast)); @@ -314,6 +327,9 @@ pub(crate) struct FormulaEntry { pub(crate) error: Option, pub(crate) value_kind: FormulaValueKind, pub(crate) volatile: bool, + /// The formula contains `A1#`. Each evaluation replaces those with the + /// current spill ranges; other formulas skip that step. + pub(crate) has_spill_refs: bool, pub(crate) source_kind: PersistedSourceKind, } @@ -326,6 +342,7 @@ impl FormulaEntry { pub(crate) fn parsed_source(ast: Ast, sheet: u32, source: String) -> Self { let (reads, volatile) = formula_metadata(&ast, sheet); Self { + has_spill_refs: ast_contains_spill_ref(&ast), ast: Some(ast), source: source.to_string(), reads, @@ -354,6 +371,7 @@ impl FormulaEntry { error: None, value_kind: FormulaValueKind::Blank, volatile: false, + has_spill_refs: false, source_kind: PersistedSourceKind::Reference, } } @@ -397,6 +415,7 @@ impl FormulaEntry { error: Some(error), value_kind: FormulaValueKind::Number, volatile: false, + has_spill_refs: false, source_kind: PersistedSourceKind::Formula, } } diff --git a/packages/xlsx/src/formula.ts b/packages/xlsx/src/formula.ts index 7d0e2810..6a2b123a 100644 --- a/packages/xlsx/src/formula.ts +++ b/packages/xlsx/src/formula.ts @@ -26,3 +26,37 @@ export function formulaContainsExternalReference(formula: string): boolean { } return EXTERNAL_BOOK_TOKEN.test(executable) || EXTERNAL_DATA_FUNCTION.test(executable); } + +/** A cell reference with an optional sheet qualifier, such as `A1` or `'My Sheet'!$B$2`. */ +const CELL_REFERENCE = String.raw`(?:(?:'(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!)?\$?[A-Za-z]{1,3}\$?[0-9]+`; +/** Text in double quotes, which the conversions must leave unchanged. */ +const QUOTED_TEXT = `"(?:[^"]|"")*"`; +const XLSX_SPILL_REFERENCE = new RegExp( + String.raw`${QUOTED_TEXT}|_xlfn\.ANCHORARRAY\(\s*(${CELL_REFERENCE})\s*\)`, + "gi", +); +const SPILL_REFERENCE = new RegExp(`${QUOTED_TEXT}|(${CELL_REFERENCE})#`, "g"); + +/** + * Excel stores the spill reference `A1#` as `_xlfn.ANCHORARRAY(A1)`. Convert + * that form to `A1#` when a workbook is read. + */ +export function spillReferencesFromXlsx(formula: string): string { + return formula.replace(XLSX_SPILL_REFERENCE, (match, reference?: string) => + reference === undefined ? match : `${reference}#`, + ); +} + +/** Convert `A1#` back to `_xlfn.ANCHORARRAY(A1)` when a workbook is written. */ +export function spillReferencesToXlsx(formula: string): string { + return formula.replace( + SPILL_REFERENCE, + (match, reference: string | undefined, offset: number, full: string) => { + if (reference === undefined) return match; + // A reference glued to a name, such as `LOG10#`, is not a cell reference. + const previous = offset > 0 ? full.charAt(offset - 1) : ""; + if (/[A-Za-z0-9_.]/.test(previous)) return match; + return `_xlfn.ANCHORARRAY(${reference})`; + }, + ); +} diff --git a/packages/xlsx/src/reader.ts b/packages/xlsx/src/reader.ts index 4f3ec087..826f313f 100644 --- a/packages/xlsx/src/reader.ts +++ b/packages/xlsx/src/reader.ts @@ -30,7 +30,7 @@ import { validateSheetName, validateWorkbookTableName, } from "@sheetwrite/core"; -import { formulaContainsExternalReference } from "./formula.js"; +import { formulaContainsExternalReference, spillReferencesFromXlsx } from "./formula.js"; import { OpcPackage, type OpcRelationship, relationshipTypeMatches } from "./opc.js"; import { assertResource, @@ -1187,7 +1187,7 @@ function parseSheet( let value: CellValue | null; let sharedFormula: string | undefined; if (formulaElement) { - const formulaText = decodeXstring(formulaElement.text); + const formulaText = spillReferencesFromXlsx(decodeXstring(formulaElement.text)); const formulaType = xmlAttribute(formulaElement, "t") ?? "normal"; if (formulaType === "shared") { sharedFormula = xmlAttribute(formulaElement, "si"); diff --git a/packages/xlsx/src/writer.ts b/packages/xlsx/src/writer.ts index 853bf870..361f5568 100644 --- a/packages/xlsx/src/writer.ts +++ b/packages/xlsx/src/writer.ts @@ -8,7 +8,7 @@ import type { WorkbookSnapshot, } from "@sheetwrite/core"; import { colToA1, sheetNameKey, validateSheetName } from "@sheetwrite/core"; -import { formulaContainsExternalReference } from "./formula.js"; +import { formulaContainsExternalReference, spillReferencesToXlsx } from "./formula.js"; import { type ContentTypeOverride, contentTypesXml, relationshipsXml } from "./opc.js"; import { assertResource, @@ -338,7 +338,7 @@ function cellXml( }); return `${escapeXml(encodeXstring(`=${formula}`))}`; } - return `${escapeXml(encodeXstring(formula))}`; + return `${escapeXml(encodeXstring(spillReferencesToXlsx(formula)))}`; } if (value.value === null) return ``; if (typeof value.value === "string") { diff --git a/packages/xlsx/test/formula-source-preservation.test.ts b/packages/xlsx/test/formula-source-preservation.test.ts index bf01fdcb..59de967c 100644 --- a/packages/xlsx/test/formula-source-preservation.test.ts +++ b/packages/xlsx/test/formula-source-preservation.test.ts @@ -5,6 +5,7 @@ import { toXlsxWorkbook, type WorkbookSnapshot, } from "@sheetwrite/core"; +import { strFromU8, unzipSync } from "fflate"; import { registerXlsxBackends } from "../src/index.js"; interface FormulaContractInventory { @@ -26,7 +27,7 @@ beforeAll(async () => { registerXlsxBackends(); }); -function formulaWorkbook(): WorkbookSnapshot { +function formulaWorkbook(sources: readonly string[] = FORMULA_SOURCES): WorkbookSnapshot { return { schemaVersion: 1, documentId: "formula-source-preservation", @@ -37,15 +38,15 @@ function formulaWorkbook(): WorkbookSnapshot { id: "formula", name: "Formula Source", order: 0, - rowCount: FORMULA_SOURCES.length, + rowCount: sources.length, columns: [{ key: "formula", header: "Formula", width: 180, type: "number" }], cells: [ { startRow: 0, startCol: 0, - rowCount: FORMULA_SOURCES.length, + rowCount: sources.length, colCount: 1, - cells: FORMULA_SOURCES.map((src, rowOffset) => ({ + cells: sources.map((src, rowOffset) => ({ rowOffset, colOffset: 0, value: { kind: "formula" as const, src }, @@ -80,4 +81,15 @@ describe("XLSX formula source preservation", () => { }); expect(formulaSources(decoded)).toEqual(FORMULA_SOURCES); }); + + it("writes spill references in Excel's ANCHORARRAY form and reads them back", async () => { + const sources = ["=SUM(A1#)", "='My Sheet'!$B$2#", '=LEN("A1#")&C3#']; + const encoded = await toXlsxWorkbook(formulaWorkbook(sources)); + const worksheet = strFromU8(unzipSync(encoded)["xl/worksheets/sheet1.xml"]!); + expect(worksheet).toContain("SUM(_xlfn.ANCHORARRAY(A1))"); + expect(worksheet).toContain("_xlfn.ANCHORARRAY('My Sheet'!$B$2)"); + // The quoted text is not a reference and keeps its `#`. + expect(worksheet).toContain("A1#")&_xlfn.ANCHORARRAY(C3)"); + expect(formulaSources(await fromXlsxWorkbook(encoded))).toEqual(sources); + }); }); From a40d5f100d4665d8b2113c97aaa1eb1b4f87c85d Mon Sep 17 00:00:00 2001 From: chh-ay Date: Mon, 5 Oct 2026 14:50:17 +0700 Subject: [PATCH 3/3] fix(wasm): keep #REF! formulas off the spill path --- packages/wasm/src/eval/array.rs | 2 -- packages/wasm/src/eval/mod.rs | 2 ++ packages/wasm/src/tests.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/wasm/src/eval/array.rs b/packages/wasm/src/eval/array.rs index df4d0595..c69014ba 100644 --- a/packages/wasm/src/eval/array.rs +++ b/packages/wasm/src/eval/array.rs @@ -168,8 +168,6 @@ impl CellStore { let range = self.spill_reference(args, formula_sheet); Some(self.matrix_shape(&range, formula_sheet).map(|(_, _, cells)| cells)) } - // A deleted reference, or `A1#` when A1 holds no spill. - Ast::InvalidRef => Some(Err(FormulaError::Ref)), #[cfg(feature = "analysis")] Ast::BoundMatrix { rows, cols, .. } => Some(EvalMatrix::validate_shape(*rows, *cols, 1, 0)), #[cfg(feature = "analysis")] diff --git a/packages/wasm/src/eval/mod.rs b/packages/wasm/src/eval/mod.rs index 8c481a45..ae6a3adc 100644 --- a/packages/wasm/src/eval/mod.rs +++ b/packages/wasm/src/eval/mod.rs @@ -559,6 +559,8 @@ impl CellStore { None => Err(FormulaError::Num), }, Some(Err(error)) => Err(error), + // A top-level `A1#` whose anchor holds no spill. + None if matches!(ast, Ast::InvalidRef) => Err(FormulaError::Ref), None => Err(FormulaError::Value), }; let changed = self.install_spill_result(key, evaluated, &mut memo); diff --git a/packages/wasm/src/tests.rs b/packages/wasm/src/tests.rs index f74bba73..863df0f6 100644 --- a/packages/wasm/src/tests.rs +++ b/packages/wasm/src/tests.rs @@ -3574,6 +3574,7 @@ fn spill_references_follow_the_current_spill_of_their_anchor() { store.set_formula(sheet, 0, 4, "=INDEX(A1#,2)", 0); store.set_formula(sheet, 0, 5, "=COUNTIF(A1#,\">1\")", 0); store.set_formula(sheet, 0, 6, "=SUM(J1#)", 0); + store.set_formula(sheet, 0, 7, "=J1#", 0); store.recompute(sheet); let column_b = |store: &CellStore| -> Vec { (0..6).map(|row| store.get_cell(sheet, row, 1).num()).collect() @@ -3585,6 +3586,7 @@ fn spill_references_follow_the_current_spill_of_their_anchor() { assert_eq!(results(&store), [6.0, 3.0, 2.0, 2.0]); // J1 holds a value, not a spill. assert_eq!(string(&store, sheet, 0, 6).as_deref(), Some("#REF!")); + assert_eq!(string(&store, sheet, 0, 7).as_deref(), Some("#REF!")); store.set_number(sheet, 0, 9, 5.0, 0); store.recompute(sheet);