collective

Browse / github.com/storytold/gridcraft / patch

Calc: legacy implicit intersection for formulas imported without dynamic arrays

Calc: legacy implicit intersection for formulas imported without dynamic arrays Formulas read from files without dynamic-array metadata (cm), and every .xls formula, are marked implicit and calculated with implicit intersection, so legacy workbooks show their values instead of #SPILL./deploy/publish-floodiis.sh The formula text and saved file stay plain; typing a formula still creates a dynamic array. Verified against a real legacy workbook (4179/4179 formulas match Excel's cached values) and covered by calc, XLSX/BIFF and engine tests. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbi

Status
open
Score
0.00
Adopters
0 other operators
Author
robalexclark (imported from pull request #2677, open)
Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86
Size
+558 −22 in 25 files
Review
pending
Shared
2026-10-28
Id
5fa03f48083ffcceb09322f3

How it went

No other operator has reported using it yet.

Diff

raw · crates/calc/src/eval.rs crates/calc/src/implicit.rs crates/calc/src/lib.rs crates/calc/src/recalc.rs crates/calc/src/tests.rs crates/engine/src/cmd/data.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/formulas.rs crates/engine/src/cmd/insert.rs crates/engine/src/cmd/sheet.rs crates/engine/src/fill.rs crates/engine/src/tables.rs crates/engine/tests/it/legacyimplicit.rs crates/engine/tests/it/main.rs crates/model/src/cell.rs crates/model/src/workbook.rs crates/xlsx/src/array_formula.rs crates/xlsx/src/sheet_read.rs crates/xlsx/src/sheet_write.rs crates/xlsx/src/tests/fixtures.rs crates/xlsx/src/tests/xls_tests.rs crates/xlsx/src/xls/mod.rs docs/decisions.md docs/file-format-parity.md docs/function-parity.md

diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs--- a/crates/calc/src/eval.rs+++ b/crates/calc/src/eval.rs@@ -156,8 +156,12 @@ impl Ctx for FnCtx<'_, '_> {     fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {         let area = self.criteria?;         let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;-        let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;+        let formula = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?;+        let expr = formula.expr_arc()?;+        let implicit = formula.implicit && formula.array.is_none();         let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);+        // A legacy formula intersects its references, as it does in its own cell.+        let shifted = if implicit { crate::implicit::scalarize(&shifted) } else { shifted };         let env = (0..db.cols)             .filter_map(|c| match db.get(0, c) {                 Some(Value::Text(t)) if !t.trim().is_empty() => {diff --git a/crates/calc/src/implicit.rs b/crates/calc/src/implicit.rsnew file mode 100644--- /dev/null+++ b/crates/calc/src/implicit.rs@@ -0,0 +1,256 @@+//! Legacy implicit intersection.+//!+//! A formula read from a file that predates dynamic arrays (no `cm` cell metadata, not a legacy+//! CSE array) is calculated by Excel with *implicit intersection*: where the formula expects one+//! value, a multi-cell reference is reduced to the cell on the formula's row or column, and an+//! array value to its top-left element. Excel 365 applies the same rule, and shows the+//! intersection as `@`.+//!+//! [`scalarize`] rewrites the parsed formula the same way: every reference in a scalar position+//! is wrapped in `@`, which the evaluator already understands. The calculation engine uses it+//! for formulas marked [`Formula::implicit`](gridcraft_model::Formula::implicit); the formula+//! text, its references and the saved file stay unchanged.+//!+//! Rules, from Microsoft's *Implicit intersection operator: @*:+//! - a reference in a scalar position is intersected: `=A1:A10` is `=@A1:A10`, and so is+//!   `=INDEX(A1:A10,B1)`, a call that may return a range;+//! - a reference that is the whole argument of a function taking ranges or arrays is not+//!   intersected: `SUM(A1:A10)` is unchanged, and so is `SUM(INDEX(A1:A10,B1))`;+//! - an expression inside such an argument is a scalar position: `SUM(A1:A3*B1:B3)` intersects+//!   both references, as `@A1:A3*@B1:B3` (older Excel could not mix the two modes);+//! - `SUMPRODUCT`, `MMULT`, `TRANSPOSE` and the other functions that always took arrays keep+//!   their arguments in array mode, and `IF`/`IFERROR`/`CHOOSE`/`SWITCH` pass the position on,+//!   so a legacy formula can still contain an array computation there;+//! - an explicit `@` (read from `_xlfn.SINGLE`) is left as it is.++use gridcraft_formula::{BinOp, Expr, UnOp};++/// Functions that always evaluate their arguments as arrays in a legacy formula (they never+/// needed Ctrl+Shift+Enter).+const ARRAY_FUNCTIONS: &[&str] = &["SUMPRODUCT", "MMULT", "MINVERSE", "MDETERM", "TRANSPOSE", "FREQUENCY", "LINEST", "LOGEST", "TREND", "GROWTH"];++/// Functions that return one of their arguments: they keep the position they are used in.+const PASS_THROUGH: &[&str] = &["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"];++/// Functions that may return a range or an array, so a scalar position intersects their result.+/// Any other function returns a single value (a scalar function, or one that aggregates).+const RANGE_FUNCTIONS: &[&str] = &["INDEX", "OFFSET", "INDIRECT", "CHOOSE"];++/// Deepest nesting the rewrite follows; a formula may nest about 4,000 levels (the 8,192+/// character limit), so this only stops hostile input, which is then left as it is.+const MAX_DEPTH: usize = 512;++/// The formula rewritten for legacy implicit intersection: the same expression with `@` around+/// every reference used where Excel expects one value.+pub fn scalarize(e: &Expr) -> Expr {+    scalarize_in(e, Position::Scalar, 0)+}++#[derive(Clone, Copy, PartialEq, Eq)]+enum Position {+    /// One value is expected: references are intersected.+    Scalar,+    /// An array or range is expected: references keep their shape.+    Array,+}++fn scalarize_in(e: &Expr, at: Position, depth: usize) -> Expr {+    if depth >= MAX_DEPTH {+        return e.clone();+    }+    let d = depth + 1;+    match e {+        Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_) => match at {+            Position::Array => e.clone(),+            Position::Scalar => Expr::Unary(UnOp::At, Box::new(e.clone())),+        },+        Expr::Paren(x) => Expr::Paren(Box::new(scalarize_in(x, at, d))),+        // An array constant's items are values, not references in the formula's position.+        Expr::Array(rows) => Expr::Array(rows.iter().map(|r| r.iter().map(|x| scalarize_in(x, Position::Array, d)).collect()).collect()),+        // An explicit `@` already does the intersection: its operand keeps its shape.+        Expr::Unary(UnOp::At, x) => Expr::Unary(UnOp::At, Box::new(scalarize_in(x, Position::Array, d))),+        Expr::Unary(op, x) => {+            // `A1#` and trim references are reference operators: their operand stays a range.+            let inner = if matches!(op, UnOp::Spill | UnOp::Trim(_)) { Position::Array } else { at };+            Expr::Unary(*op, Box::new(scalarize_in(x, inner, d)))+        }+        Expr::Binary(op, a, b) => binary(*op, a, b, at, d),+        Expr::Call(name, args) => call(name, args, at, d),+        Expr::Invoke(callee, args) => Expr::Invoke(Box::new(scalarize_in(callee, at, d)), args.iter().map(|a| scalarize_in(a, at, d)).collect()),+        other => other.clone(),+    }+}++fn binary(op: BinOp, a: &Expr, b: &Expr, at: Position, depth: usize) -> Expr {+    if matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect) {+        // A reference: both ends stay references, and the result is intersected if one value+        // is expected (`=A1:B3`, `=A1 A2:B3`).+        let node = Expr::Binary(op, Box::new(scalarize_in(a, Position::Array, depth)), Box::new(scalarize_in(b, Position::Array, depth)));+        return match at {+            Position::Array => node,+            Position::Scalar => Expr::Unary(UnOp::At, Box::new(node)),+        };+    }+    // A flat chain nests on its left, so follow that spine in a loop rather than one call per+    // operator (a formula as long as Excel allows is about 4,000 levels deep).+    let mut spine = vec![(op, b)];+    let mut left = a;+    while let Expr::Binary(op, l, r) = left {+        if matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect) {+            break;+        }+        spine.push((*op, r.as_ref()));+        left = l.as_ref();+    }+    let mut acc = scalarize_in(left, at, depth);+    while let Some((op, right)) = spine.pop() {+        acc = Expr::Binary(op, Box::new(acc), Box::new(scalarize_in(right, at, depth)));+    }+    acc+}++fn call(name: &str, args: &[Expr], at: Position, depth: usize) -> Expr {+    let rewritten: Vec<Expr> = args.iter().enumerate().map(|(i, a)| scalarize_in(a, argument_position(name, i, a, at), depth)).collect();+    let node = Expr::Call(name.to_string(), rewritten);+    // Where one value is expected, the result is intersected: a call that returns a range+    // (`INDEX`, `OFFSET`, `INDIRECT`, a user function) gives the cell on the formula's row or+    // column, and one that returns an array (`ROW(A1:A3)`) its top-left value. A call that+    // returns one value is unaffected by `@`.+    match at {+        Position::Scalar => Expr::Unary(UnOp::At, Box::new(node)),+        Position::Array => node,+    }+}++/// The position argument `i` of `name` is in: an array where the function takes ranges or+/// arrays as values, one value everywhere else.+fn argument_position(name: &str, i: usize, arg: &Expr, at: Position) -> Position {+    if ARRAY_FUNCTIONS.iter().any(|f| f.eq_ignore_ascii_case(name)) {+        return Position::Array;+    }+    if PASS_THROUGH.iter().any(|f| f.eq_ignore_ascii_case(name)) {+        return at;+    }+    match gridcraft_functions::lookup(name) {+        // A value parameter rejects a multi-cell reference with #VALUE! instead of intersecting+        // it, in legacy formulas too: its references keep their shape so the function can.+        Some(spec) if spec.is_value_arg(i) => Position::Array,+        // A whole reference (or array constant) is the function's argument: `SUM(A1:A3)` keeps+        // its three cells. Any other expression is calculated as one value: `SUM(A1:A3*B1:B3)`.+        Some(spec) if !spec.is_scalar_arg(i) => {+            if whole_argument(arg) {+                Position::Array+            } else {+                Position::Scalar+            }+        }+        // A single-value parameter is one value, which an old formula intersects.+        Some(_) => Position::Scalar,+        // A function GridCraft does not know: its arguments may be anything, so leave them.+        None => Position::Array,+    }+}++/// Whether `e` is a reference or an array constant in itself, rather than a calculation over+/// one: a range argument used whole by a function that takes ranges.+fn whole_argument(e: &Expr) -> bool {+    match e {+        Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_) | Expr::Array(_) => true,+        Expr::Paren(x) => whole_argument(x),+        Expr::Unary(UnOp::Spill, _) => true,+        Expr::Binary(op, a, b) => matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect) && whole_argument(a) && whole_argument(b),+        Expr::Call(name, _) => RANGE_FUNCTIONS.iter().any(|f| f.eq_ignore_ascii_case(name)),+        _ => false,+    }+}++#[cfg(test)]+mod tests {+    use gridcraft_formula::print;++    use super::scalarize;++    /// The formula after the rewrite, as a person would type it.+    fn rewritten(text: &str) -> String {+        let e = gridcraft_formula::parse(text).expect("test formula parses");+        print(&scalarize(&e))+    }++    #[test]+    fn references_used_as_one_value_are_intersected() {+        assert_eq!(rewritten("A1:A10"), "@A1:A10");+        assert_eq!(rewritten("A1:A10*2"), "@A1:A10*2");+        assert_eq!(rewritten("A1:A10&B1"), "@A1:A10&@B1");+        assert_eq!(rewritten("IF(Pay_Num<>\"\",Beg_Bal*Rate/12,\"\")"), "@IF(@Pay_Num<>\"\",@Beg_Bal*@Rate/12,\"\")");+        assert_eq!(rewritten("SQRT(A1:A3)"), "@SQRT(@A1:A3)");+        assert_eq!(rewritten("Sheet2!A1:B3"), "@Sheet2!A1:B3");+        assert_eq!(rewritten("Data2[[#This Row],[Input]]*2"), "@Data2[@Input]*2");+    }++    #[test]+    fn a_reference_used_whole_is_not_intersected() {+        assert_eq!(rewritten("SUM(A1:A10)"), "@SUM(A1:A10)");+        assert_eq!(rewritten("AVERAGE((A1:A10))"), "@AVERAGE((A1:A10))");+        assert_eq!(rewritten("SUM(Sheet2!A1:B3)"), "@SUM(Sheet2!A1:B3)");+        assert_eq!(rewritten("SUM(Pay_Num)"), "@SUM(Pay_Num)");+        assert_eq!(rewritten("SUM({1,2,3})"), "@SUM({1,2,3})");+        assert_eq!(rewritten("SUM(INDEX(A1:B3,0,1))"), "@SUM(INDEX(A1:B3,0,1))");+        assert_eq!(rewritten("COUNTIF(A1:A10,\">0\")"), "@COUNTIF(A1:A10,\">0\")");+    }++    #[test]+    fn an_expression_in_a_range_argument_is_one_value() {+        assert_eq!(rewritten("SUM(A1:A3*B1:B3)"), "@SUM(@A1:A3*@B1:B3)");+        assert_eq!(rewritten("SUM(A1:A3+0)"), "@SUM(@A1:A3+0)");+    }++    #[test]+    fn array_functions_and_pass_through_keep_array_positions() {+        assert_eq!(rewritten("SUMPRODUCT(A1:A3*B1:B3)"), "@SUMPRODUCT(A1:A3*B1:B3)");+        assert_eq!(rewritten("SUMPRODUCT(--(A1:A3=\"x\"))"), "@SUMPRODUCT(--(A1:A3=\"x\"))");+        assert_eq!(rewritten("MMULT(A1:B3,D1:E2)"), "@MMULT(A1:B3,D1:E2)");+        assert_eq!(rewritten("IF(A1:A3>0,B1:B3,0)"), "@IF(@A1:A3>0,@B1:B3,0)");+        assert_eq!(rewritten("SUMPRODUCT(IF(A1:A3>0,B1:B3,0))"), "@SUMPRODUCT(IF(A1:A3>0,B1:B3,0))");+        assert_eq!(rewritten("IFERROR(ROW(A1:A3),0)"), "@IFERROR(@ROW(A1:A3),0)");+    }++    #[test]+    fn calls_that_return_a_range_are_intersected() {+        assert_eq!(rewritten("INDEX(A1:A10,B1)"), "@INDEX(A1:A10,@B1)");+        assert_eq!(rewritten("INDEX(A1:A10,B1)*2"), "@INDEX(A1:A10,@B1)*2");+        assert_eq!(rewritten("OFFSET(A1,0,0,3,1)"), "@OFFSET(A1,0,0,3,1)");+        assert_eq!(rewritten("MYUDF(A1)"), "@MYUDF(A1)");+    }++    #[test]+    fn value_parameters_keep_their_reference_so_it_is_rejected() {+        // `EOMONTH` and its peers give #VALUE! for a multi-cell reference in a legacy formula+        // too, instead of intersecting it (`functions::lift`), so the reference stays whole.+        assert_eq!(rewritten("EOMONTH(A1:A3,0)"), "@EOMONTH(A1:A3,0)");+        assert_eq!(rewritten("EOMONTH(INDEX(A1:A3,0,1),0)"), "@EOMONTH(INDEX(A1:A3,0,1),0)");+        assert_eq!(rewritten("ISEVEN(B1:B3)"), "@ISEVEN(B1:B3)");+        assert_eq!(rewritten("SUM(EOMONTH(A1:A3,0))"), "@SUM(@EOMONTH(A1:A3,0))");+    }++    #[test]+    fn scalar_functions_are_not_intersected() {+        assert_eq!(rewritten("SUM(A1:A3)+1"), "@SUM(A1:A3)+1");+        assert_eq!(rewritten("ROUND(A1*2,0)"), "@ROUND(@A1*2,0)");+    }++    #[test]+    fn an_explicit_intersection_is_kept() {+        assert_eq!(rewritten("@A1:A3*2"), "@A1:A3*2");+        assert_eq!(rewritten("SUM(@A1:A3)"), "@SUM(@A1:A3)");+        assert_eq!(rewritten("A1#"), "A1#");+    }++    #[test]+    fn a_long_chain_does_not_recurse_per_operator() {+        let text: String = (1..=1_000).map(|i| format!("+A{i}")).collect();+        let e = gridcraft_formula::parse(&format!("1{text}")).expect("parses");+        let out = scalarize(&e);+        // Every reference is intersected, and the chain is still one flat formula.+        assert_eq!(gridcraft_formula::print(&out), format!("1{}", (1..=1_000).map(|i| format!("+@A{i}")).collect::<String>()));+    }+}diff --git a/crates/calc/src/lib.rs b/crates/calc/src/lib.rs--- a/crates/calc/src/lib.rs+++ b/crates/calc/src/lib.rs@@ -4,6 +4,7 @@ #![forbid(unsafe_code)]  pub mod eval;+pub mod implicit; pub mod recalc; mod trimrange; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs--- a/crates/calc/src/recalc.rs+++ b/crates/calc/src/recalc.rs@@ -1216,9 +1216,12 @@ impl Calc {             dirty.sort_by_key(|(s, c)| (*s, c.row, c.col));             let mut exprs: HashMap<Key, std::sync::Arc<Expr>> = HashMap::with_capacity_and_hasher(dirty.len(), Default::default());             for k in &dirty {-                if let Some(e) = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.expr_arc()) {-                    exprs.insert(*k, e);-                }+                let Some(f) = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()) else { continue };+                let Some(e) = f.expr_arc() else { continue };+                // A formula from a file that predates dynamic arrays is calculated with implicit+                // intersection, as Excel does: its references in scalar positions become `@`.+                let e = if f.implicit && f.array.is_none() { std::sync::Arc::new(crate::implicit::scalarize(&e)) } else { e };+                exprs.insert(*k, e);             }             let now = self.now();             let mut rng = self.rng;diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs--- a/crates/calc/src/tests.rs+++ b/crates/calc/src/tests.rs@@ -25,6 +25,14 @@ impl T {         self.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);         self.calc.cells_changed(&mut self.wb, &[(0, c(at))]);     }+    /// A formula read from a file without dynamic-array metadata: Excel calculates it with+    /// implicit intersection.+    fn set_legacy(&mut self, at: &str, input: &str) {+        let mut f = Formula::new(input);+        f.implicit = true;+        self.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(f));+        self.calc.cells_changed(&mut self.wb, &[(0, c(at))]);+    }     fn get(&self, at: &str) -> Value {         self.wb.sheet(0).unwrap().value(c(at))     }@@ -2274,3 +2282,35 @@ fn loan_schedule_residue_keeps_excels_sign() {     }     assert_eq!(t.get("P7"), Value::Text("".into())); }++/// A formula imported from a file that predates dynamic arrays is calculated with implicit+/// intersection: a reference used as one value gives the cell on the formula's row, not a spill.+#[test]+fn legacy_formulas_intersect_instead_of_spilling() {+    let mut t = T::new();+    for (at, n) in [("A1", 1.0), ("A2", 2.0), ("A3", 3.0), ("B1", 10.0), ("B2", 20.0), ("B3", 30.0)] {+        t.set(at, &n.to_string());+    }+    // One reference: the cell on the formula's row (`A2`). Two: each intersects, then multiply.+    t.set_legacy("D2", "=A1:A3");+    t.set_legacy("E2", "=A1:A3*B1:B3");+    assert_eq!((t.get("D2"), t.get("E2")), (Value::Number(2.0), Value::Number(40.0)));+    // Nothing spilled: the cells below and to the right are untouched.+    for at in ["D3", "D4", "E3", "E4"] {+        assert_eq!(t.get(at), Value::Empty, "{at}");+    }+    // A reference used whole by an aggregating function keeps its cells.+    t.set_legacy("F2", "=SUM(A1:A3)");+    t.set_legacy("G2", "=SUM(A1:A3*B1:B3)");+    assert_eq!((t.num("F2"), t.num("G2")), (6.0, 40.0));+    // A function returning a range intersects its result (`INDEX` here picks A2 anyway).+    t.set_legacy("H2", "=INDEX(A1:A3,2)");+    assert_eq!(t.num("H2"), 2.0);+    // A value parameter rejects a multi-cell reference in a legacy formula too, instead of+    // intersecting it.+    t.set_legacy("I2", "=EOMONTH(A1:A3,0)");+    assert_eq!(t.get("I2"), Value::Error(CellError::Value));+    // The same formula typed in GridCraft is a dynamic array and spills.+    t.set("J2", "=A1:A3*B1:B3");+    assert_eq!((t.num("J2"), t.num("J3"), t.num("J4")), (10.0, 40.0, 90.0));+}diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs--- a/crates/engine/src/cmd/data.rs+++ b/crates/engine/src/cmd/data.rs@@ -366,7 +366,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S                     && let Some(e) = f.expr()                 {                     let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64);-                    c.formula = Some(Arc::new(Formula::from_expr(shifted)));+                    let mut nf = Formula::from_expr(shifted);+                    nf.implicit = f.implicit;+                    c.formula = Some(Arc::new(nf));                 }                 if let Some(picture) = sh.cell_pictures.get(&src) {                     pictures.push((dst, picture.clone()));diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -1212,11 +1212,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {                                 let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);                                 moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));                                 moved.dynamic = f.dynamic;+                                moved.implicit = f.implicit;                                 return Some(std::sync::Arc::new(moved));                             }                             let moved =                                 gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);-                            Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))+                            Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, implicit: f.implicit, ..Formula::from_expr(moved) }))                         };                         if link {                             let sheet_prefix = if dest_sheet == clip.sheet && same_doc {@@ -1432,6 +1433,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &                 let mut nf = Formula::from_expr(new);                 nf.array = array;                 nf.dynamic = f.dynamic;+                nf.implicit = f.implicit;                 cell.formula = Some(std::sync::Arc::new(nf));             }         }diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs--- a/crates/engine/src/cmd/formulas.rs+++ b/crates/engine/src/cmd/formulas.rs@@ -437,6 +437,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc             let mut nf = Formula::from_expr(e);             nf.array = f.array;             nf.dynamic = f.dynamic;+            nf.implicit = f.implicit;             if let Some(cell) = sh.cells.get_mut(c) {                 cell.formula = Some(std::sync::Arc::new(nf));             }diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs--- a/crates/engine/src/cmd/insert.rs+++ b/crates/engine/src/cmd/insert.rs@@ -537,7 +537,9 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {                     o => o,                 });                 if let Some(cell) = sh.cells.get_mut(c) {-                    cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));+                    let mut nf = Formula::from_expr(ne);+                    nf.implicit = f.implicit;+                    cell.formula = Some(std::sync::Arc::new(nf));                 }             }         }diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs--- a/crates/engine/src/cmd/sheet.rs+++ b/crates/engine/src/cmd/sheet.rs@@ -785,7 +785,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {                 if let Some(e) = f.expr() {                     let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);                     if let Some(cell) = sh.cells.get_mut(c) {-                        cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));+                        let mut nf = gridcraft_model::Formula::from_expr(ne);+                        nf.implicit = f.implicit;+                        cell.formula = Some(Arc::new(nf));                     }                 }             }@@ -839,7 +841,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {                     if text != f.text                         && let Some(cell) = sh.cells.get_mut(c)                     {-                        cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));+                        let mut nf = gridcraft_model::Formula::from_expr(ne);+                        nf.implicit = f.implicit;+                        cell.formula = Some(Arc::new(nf));                     }                 }             }diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs--- a/crates/engine/src/fill.rs+++ b/crates/engine/src/fill.rs@@ -168,7 +168,9 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F                     if let Some(f) = &cell.formula {                         if let Some(e) = f.expr() {                             let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);-                            cell.formula = Some(Arc::new(Formula::from_expr(shifted)));+                            let mut nf = Formula::from_expr(shifted);+                            nf.implicit = f.implicit;+                            cell.formula = Some(Arc::new(nf));                             cell.value = Value::Empty;                         }                     } else if let Some(v) = match per_cell.get(src_i) {diff --git a/crates/engine/src/tables.rs b/crates/engine/src/tables.rs--- a/crates/engine/src/tables.rs+++ b/crates/engine/src/tables.rs@@ -244,7 +244,9 @@ pub(crate) fn sync_headers(cx: &mut crate::cmd::Ctx) {                 o => o,             });             if hit && let Some(cell) = cx.wb.sheet_mut(i).and_then(|s| s.cells.get_mut(c)) {-                cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));+                let mut nf = Formula::from_expr(ne);+                nf.implicit = f.implicit;+                cell.formula = Some(std::sync::Arc::new(nf));             }         }     }diff --git a/crates/engine/tests/it/legacyimplicit.rs b/crates/engine/tests/it/legacyimplicit.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/legacyimplicit.rs@@ -0,0 +1,100 @@+//! A plain formula from a file that predates dynamic arrays is calculated with implicit+//! intersection; edits that only move or adjust it keep that, so it never starts spilling.++use std::sync::Arc;++use gridcraft_engine::core::{CellRef, Value};+use gridcraft_engine::model::{Cell, Formula, Workbook};+use gridcraft_engine::{DocState, Session, io};+use serde_json::json;++fn at(s: &str) -> CellRef {+    CellRef::parse(s).unwrap()+}++/// A workbook whose D2 formula stands in for one read from a legacy file (the save below takes+/// the plain-formula path the writer uses for those).+fn opened() -> Session {+    let mut wb = Workbook::new();+    let sh = wb.sheet_mut(0).unwrap();+    for (r, (a, b)) in [(1.0, 10.0), (2.0, 20.0), (3.0, 30.0)].into_iter().enumerate() {+        sh.set_value(CellRef::new(r as u32, 0), Value::Number(a));+        sh.set_value(CellRef::new(r as u32, 1), Value::Number(b));+    }+    let mut f = Formula::new("Sheet1!A1:A3*Sheet1!B1:B3");+    f.implicit = true;+    sh.cells.set(at("D2"), Cell::formula(f));+    let bytes = io::save_bytes(&wb, "legacy.xlsx").unwrap();+    let (back, _) = io::open_bytes("legacy.xlsx", &bytes).unwrap();+    let mut s = Session::new();+    s.add_document(DocState::new(back, None, "legacy".into(), gridcraft_engine::Trust::FromFile));+    s+}++fn formula(s: &Session, cell: &str) -> Arc<Formula> {+    s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap()+}++fn value(s: &Session, cell: &str) -> Value {+    s.doc().unwrap().wb.sheet(0).unwrap().value(at(cell))+}++/// The formula as a fresh open of the saved file reads it: the file's metadata decides whether+/// it is a legacy formula or a dynamic array.+fn saved(s: &Session, cell: &str) -> Arc<Formula> {+    let bytes = io::save_bytes(&s.doc().unwrap().wb, "legacy.xlsx").unwrap();+    let (back, _) = io::open_bytes("legacy.xlsx", &bytes).unwrap();+    back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap()+}++#[test]+fn opened_legacy_formula_intersects_and_stays_plain() {+    let s = opened();+    let f = formula(&s, "D2");+    assert!(f.implicit && !f.dynamic && f.array.is_none(), "the file's plain formula is legacy");+    // Row 2: A2 * B2, and nothing spills into the empty cells below.+    assert_eq!(value(&s, "D2"), Value::Number(40.0));+    assert_eq!(value(&s, "D3"), Value::Empty);+    let back = saved(&s, "D2");+    assert!(back.implicit && !back.dynamic, "saving must not turn it into a dynamic array");+}++#[test]+fn inserting_rows_and_moving_keep_implicit_intersection() {+    let mut s = opened();+    s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();+    let f = formula(&s, "D3");+    assert!(f.implicit, "a structural edit only moves the formula");+    assert_eq!(f.text, "Sheet1!A2:A4*Sheet1!B2:B4");+    assert_eq!(value(&s, "D3"), Value::Number(40.0));+    assert!(saved(&s, "D3").implicit);++    s.execute("selection.set", json!({"range": "D3"})).unwrap();+    s.execute("edit.cut", json!({})).unwrap();+    s.execute("selection.set", json!({"range": "F3"})).unwrap();+    s.execute("edit.paste", json!({})).unwrap();+    assert!(formula(&s, "F3").implicit, "a cut keeps the formula");+    assert_eq!(value(&s, "F3"), Value::Number(40.0));+    assert!(saved(&s, "F3").implicit);+}++#[test]+fn renaming_the_sheet_keeps_implicit_intersection() {+    let mut s = opened();+    s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap();+    let f = formula(&s, "D2");+    assert!(f.implicit);+    assert_eq!(f.text, "Data!A1:A3*Data!B1:B3");+    assert_eq!(value(&s, "D2"), Value::Number(40.0));+    assert!(saved(&s, "D2").implicit);+}++#[test]+fn a_retyped_formula_is_a_dynamic_array_again() {+    let mut s = opened();+    s.execute("cell.set", json!({"cell": "D2", "input": "=Sheet1!A1:A3*Sheet1!B1:B3"})).unwrap();+    assert!(!formula(&s, "D2").implicit, "typing a formula makes it dynamic");+    assert_eq!((value(&s, "D2"), value(&s, "D3"), value(&s, "D4")), (Value::Number(10.0), Value::Number(40.0), Value::Number(90.0)));+    let back = saved(&s, "D2");+    assert!(back.dynamic && !back.implicit);+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -15,6 +15,7 @@ mod font_scheme_minor; mod format_as_table_restyle; mod hidden_sheet_navigation; mod ink_roundtrip;+mod legacyimplicit; mod line_count_overflow; mod long_operator_chains; mod offset_overflow;diff --git a/crates/model/src/cell.rs b/crates/model/src/cell.rs--- a/crates/model/src/cell.rs+++ b/crates/model/src/cell.rs@@ -23,6 +23,13 @@ pub struct Formula {     /// rewritten in GridCraft start without it, and the writer decides from their shape.     #[serde(default, skip_serializing_if = "std::ops::Not::not")]     pub dynamic: bool,+    /// Read from a file that does not mark this formula as a dynamic-array formula (and it is+    /// not a legacy CSE array): Excel has calculated it with implicit intersection since it was+    /// written, and still does. GridCraft calculates it the same way (`gridcraft_calc::implicit`)+    /// and leaves it as a plain formula when saving, so the file keeps behaving as it did.+    /// Formulas entered or rewritten in GridCraft start without it, and are dynamic arrays.+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]+    pub implicit: bool, }  impl Formula {@@ -31,12 +38,12 @@ impl Formula {     pub fn new(text: &str) -> Formula {         let body = text.strip_prefix('=').unwrap_or(text);         match gridcraft_formula::parse(body) {-            Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false },-            Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false },+            Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false, implicit: false },+            Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false, implicit: false },         }     }     pub fn from_expr(e: Expr) -> Formula {-        Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }+        Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false, implicit: false }     }     /// The parsed expression, re-parsing after deserialization.     pub fn expr(&self) -> Option<Expr> {diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs--- a/crates/model/src/workbook.rs+++ b/crates/model/src/workbook.rs@@ -361,7 +361,7 @@ impl Workbook {                     let f = cell.formula.as_ref()?;                     let e = f.expr()?;                     let ne = spell_calls(e.clone(), &lambdas);-                    (ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, ..Formula::from_expr(ne) }))+                    (ne != e).then(|| (c, Formula { array: f.array, dynamic: f.dynamic, implicit: f.implicit, ..Formula::from_expr(ne) }))                 })                 .collect();             if fixed.is_empty() {diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs--- a/crates/xlsx/src/array_formula.rs+++ b/crates/xlsx/src/array_formula.rs@@ -20,6 +20,52 @@ impl Shape {     } } +/// Functions and operators that only exist with dynamic arrays. A formula that uses one is not a+/// legacy formula even when the file does not mark it (cell metadata `cm`): a dynamic-array+/// formula written by Excel carries the mark, so such a file came from another tool, and the+/// formula means what GridCraft's array evaluation gives.+pub(crate) fn uses_dynamic_arrays(formula: &Formula) -> bool {+    const FUNCTIONS: &[&str] = &[+        "FILTER",+        "SORT",+        "SORTBY",+        "UNIQUE",+        "SEQUENCE",+        "RANDARRAY",+        "VSTACK",+        "HSTACK",+        "TOROW",+        "TOCOL",+        "WRAPROWS",+        "WRAPCOLS",+        "TAKE",+        "DROP",+        "CHOOSEROWS",+        "CHOOSECOLS",+        "EXPAND",+        "GROUPBY",+        "PIVOTBY",+        "LET",+        "LAMBDA",+        "MAP",+        "SCAN",+        "BYROW",+        "BYCOL",+        "MAKEARRAY",+        "ISOMITTED",+        "ANCHORARRAY",+        "SINGLE",+    ];+    let Some(expr) = formula.expr_arc() else { return false };+    let mut found = false;+    expr.walk(&mut |e| match e {+        Expr::Unary(UnOp::At | UnOp::Spill, _) => found = true,+        Expr::Call(name, _) => found |= FUNCTIONS.iter().any(|f| f.eq_ignore_ascii_case(name)),+        _ => {}+    });+    found+}+ pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula) -> bool {     let Some(expr) = formula.expr_arc() else { return false };     // The budget covers every node of a formula as long as Excel allows (8,192 characters makediff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs--- a/crates/xlsx/src/sheet_read.rs+++ b/crates/xlsx/src/sheet_read.rs@@ -521,7 +521,12 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos  fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option<Formula> {     // Keep the file's dynamic-array mark, so saving never turns the formula into a legacy one.-    read_formula_text(cx, st, f, pos, dynamic).map(|fm| Formula { dynamic, ..fm })+    // A formula without it that is not a legacy CSE array is one Excel calculated with implicit+    // intersection, and goes on doing so: the calculation and the writer both honour that.+    read_formula_text(cx, st, f, pos, dynamic).map(|fm| {+        let implicit = !dynamic && fm.array.is_none() && !crate::array_formula::uses_dynamic_arrays(&fm);+        Formula { dynamic, implicit, ..fm }+    }) }  fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option<Formula> {diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs--- a/crates/xlsx/src/sheet_write.rs+++ b/crates/xlsx/src/sheet_write.rs@@ -338,7 +338,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (             let ex = extra.get(c);             let array = dynamic.get(c).copied().or_else(|| {                 let f = cell?.formula.as_ref()?;-                (f.array.is_none() && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c))+                // A formula read from a legacy file keeps its implicit intersection: Excel+                // calculates a plain formula that way, so marking it dynamic would change it.+                (f.array.is_none() && !f.implicit && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c))             });             write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, array.as_ref(), &sid, out);         }diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs--- a/crates/xlsx/src/tests/fixtures.rs+++ b/crates/xlsx/src/tests/fixtures.rs@@ -724,3 +724,42 @@ fn every_skipped_drawing_object_is_reported() {         assert_eq!(hits, 1, "object {n}: {:?}", rep.warnings);     } }++/// A plain formula from a file without dynamic-array metadata is one Excel calculated with+/// implicit intersection, so GridCraft imports it that way and saves it plain. A legacy CSE+/// array, a dynamic-array formula and a third-party formula that uses dynamic-array-only+/// functions keep array evaluation.+#[test]+fn legacy_formulas_keep_implicit_intersection_through_a_save() {+    let body = r#"<sheetData>+        <row r="1"><c r="A1"><v>1</v></c><c r="B1"><f>A1:A3*2</f><v>2</v></c><c r="C1"><f t="array" ref="C1:C3">A1:A3*2</f><v>2</v></c><c r="D1" cm="1"><f t="array" ref="D1">_xlfn.SEQUENCE(1)</f><v>1</v></c><c r="E1"><f>FILTER(A1:A3,A1:A3&gt;0)</f><v>1</v></c><c r="F1"><f>SUM(A1:A3)</f><v>6</v></c></row>+        <row r="2"><c r="A2"><v>2</v></c><c r="C2"><v>4</v></c></row>+        <row r="3"><c r="A3"><v>3</v></c><c r="C3"><v>6</v></c></row>+    </sheetData>"#;+    let (wb, _) = read_xlsx(&super::minimal(body, &[], "", "")).unwrap();+    let s = wb.sheet(0).unwrap();+    let formula = |address: &str| s.cell(at(address)).and_then(|c| c.formula.clone()).unwrap();+    let plain = formula("B1");+    assert!(plain.implicit && !plain.dynamic && plain.array.is_none(), "a plain formula is legacy");+    assert!(formula("F1").implicit, "SUM keeps its range argument, but the formula is still legacy");+    assert!(!formula("C1").implicit, "a legacy CSE array is array-evaluated");+    assert!(!formula("D1").implicit, "a formula with dynamic-array metadata is not legacy");+    assert!(!formula("E1").implicit, "a formula using FILTER is dynamic even without the metadata");++    let bytes = crate::write_xlsx(&wb).unwrap();+    let mut pkg = crate::package::Package::open(&bytes).unwrap();+    let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();+    let cell =+        |address: &str| sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap();+    for address in ["B1", "F1"] {+        assert_eq!(cell(address).attr("cm"), None, "{address} stays plain");+        let f = cell(address).child("f").unwrap();+        assert_eq!((f.attr("t"), f.attr("ref")), (None, None), "{address} stays a scalar formula");+        assert!(!f.text.contains('@') && !f.text.contains("SINGLE"), "{address}: {}", f.text);+    }+    assert_eq!(cell("B1").child("f").unwrap().text, "A1:A3*2");+    assert_eq!(cell("F1").child("f").unwrap().text, "SUM(A1:A3)");+    let cse = cell("C1").child("f").unwrap();+    assert_eq!((cse.attr("t"), cse.attr("ref")), (Some("array"), Some("C1:C3")));+    assert_eq!(cell("D1").attr("cm"), Some("1"));+}diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs--- a/crates/xlsx/src/tests/xls_tests.rs+++ b/crates/xlsx/src/tests/xls_tests.rs@@ -318,6 +318,8 @@ fn reads_values_formulas_formats_and_layout() {         // Formulas: text plus the cached result.         assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2"));         assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0));+        assert!(wb.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().implicit, "BIFF has no dynamic arrays");+         assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));         assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\""));         assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!"));@@ -334,6 +336,7 @@ fn reads_values_formulas_formats_and_layout() {         // the formula fills the range on recalculation (as when an .xlsx is read, #156).         let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();         assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));+        assert!(!c5.implicit, "a legacy CSE array keeps array evaluation");         assert_eq!(ftext(&wb, "C6"), None);         assert_eq!(v(&wb, 0, "C6"), Value::Empty);         // Number formats, merges, sizes, names, print area; embedded chart records ignored.diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs--- a/crates/xlsx/src/xls/mod.rs+++ b/crates/xlsx/src/xls/mod.rs@@ -469,6 +469,9 @@ impl SheetState<'_> {                     return;                 }                 f.array = array.filter(|r| !r.is_single());+                // BIFF predates dynamic arrays: every other formula is a legacy one, calculated+                // with implicit intersection, and is saved as a plain formula.+                f.implicit = f.array.is_none();                 if let Some(cell) = self.sheet.cells.get_mut(pos) {                     cell.formula = Some(Arc::new(f));                 } else {diff --git a/docs/decisions.md b/docs/decisions.md--- a/docs/decisions.md+++ b/docs/decisions.md@@ -28,7 +28,7 @@ The exporter inspects the parsed formula without evaluating it. It distinguishes  Names are resolved using workbook/sheet scope, and structured references distinguish one table-row cell from a column. Literal `INDIRECT` and `INDEX` arguments permit tighter classification; unresolved reference dimensions are conservatively treated as potentially multiple cells. Name/AST traversal is bounded, with array preservation as the fallback. This is an export classifier, not a claim of exact Excel metadata parity for every function or user-defined function. It stays within the format crate's existing dependencies and does not change calculation or import behavior. -This is a writer policy; interpreting implicit intersection in imported legacy formulas remains separate work. See Microsoft's [Formula vs. Formula2 documentation](https://learn.microsoft.com/en-us/office/vba/excel/concepts/cells-and-ranges/range-formula-vs-formula2).+Imported formulas are the other half. A formula read from a file without dynamic-array metadata (`cm`) that is not a legacy CSE array is one Excel has always calculated with implicit intersection, and every `.xls` (BIFF) formula is such a formula, so the model marks it `implicit`. At calculation time `gridcraft_calc::implicit` rewrites a copy of the expression, wrapping every reference used as one value in `@`; the formula text, its references and the saved file stay plain. Edits that only move or adjust the formula (inserting rows, moving or copying it, renaming a sheet, table or name) keep the mark, while typing a formula makes it a dynamic array, as in Excel. A formula that uses a dynamic-array-only function (`FILTER`, `SEQUENCE`, `LET`, …) is treated as dynamic even without the mark, for files written by other tools. Names used by an implicit formula are intersected; a name whose own definition is a legacy calculation is not rewritten (a known gap). See Microsoft's [Formula vs. Formula2 documentation](https://learn.microsoft.com/en-us/office/vba/excel/concepts/cells-and-ranges/range-formula-vs-formula2).  ## Static cell pictures diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (XLSX formulas read without dynamic-array metadata calculate with implicit intersection and are saved plain, so legacy workbooks no longer show `#SPILL!`; earlier: .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**@@ -39,6 +39,17 @@ names keep the mark; retyping the formula drops it, and the writer then decides formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error values) is still dropped. +A plain formula read without that metadata, and not a legacy CSE array, is calculated the way+Excel calculates it: with implicit intersection. The model marks it `implicit`, the evaluator+rewrites its scalar positions with `@` when it recalculates, and the writer saves it plain, so a+file from Excel 2019 (or any writer that leaves out `cm`) opens with the values it had instead of+`#SPILL!` (`fixtures.rs`, engine `legacyimplicit.rs`). `.xls` (BIFF) has no dynamic arrays, so+every non-CSE `.xls` formula is marked the same way (`xls_tests.rs`). Formulas using dynamic-array-only+functions (`FILTER`, `SEQUENCE`, `LET`, …) are treated as dynamic even without the mark, and a+formula typed in GridCraft is always a dynamic array. Undo/redo, row and column insert/delete,+cut/copy/paste, fills and sheet, table or name renames keep the mark; a defined name whose own+definition is a legacy calculation is not rewritten yet.+ | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |@@ -110,6 +121,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | XLSX formulas read without dynamic-array metadata (`cm`), and every `.xls` formula, calculate with implicit intersection as Excel does and are saved plain: a legacy workbook's formulas show their values instead of `#SPILL!`. Dynamic-array-only functions still mark a formula dynamic, and typing a formula makes it one; moves, fills and renames keep the legacy mark (`fixtures.rs`, `xls_tests.rs`, engine `legacyimplicit.rs`) | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |diff --git a/docs/function-parity.md b/docs/function-parity.md--- a/docs/function-parity.md+++ b/docs/function-parity.md@@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-26 · **Change:** minor (legacy formulas read without dynamic-array metadata calculate with implicit intersection; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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).@@ -83,8 +83,7 @@ This improves series preparation; it does not change the model-fitting or season | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale spills after a structural edit or a cleared blocker fixed (#133, #139; PRs #148, #157) | | Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) | | SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) |-| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |-| Implicit intersection `@`, legacy CSE array formulas | done, the entered range filled (PR #156) |+| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156); formulas read from files without dynamic-array metadata calculate with implicit intersection and are saved plain, while formulas typed here are dynamic arrays | | Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |