From 50a9e9ede44a0c9bd1840fa78e12fcdc204f0c73 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:05:02 -0600 Subject: [PATCH] XLS: formulas open as legacy formulas with Excel's implicit @ (#410) (#1367) .xls formulas are read as legacy formulas with Excel's implicit @ (apply_legacy via read_xls_with), one-cell Ctrl+Shift+Enter arrays keep their range, @ of a 2-D range meets the formula's row and column, and defined-name analyses are shared across the formulas of a pass and replayed exactly. Closes #410 --- crates/calc/src/eval.rs | 5 + crates/calc/src/tests.rs | 19 ++- crates/engine/src/io.rs | 5 +- crates/engine/src/tests.rs | 73 ++++++++++ crates/engine/tests/legacy_array_functions.rs | 33 ++++- crates/xlsx/src/array_formula.rs | 119 +++++++++++++++- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/sheet_write.rs | 2 +- crates/xlsx/src/tests/array_export.rs | 132 ++++++++++++++++++ crates/xlsx/src/tests/xls_tests.rs | 113 ++++++++++++++- crates/xlsx/src/write.rs | 3 + crates/xlsx/src/xls.rs | 33 ++++- docs/file-format-parity.md | 3 +- 13 files changed, 518 insertions(+), 24 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 41ffca9c2..2fda4442b 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -543,6 +543,11 @@ impl<'h> Evaluator<'h> { if r.height() == 1 && r.start.col <= self.at.col && self.at.col <= r.end.col { return Some(CellRef::new(r.start.row, self.at.col)); } + // A 2-D range intersects on both the formula's row and column (observed in Excel, on + // another sheet too); one that includes the formula's own cell is a circular reference. + if r.start.row <= self.at.row && self.at.row <= r.end.row && r.start.col <= self.at.col && self.at.col <= r.end.col { + return Some(self.at); + } None } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index b4f820f0c..8784a8d69 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1923,8 +1923,8 @@ fn offset_extreme_arguments_return_ref() { } /// `@` intersects a reference with the formula's row (a column range) or column (a row range); -/// a 2D range or one the formula does not line up with gives #VALUE!. An array gives its -/// top-left value. Legacy formulas read from files use it where Excel intersects implicitly. +/// a 2D range both (circular when it includes the formula's cell); a range the formula does not +/// line up with gives #VALUE!. An array gives its top-left value. Legacy formulas read from files use it where Excel intersects implicitly. #[test] fn implicit_intersection_operator() { let mut t = T::new(); @@ -1941,9 +1941,22 @@ fn implicit_intersection_operator() { t.set("D3", "=SUM(@A1:A3)"); assert_eq!(t.num("B2"), 2f64.tanh()); assert_eq!(t.num("B7"), 5f64.tanh()); - for at in ["B3", "B9", "E9"] { + for at in ["B9", "E9"] { assert_eq!(t.get(at), Value::Error(CellError::Value), "{at}"); } + // A 2D range that includes the formula's own cell is a circular reference (Excel shows 0). + assert_eq!(t.get("B3"), Value::Error(CellError::Circ)); + // From another sheet a 2D range intersects on both row and column; outside it is #VALUE!. + t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Other"))); + for (at, input) in [("B2", "=@Sheet1!A1:C3"), ("C1", "=@Sheet1!A1:C3"), ("B9", "=@Sheet1!A1:C3"), ("E2", "=@Sheet1!A1:C3")] { + t.wb.sheet_mut(1).unwrap().cells.set(c(at), Cell::formula(Formula::new(input))); + } + t.calc.recalc_all(&mut t.wb); + let other = |at: &str| t.wb.sheet(1).unwrap().value(c(at)); + assert_eq!(other("B2"), Value::Number(2f64.tanh())); // Sheet1!B2 + assert_eq!(other("C1"), Value::Number(0.0)); + assert_eq!(other("B9"), Value::Error(CellError::Value)); + assert_eq!(other("E2"), Value::Error(CellError::Value)); assert_eq!(t.num("D1"), 7.0); assert_eq!(t.num("D2"), 2.0); assert_eq!(t.num("D3"), 3.0); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 22c236e61..690efec1d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -9,7 +9,7 @@ use crate::{EngineError, Result}; mod sylk; -/// The function library's answers to what the XLSX reader and writer need to know about functions +/// The function library's answers to what the XLSX and XLS readers and the XLSX writer need to know about functions /// (which names are built in, which parameters take a single value). pub const XLSX_FUNCTIONS: gridcraft_xlsx::FunctionInfo = gridcraft_xlsx::FunctionInfo { is_builtin: |name| gridcraft_functions::lookup(name).is_some(), @@ -271,7 +271,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); if sniffed == gridcraft_xlsx::Format::Xls { - let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; + let (wb, report) = + gridcraft_xlsx::read_xls_with(bytes, XLSX_FUNCTIONS).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); } if sniffed == gridcraft_xlsx::Format::LegacyBinary { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fcaf19c36..08fc9119f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3664,6 +3664,79 @@ fn xls_import_keeps_formulas_and_never_saves_back_to_xls() { assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); } +#[test] +fn xls_formulas_calculate_with_legacy_implicit_intersection() { + // Data!A1 = 11, B1:G1 = 1, A2 = 5, A3 = 7. Report!B1:G1 are filled, so a formula in Report!A1 + // that spilled would show #SPILL!. Every cached result is 0, so the values below are GridCraft's. + fn rec(id: u16, payload: &[u8]) -> Vec { + [&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat() + } + let u16s = |v: &[u16]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat()); + let number = |r: u16, c: u16, n: f64| rec(0x0203, &[&u16s(&[r, c, 0])[..], &n.to_le_bytes()].concat()); + let formula = |r: u16, c: u16, rgce: &[u8]| { + rec(0x0006, &[&u16s(&[r, c, 0])[..], &0f64.to_le_bytes(), &[0; 6], &(rgce.len() as u16).to_le_bytes(), rgce].concat()) + }; + let area3d = |r0: u16, r1: u16, c0: u16, c1: u16| [&[0x3Bu8, 0, 0][..], &u16s(&[r0, r1, c0, c1])].concat(); + let a2a3 = area3d(1, 2, 0, 0); + let mut data = bof(0x0010); + data.extend([number(0, 0, 11.0), number(1, 0, 5.0), number(2, 0, 7.0)].concat()); + (1..7).for_each(|c| data.extend(number(0, c, 1.0))); + data.extend(rec(0x000A, &[])); + let mut report = bof(0x0010); + (1..7).for_each(|c| report.extend(number(0, c, 0.0))); + // A1 = Data!A1:G1; B2 = Data!$A$2:$A$3*10; C2 = SQRT(Data!$A$2:$A$3) (row 2: SQRT(5)); + // H1 = {SUM(Data!$A$2:$A$3*2)}, a one-cell Ctrl+Shift+Enter array. + let cse = [&a2a3[..], &[0x1E, 2, 0, 0x05, 0x22, 1, 4, 0]].concat(); + report.extend(formula(0, 0, &area3d(0, 0, 0xC000, 6 | 0xC000))); + report.extend(formula(1, 1, &[&a2a3[..], &[0x1E, 10, 0, 0x05]].concat())); + report.extend(formula(1, 2, &[&a2a3[..], &[0x21, 20, 0]].concat())); + report.extend(formula(0, 7, &[1, 0, 0, 7, 0])); + report.extend(rec(0x0221, &[&u16s(&[0, 0])[..], &[7, 7], &[0; 6], &(cse.len() as u16).to_le_bytes(), &cse].concat())); + report.extend(rec(0x000A, &[])); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, name.len() as u8, 0], name.as_bytes()].concat()); + let globals = |p1: u32, p2: u32| { + [ + bof(0x0005), + boundsheet(p1, "Data"), + boundsheet(p2, "Report"), + rec(0x01AE, &u16s(&[2, 0x0401])), + rec(0x0017, &u16s(&[1, 0, 0, 0])), + rec(0x000A, &[]), + ] + .concat() + }; + let p1 = globals(0, 0).len() as u32; + let stream = [globals(p1, p1 + data.len() as u32), data, report].concat(); + let bytes = gridcraft_xlsx::testing::compound_file(&[("Workbook", &stream)]); + + let mut s = s(); + s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + s.execute("formulas.calculateNow", json!({})).unwrap(); + let report = |s: &Session, a1: &str| s.doc().unwrap().wb.sheets[1].value(CellRef::parse(a1).unwrap()); + assert_eq!(report(&s, "A1"), Value::Number(11.0)); + assert_eq!(report(&s, "B2"), Value::Number(50.0)); + assert_eq!(report(&s, "C2"), Value::Number(5f64.sqrt())); + assert_eq!(report(&s, "H1"), Value::Number(24.0)); + // Nothing spilled out of A1 or B2. + assert_eq!(report(&s, "B1"), Value::Number(0.0)); + assert_eq!(report(&s, "B3"), Value::Empty); + // Saved as .xlsx, the formulas carry no dynamic-array metadata. + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let saved = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + let (wb, _) = gridcraft_xlsx::read_xlsx(&saved).unwrap(); + let f = |a1: &str| wb.sheets[1].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.clone()).unwrap(); + assert_eq!(f("A1").text, "@Data!A1:G1"); + assert_eq!((f("H1").text.as_str(), f("H1").array), ("SUM(Data!$A$2:$A$3*2)", gridcraft_core::RangeRef::parse("H1"))); + let mut sheet = String::new(); + std::io::Read::read_to_string( + &mut zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap().by_name("xl/worksheets/sheet2.xml").unwrap(), + &mut sheet, + ) + .unwrap(); + assert!(!sheet.contains("cm=") && !sheet.contains("SINGLE"), "{sheet}"); +} + #[test] fn malformed_xlsb_keeps_the_current_workbook() { let mut s = s(); diff --git a/crates/engine/tests/legacy_array_functions.rs b/crates/engine/tests/legacy_array_functions.rs index 1549b6f70..6e86f2e13 100644 --- a/crates/engine/tests/legacy_array_functions.rs +++ b/crates/engine/tests/legacy_array_functions.rs @@ -228,6 +228,28 @@ fn legacy_unions_names_and_parentheses_calculate_and_save_as_read() { assert!(!xml.contains("cm=") && !xml.contains("SINGLE") && !xml.contains("t=\"array\""), "{xml}"); } +/// A whole column in a scalar argument of a legacy formula meets the formula's row (issue #1636, +/// from PR #1660): `ISBLANK(B:B)` in row 4 tests `B4`, so the formula shows `C4` instead of +/// spilling into `G5`, and in row 6, where `B6` is blank, shows 0. +#[test] +fn whole_column_in_a_legacy_scalar_argument_meets_the_formulas_row() { + let formulas: Vec<(String, String)> = [ + ("B4", r#""Thierry""#), + ("C4", "7"), + ("G4", r#"IF(ISBLANK(B:B),0,IF(B4="Thierry",C4,0))"#), + ("G5", "1"), + ("G6", r#"IF(ISBLANK(B:B),0,IF(B6="Thierry",C6,0))"#), + ] + .iter() + .map(|(a, f)| (a.to_string(), f.to_string())) + .collect(); + let s = open(&legacy_xlsx(&formulas, "")); + let at = |a: &str| CellRef::parse(a).unwrap(); + assert_eq!(value(&s, at("G4")), Value::Number(7.0), "B:B meets row 4 instead of spilling"); + assert_eq!(value(&s, at("G5")), Value::Number(1.0), "nothing spills below G4"); + assert_eq!(one_value(&s, at("G6"), "G6").unwrap(), Value::Number(0.0), "B6 is blank"); +} + /// One-cell ranges written as ranges (`B1:B1`) outside any `@`, and formulas whose reading /// reaches the parser's nesting limit (254 nested functions, far past Excel's 64): saved as read, /// without `@`. Only a one-cell range under an `@` counts as one an edit may have shrunk. All but @@ -302,9 +324,18 @@ fn legacy_xlsx(formulas: &[(String, String)], names: &str) -> Vec { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); let mut rows = String::from(r#"1234"#); + // Cells in one row share its ``, in the order given. + let mut by_row: Vec<(String, String)> = Vec::new(); for (a, f) in formulas { let r: String = a.chars().filter(char::is_ascii_digit).collect(); - rows.push_str(&format!(r#"{}"#, esc(f))); + let c = format!(r#"{}"#, esc(f)); + match by_row.iter_mut().find(|(row, _)| *row == r) { + Some((_, cells)) => cells.push_str(&c), + None => by_row.push((r, c)), + } + } + for (r, cells) in by_row { + rows.push_str(&format!(r#"{cells}"#)); } let names = if names.is_empty() { String::new() } else { format!("{names}") }; let parts: [(&str, String); 5] = [ diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index 8d871dd75..804192969 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -88,11 +88,11 @@ pub(crate) enum Export { Dynamic, } -pub(crate) fn export(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula, functions: FunctionInfo) -> Export { +pub(crate) fn export(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula, functions: FunctionInfo, names: &mut NameCache) -> Export { let Some(expr) = formula.expr_arc() else { return Export::Unchanged }; let mut explicit = false; expr.walk(&mut |e| explicit |= matches!(e, Expr::Unary(UnOp::At, _))); - let mut legacy = Legacy::new(wb, at, functions); + let mut legacy = Legacy::new(wb, at, functions, names); if !explicit { legacy.formula(expr.as_ref().clone(), sheet); return if !legacy.certain { @@ -119,7 +119,7 @@ pub(crate) fn export(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula candidates.insert(0, unwrap_at(x.as_ref().clone())); } for text in candidates { - let read = self::legacy(wb, sheet, at, &Formula::from_expr(text.clone()), functions); + let read = self::legacy(wb, sheet, at, &Formula::from_expr(text.clone()), functions, names); if read.map_or(text == *expr, |f| f.expr_arc().is_some_and(|e| *e == *expr)) { return reads_back(text).map_or(Export::Dynamic, Export::Legacy); } @@ -140,12 +140,14 @@ fn remove_at(e: Expr) -> Expr { /// Rewrites the legacy cell formulas at `cells` (model sheet index, positions) to their /// dynamic-array reading, with `@` where Excel intersects. Runs once names and tables are loaded. pub(crate) fn apply_legacy(wb: &mut Workbook, cells: Vec<(usize, Vec)>, functions: FunctionInfo) { + // Defined names do not change during the pass: each is analysed once for all the cells. + let mut names = NameCache::default(); for (si, positions) in cells { let Some(sheet) = wb.sheet(si) else { continue }; let mut changed = Vec::new(); for at in positions { let Some(formula) = sheet.cell(at).and_then(|c| c.formula.as_deref()) else { continue }; - if let Some(f) = legacy(wb, si, at, formula, functions) { + if let Some(f) = legacy(wb, si, at, formula, functions, &mut names) { changed.push((at, f)); } } @@ -162,12 +164,12 @@ pub(crate) fn apply_legacy(wb: &mut Workbook, cells: Vec<(usize, Vec)>, } /// The dynamic-array reading of a legacy formula, when it differs. -fn legacy(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula, functions: FunctionInfo) -> Option { +fn legacy(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula, functions: FunctionInfo, names: &mut NameCache) -> Option { if formula.array.is_some() { return None; } let expr = formula.expr_arc()?; - let mut legacy = Legacy::new(wb, at, functions); + let mut legacy = Legacy::new(wb, at, functions, names); let out = legacy.formula(expr.as_ref().clone(), sheet); if legacy.certain && legacy.complete { if legacy.inserted == 0 { @@ -228,8 +230,56 @@ fn atomic(e: &Expr) -> bool { /// (see [`Legacy::binary`]). const MAX_DEPTH: usize = 256; +/// What the analysis of one defined name leaves for a later formula that refers to it. +#[derive(Clone)] +struct NameInfo { + kind: Kind, + /// The definition reached a one-cell reference written as a range. + one_cell_range: bool, + /// The analysis covered only established legacy semantics. + certain: bool, + /// Nodes of the per-formula budget the analysis used. + cost: usize, + /// Nesting below the reference that the analysis reached. + depth: usize, + /// Every name the analysis read, with its result (itself last): a hit records them all, as a + /// fresh analysis does, so a later reference to one of them in the formula costs nothing. + closure: Vec<(NameKey, Kind, bool)>, +} + +/// A defined name as [`Legacy::names`] keys it: upper-case name, scope, collapsed. +type NameKey = (String, usize, bool); + +/// Defined names analysed so far, shared by the formulas of one pass (reading a workbook, or +/// writing one) so that a name is parsed and analysed once, not once per formula that refers +/// to it. Keyed like [`Legacy::names`]. Only analyses that finished within their limits, did not +/// depend on the formula's own position and did not reuse a name the formula had already read +/// (or meet a cycle) are kept. A hit charges the budget and nesting the analysis used and records +/// every name it read, and is taken only when the formula can afford it, so a formula reads the +/// same with the cache as without it. +#[derive(Default)] +pub(crate) struct NameCache { + map: HashMap, + /// How many names were analysed (not found in the cache). + pub(crate) analyses: usize, +} + +/// Names kept at most: the cache is bounded even when a workbook defines a great many. +const MAX_CACHED_NAMES: usize = 65_536; + struct Legacy<'a> { wb: &'a Workbook, + /// Name analyses shared with the other formulas of this pass. + shared: &'a mut NameCache, + /// The analysis read a structured reference without a table name, which depends on `at`. + position_dependent: bool, + /// The analysis reused a name this formula had already read, or met a cycle: its result + /// leaves out what that name cost. + reused: bool, + /// The names read so far in this formula, in order, with their results. + read: Vec<(NameKey, Kind, bool)>, + /// The deepest nesting reached. + deepest: usize, at: CellRef, functions: FunctionInfo, remaining: usize, @@ -262,9 +312,14 @@ struct Legacy<'a> { } impl<'a> Legacy<'a> { - fn new(wb: &'a Workbook, at: CellRef, functions: FunctionInfo) -> Self { + fn new(wb: &'a Workbook, at: CellRef, functions: FunctionInfo, shared: &'a mut NameCache) -> Self { Legacy { wb, + shared, + position_dependent: false, + reused: false, + read: Vec::new(), + deepest: 0, at, functions, remaining: 16384, @@ -364,6 +419,7 @@ impl<'a> Legacy<'a> { fn expr(&mut self, e: Expr, sheet: usize, ctx: Ctx, depth: usize) -> (Expr, Kind) { // Names can be cyclic or nest deeply; their kinds are remembered, so repeated names // cost one analysis each. + self.deepest = self.deepest.max(depth); if depth >= MAX_DEPTH || self.remaining == 0 { self.complete = false; return (e, Kind::Array); @@ -407,6 +463,7 @@ impl<'a> Legacy<'a> { } Expr::Struct(r) => { let table = if r.table.is_empty() { + self.position_dependent = true; self.wb.sheet(sheet).and_then(|s| s.table_at(self.at)) } else { self.wb.table(&r.table).and_then(|(si, ti)| self.wb.sheet(si).and_then(|s| s.tables.get(ti))) @@ -459,25 +516,73 @@ impl<'a> Legacy<'a> { let key = (bare.to_ascii_uppercase(), scope, self.collapsed); match self.names.get(&key) { Some(Some((kind, one_cell_range))) => { + self.reused = true; self.one_cell_range |= *one_cell_range; return *kind; } Some(None) => { // A name defined in terms of itself has no legacy value to keep. self.certain = false; + self.reused = true; return Kind::Array; } None => {} } + let reached = depth.saturating_add(1); + // Taken only when the formula can afford it and has read none of the names it read: + // otherwise the fresh analysis below differs (cut short, or reusing those names). + let hit = self.shared.map.get(&key).filter(|i| { + reached + i.depth < MAX_DEPTH && i.cost <= self.remaining && i.closure.iter().all(|(k, _, _)| !self.names.contains_key(k)) + }); + if let Some(info) = hit { + // Charged and recorded as the analysis was: the formula reads as a fresh one does. + self.deepest = self.deepest.max(reached + info.depth); + self.certain &= info.certain; + self.one_cell_range |= info.one_cell_range; + self.remaining -= info.cost; + for (k, kind, one_cell_range) in &info.closure { + self.names.insert(k.clone(), Some((*kind, *one_cell_range))); + } + self.read.extend(info.closure.iter().cloned()); + return info.kind; + } let Ok(expr) = gridcraft_formula::parse_name(def.formula.trim_start_matches('=')) else { return Kind::Array }; + self.shared.analyses += 1; self.names.insert(key.clone(), None); // A name's own formula does not see the formula's LET variables. let locals = std::mem::take(&mut self.locals); let seen = std::mem::replace(&mut self.one_cell_range, false); + let before = self.remaining; + let first_read = self.read.len(); + let outer = ( + std::mem::replace(&mut self.certain, true), + std::mem::replace(&mut self.complete, true), + std::mem::replace(&mut self.position_dependent, false), + std::mem::replace(&mut self.reused, false), + std::mem::replace(&mut self.deepest, 0), + ); let kind = self.kind_of(expr, def.scope.unwrap_or(scope), depth + 1); let one_cell_range = std::mem::replace(&mut self.one_cell_range, seen); + self.read.push((key.clone(), kind, one_cell_range)); + let cacheable = self.complete && !self.position_dependent && !self.reused; + let info = cacheable.then(|| NameInfo { + kind, + one_cell_range, + certain: self.certain, + cost: before.saturating_sub(self.remaining), + depth: self.deepest.saturating_sub(depth + 1), + closure: self.read[first_read..].to_vec(), + }); + self.certain &= outer.0; + self.complete &= outer.1; + self.position_dependent |= outer.2; + self.reused |= outer.3; + self.deepest = self.deepest.max(outer.4); self.one_cell_range |= one_cell_range; self.locals = locals; + if let Some(info) = info.filter(|_| self.shared.map.len() < MAX_CACHED_NAMES) { + self.shared.map.insert(key.clone(), info); + } self.names.insert(key, Some((kind, one_cell_range))); return kind; } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 259a1a1a6..773e2895f 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -52,7 +52,7 @@ pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_ pub use ods::read_ods; pub use package::MAX_PART; pub use passthrough::rebase_links; -pub use xls::read_xls; +pub use xls::{read_xls, read_xls_with}; pub use xlsb::read_xlsb; /// Errors from reading or writing files. diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index d3ba1426b..c405667b1 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -652,7 +652,7 @@ fn write_sheet_data(s: &mut String, data: SheetCellData<'_>, sid: &dyn Fn(gridcr if f.dynamic { return Some(RangeRef::cell(cell_ref)); } - match crate::array_formula::export(workbook, index, cell_ref, f, out.functions) { + match crate::array_formula::export(workbook, index, cell_ref, f, out.functions, &mut out.names) { crate::array_formula::Export::Unchanged => None, crate::array_formula::Export::Dynamic => Some(RangeRef::cell(cell_ref)), // Every `@` is one Excel implies in a legacy formula: write it without them. diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index 9c7f0700a..9e60f0373 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -467,6 +467,138 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() { ); } +/// How `export` saved a formula, as comparable text. +fn export_text(wb: &Workbook, at: CellRef, text: &str, names: &mut crate::array_formula::NameCache) -> String { + use crate::array_formula::{Export, export}; + match export(wb, 0, at, &Formula::new(text), crate::FunctionInfo::default(), names) { + Export::Unchanged => "unchanged".into(), + Export::Dynamic => "dynamic".into(), + Export::Legacy(e) => format!("legacy {}", gridcraft_formula::print(&e)), + } +} + +#[test] +fn defined_names_are_analysed_once_for_many_formulas() { + use crate::array_formula::NameCache; + let mut wb = Workbook::new(); + // A large definition: many operations, ending in a range. + let big = (1..=500).map(|i| format!("Sheet1!$A${i}")).collect::>().join("+"); + for (name, text) in [("Big", format!("({big})*Sheet1!$B$1:$B$3")), ("Rng", "Sheet1!$A$1:$A$3".to_string()), ("Alias", "Rng".to_string())] { + wb.names.push(DefinedName { name: name.into(), scope: None, formula: text, comment: String::new(), hidden: false }); + } + let texts = ["SUM(Big)", "SQRT(Big)", "Big*2", "SQRT(Alias)", "SUM(Alias)", "Alias", "SUM(Rng*2)", "Missing*2"]; + let mut shared = NameCache::default(); + for i in 0..400u32 { + for text in texts { + let at = CellRef::new(i + 10, 5); + let with_shared = export_text(&wb, at, text, &mut shared); + let fresh = export_text(&wb, at, text, &mut NameCache::default()); + assert_eq!(with_shared, fresh, "{text}"); + } + } + assert!(gridcraft_formula::parse(&wb.names[0].formula).is_ok(), "the large name must parse"); + // 3 names, each analysed once for all 3200 formulas (a fresh cache analyses a name per formula). + assert_eq!(shared.analyses, 3); +} + +#[test] +fn shared_name_analysis_keeps_each_formulas_limits_and_position() { + use crate::array_formula::NameCache; + let mut wb = Workbook::new(); + let terms = |n: usize| (1..=n).map(|i| format!("Sheet1!$A${i}")).collect::>().join("+"); + // Twenty names of about a thousand nodes each: a formula using all of them needs more than + // its 16,384-node budget, one using a few does not. + for i in 0..20 { + let text = format!("({})*Sheet1!$B$1:$B$3", terms(500)); + wb.names.push(DefinedName { name: format!("Big_{i}"), scope: None, formula: text, comment: String::new(), hidden: false }); + } + for (name, text) in [ + ("Cyc1", "Cyc2".to_string()), + ("Cyc2", "Cyc1".to_string()), + ("Deep", format!("{}1{}", "(".repeat(100), ")".repeat(100))), + ("Tbl", "Table1[Col]".to_string()), + ] { + wb.names.push(DefinedName { name: name.into(), scope: None, formula: text, comment: String::new(), hidden: false }); + } + assert!(wb.names.iter().all(|n| gridcraft_formula::parse(&n.formula).is_ok()), "every name must parse"); + let uses = |n: usize| (0..n).map(|i| format!("SQRT(Big_{i})")).collect::>().join("+"); + let mut texts: Vec = [1, 5, 10, 15, 17, 20].iter().map(|n| uses(*n)).collect(); + texts.extend(["SQRT(Big_3)", "Cyc1", "SUM(Cyc1)", "SUM(Deep)", "SQRT(Tbl)", "((((((((SQRT(Deep))))))))"].map(String::from)); + // The limit is really reached: a few uses analyse fully, all twenty stop at the budget. + let probe = |n: usize| export_text(&wb, CellRef::new(5, 5), &uses(n), &mut NameCache::default()); + assert_eq!(probe(1), "dynamic"); + assert_eq!(probe(20), "unchanged"); + let mut shared = NameCache::default(); + for round in 0..3 { + for text in &texts { + for at in [CellRef::new(5, 5), CellRef::new(7, 9)] { + let with_shared = export_text(&wb, at, text, &mut shared); + let fresh = export_text(&wb, at, text, &mut NameCache::default()); + assert_eq!(with_shared, fresh, "round {round}: {}", &text[..text.len().min(60)]); + } + } + } +} + +/// A name defined as another name reads the same from the shared cache in either order: after +/// the other name (whose cost and uncertainty the formula already paid) and before it (a hit +/// records the other name too, so the formula does not pay for it twice). +#[test] +fn shared_name_analysis_of_names_defined_by_names_matches_fresh_in_both_orders() { + use crate::array_formula::NameCache; + let mut wb = Workbook::new(); + let terms = (1..=500).map(|i| format!("Sheet1!$A${i}")).collect::>().join("+"); + // `Unsure` is outside the established legacy semantics (ISOMITTED), and `Alias` is it by name. + let mut defs = vec![ + ("Unsure".to_string(), "IF(ISOMITTED(Sheet1!$C$1),Sheet1!$A$1:$A$3,Sheet1!$B$1:$B$3)".to_string()), + ("Alias".to_string(), "Unsure".to_string()), + ]; + for i in 0..10 { + defs.push((format!("Big_{i}"), format!("({terms})*Sheet1!$B$1:$B$3"))); + defs.push((format!("Via_{i}"), format!("Big_{i}"))); + } + for (name, formula) in defs { + wb.names.push(DefinedName { name, scope: None, formula, comment: String::new(), hidden: false }); + } + // Ten names of about a thousand nodes, each reached twice: within the budget only if the + // second reference is free. + let both = |first: &str, second: &str| (0..10).map(|i| format!("SQRT({first}_{i})+SQRT({second}_{i})")).collect::>().join("+"); + let orders = [ + ["Unsure+SQRT(Alias)", "SQRT(Alias)+Unsure", "SQRT(Alias)+SQRT(Sheet1!$A$1:$A$3)", "@SQRT(Alias)"].map(String::from).to_vec(), + ["SQRT(Alias)", "SQRT(Alias)+Unsure", "Unsure+SQRT(Alias)", "SQRT(Alias)+SQRT(Sheet1!$A$1:$A$3)", "@SQRT(Alias)"].map(String::from).to_vec(), + vec![both("Big", "Via"), both("Via", "Big"), "SQRT(Via_0)".to_string(), both("Via", "Big")], + vec!["SQRT(Via_0)".to_string(), both("Via", "Big"), both("Big", "Via"), both("Via", "Big")], + ]; + let at = CellRef::new(5, 5); + assert_eq!(export_text(&wb, at, &both("Via", "Big"), &mut NameCache::default()), "dynamic", "fresh fits the budget"); + for texts in orders { + let mut shared = NameCache::default(); + for text in &texts { + let with_shared = export_text(&wb, at, text, &mut shared); + let fresh = export_text(&wb, at, text, &mut NameCache::default()); + assert_eq!(with_shared, fresh, "{}", &text[..text.len().min(60)]); + } + // Reading: one pass over the formulas, one row each, against each formula read alone. + let read = |cells: &[(CellRef, &String)], one_pass: bool| -> Vec { + let mut wb = wb.clone(); + for (c, text) in cells { + Arc::make_mut(&mut wb.sheets[0]).cells.set(*c, Cell::formula(Formula::new(text))); + } + let all: Vec = cells.iter().map(|(c, _)| *c).collect(); + if one_pass { + crate::array_formula::apply_legacy(&mut wb, vec![(0, all.clone())], crate::FunctionInfo::default()); + } else { + for c in &all { + crate::array_formula::apply_legacy(&mut wb, vec![(0, vec![*c])], crate::FunctionInfo::default()); + } + } + all.iter().map(|c| gridcraft_formula::print(&wb.sheets[0].cells.get(*c).unwrap().formula.as_ref().unwrap().expr_arc().unwrap())).collect() + }; + let cells: Vec<(CellRef, &String)> = texts.iter().enumerate().map(|(i, t)| (CellRef::new(i as u32 + 5, 5), t)).collect(); + assert_eq!(read(&cells, true), read(&cells, false)); + } +} + #[test] fn long_flat_chains_are_scalar_formulas() { // `=A1+A2+…` nests one level per operator. Past the scan's depth limit of 64, a plain sum diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index bd21b4939..762e512e0 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -508,8 +508,11 @@ fn sheets_share_one_record_budget() { stream.extend(eof()); let file = compound("Workbook", &stream); // About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits. - assert!(matches!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_)))); - assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).is_ok()); + assert!(matches!( + crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(100_000), crate::FunctionInfo::default()), + Err(IoError::TooLarge(_)) + )); + assert!(crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(10_000_000), crate::FunctionInfo::default()).is_ok()); // Two sheets at the same offset are refused outright. let mut dup = bof(0x0005); @@ -625,6 +628,112 @@ fn a_format_shorter_than_its_count_keeps_its_characters() { assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); } +/// A workbook whose "Report" sheet holds legacy formulas over ranges of "Data": Data!A1 = 11, +/// B1:G1 = 1, A2 = 5, A3 = 7, and Report!B1:G1 filled, so a formula in Report!A1 that spilled +/// would be blocked. +fn legacy_formulas_book() -> Vec { + let area3d = |r0: u16, r1: u16, c0: u16, c1: u16| cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[r0, r1, c0, c1])]); + let a2a3 = area3d(1, 2, 0, 0); + let sum = |inner: &[u8]| cat(&[inner, &[0x22, 1], &4u16.to_le_bytes()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(p1, "Data"), + &boundsheet(p2, "Report"), + &rec(0x01AE, &u16s(&[2, 0x0401])), + &rec(0x0017, &cat(&[&u16s(&[1]), &u16s(&[0, 0, 0])])), + &eof(), + ]) + }; + let number = |r: u16, c: u16, n: f64| rec(0x0203, &cat(&[&cell(r, c, 0), &n.to_le_bytes()])); + let mut data = bof(0x0010); + data.extend(number(0, 0, 11.0)); + for c in 1..7 { + data.extend(number(0, c, 1.0)); + } + data.extend(number(1, 0, 5.0)); + data.extend(number(2, 0, 7.0)); + data.extend(eof()); + let shared = cat(&[&a2a3, &ptg_int(10), &[0x05]]); + let array = cat(&[&a2a3, &ptg_int(2), &[0x05]]); + let single_array = sum(&cat(&[&a2a3, &ptg_int(2), &[0x05]])); + let mut report = bof(0x0010); + for c in 1..7 { + report.extend(number(0, c, 0.0)); + } + report.extend(cat(&[ + // A1 = Data!A1:G1, A2 = +Data!A1:G1 (a title row repeated from another sheet). + &formula(0, 0, num(11.0), &area3d(0, 0, 0xC000, 6 | 0xC000)), + &formula(1, 0, num(11.0), &cat(&[&area3d(0, 0, 0xC000, 6 | 0xC000), &[0x12]])), + // C2:C3 share Data!$A$2:$A$3*10. + &formula(1, 2, num(50.0), &exp(1, 2)), + &rec(0x04BC, &cat(&[&u16s(&[1, 2]), &[2, 2, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(2, 2, num(70.0), &exp(1, 2)), + // D2:D3 = {Data!$A$2:$A$3*2}, entered with Ctrl+Shift+Enter. + &formula(1, 3, num(10.0), &exp(1, 3)), + &rec(0x0221, &cat(&[&u16s(&[1, 2]), &[3, 3], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])), + &formula(2, 3, num(14.0), &exp(1, 3)), + // H1 = {SUM(Data!$A$2:$A$3*2)}, a one-cell Ctrl+Shift+Enter array. + &formula(0, 7, num(24.0), &exp(0, 7)), + &rec(0x0221, &cat(&[&u16s(&[0, 0]), &[7, 7], &[0; 6], &(single_array.len() as u16).to_le_bytes(), &single_array])), + // H2 = MAX(SUM(Data!$A$2:$A$3/12),0), H3 = SUM(Data!$A$2:$A$3), H4 = SQRT(Data!$A$2:$A$3). + &formula(1, 7, num(5.0 / 12.0), &cat(&[&sum(&cat(&[&a2a3, &ptg_int(12), &[0x06]])), &ptg_int(0), &[0x22, 2], &7u16.to_le_bytes()])), + &formula(2, 7, num(12.0), &sum(&a2a3)), + &formula(3, 7, special(2, 0x0F), &cat(&[&a2a3, &[0x21], &20u16.to_le_bytes()])), + &eof(), + ])); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + data.len() as u32; + cat(&[&globals(p1, p2), &data, &report]) +} + +#[test] +fn legacy_formulas_read_with_implicit_intersection() { + let bytes = compound("Workbook", &legacy_formulas_book()); + let formula = |wb: &gridcraft_model::Workbook, a1: &str| { + let f = wb.sheets[1].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.clone()).unwrap(); + (f.text.clone(), f.array) + }; + let range = |a1: &str| RangeRef::parse(a1); + let (wb, _) = read_xls(&bytes).unwrap(); + // Every .xls formula is legacy: a range where one value is expected is intersected. + assert_eq!(formula(&wb, "A1"), ("@Data!A1:G1".into(), None)); + assert_eq!(formula(&wb, "A2"), ("+@Data!A1:G1".into(), None)); + // Shared formulas too, each cell on its own. + assert_eq!(formula(&wb, "C2"), ("@Data!$A$2:$A$3*10".into(), None)); + assert_eq!(formula(&wb, "C3"), ("@Data!$A$2:$A$3*10".into(), None)); + // Ctrl+Shift+Enter arrays, one cell or several, stay arrays and are not intersected. + assert_eq!(formula(&wb, "D2"), ("Data!$A$2:$A$3*2".into(), range("D2:D3"))); + assert_eq!(formula(&wb, "H1"), ("SUM(Data!$A$2:$A$3*2)".into(), range("H1"))); + // An operation inside SUM takes values; SUM of the range itself is left alone. + assert_eq!(formula(&wb, "H2"), ("MAX(SUM(@Data!$A$2:$A$3/12),0)".into(), None)); + assert_eq!(formula(&wb, "H3"), ("SUM(Data!$A$2:$A$3)".into(), None)); + assert_eq!(formula(&wb, "H4"), ("SQRT(@Data!$A$2:$A$3)".into(), None)); + + // The caller's function facts decide: here SQRT takes its argument as a whole. + let library = crate::FunctionInfo { is_builtin: |_| true, is_scalar_arg: |n, _| n != "SQRT" }; + let (custom, _) = crate::read_xls_with(&bytes, library).unwrap(); + assert_eq!(formula(&custom, "H4"), ("SQRT(Data!$A$2:$A$3)".into(), None)); + assert_eq!(formula(&custom, "A1"), ("@Data!A1:G1".into(), None)); + + // Saved as .xlsx, they stay legacy: no dynamic-array metadata, no _xlfn.SINGLE, and they read + // back as they were opened. + let saved = write_xlsx(&wb).unwrap(); + let mut sheet = String::new(); + std::io::Read::read_to_string( + &mut zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap().by_name("xl/worksheets/sheet2.xml").unwrap(), + &mut sheet, + ) + .unwrap(); + assert!(!sheet.contains("cm=") && !sheet.contains("SINGLE"), "{sheet}"); + assert!(sheet.contains("Data!A1:G1") && sheet.contains("MAX(SUM(Data!$A$2:$A$3/12),0)"), "{sheet}"); + let (back, _) = read_xlsx(&saved).unwrap(); + for a1 in ["A1", "A2", "C2", "C3", "D2", "H1", "H2", "H3", "H4"] { + assert_eq!(formula(&back, a1), formula(&wb, a1), "{a1}"); + } +} + fn one_sheet_with(sheet_records: &[u8]) -> Vec { let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &eof()]); let sheet = cat(&[&bof(0x0010), sheet_records, &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 5d06d54a0..512144980 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -134,6 +134,8 @@ pub struct Out { pub kept: crate::passthrough::KeptOut, /// Decides which formulas need dynamic-array metadata. pub functions: FunctionInfo, + /// Defined names analysed for implicit intersection, shared by every formula written. + pub(crate) names: crate::array_formula::NameCache, /// The code of the Python cells written so far, in order: script `i` of the Python part. pub python_scripts: Vec, } @@ -271,6 +273,7 @@ pub fn write_xlsx_named_with(wb: &Workbook, kind: XlsxKind, book_name: Option<&s cell_pictures: Default::default(), kept: Default::default(), functions, + names: Default::default(), python_scripts: vec![], }; out.kept.reserve_vml_blocks(wb); diff --git a/crates/xlsx/src/xls.rs b/crates/xlsx/src/xls.rs index 89f4a31f7..1c9285bae 100644 --- a/crates/xlsx/src/xls.rs +++ b/crates/xlsx/src/xls.rs @@ -10,6 +10,9 @@ //! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a //! warning. //! Encrypted workbooks and pre-BIFF8 versions are refused. +//! +//! Every formula in the file is a legacy (pre-dynamic-array) one, so it is read with Excel's +//! implicit `@` (see `array_formula.rs`), like an `.xlsx` formula saved without `cm`. mod formula; mod ftab; @@ -24,7 +27,7 @@ use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineIn use crate::cfb::Compound; use crate::tables::{col_width_to_px, pt_to_px}; -use crate::{IoError, ReadReport}; +use crate::{FunctionInfo, IoError, ReadReport}; use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; use records::{Cursor, Record, Records}; @@ -141,13 +144,20 @@ struct Globals { precision_as_displayed: bool, } -/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. +/// Reads an Excel 97-2003 workbook, with the default [`FunctionInfo`]. Inspect the report's +/// warnings for what was skipped. pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - read_xls_with(bytes, Budget::default()) + read_xls_budgeted(bytes, Budget::default(), FunctionInfo::default()) } -/// [`read_xls`] with an explicit resource budget. -pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> { +/// [`read_xls`] with the function library's facts, which decide where the legacy formulas get +/// Excel's implicit `@`. +pub fn read_xls_with(bytes: &[u8], functions: FunctionInfo) -> Result<(Workbook, ReadReport), IoError> { + read_xls_budgeted(bytes, Budget::default(), functions) +} + +/// [`read_xls_with`] with an explicit resource budget. +pub(crate) fn read_xls_budgeted(bytes: &[u8], mut budget: Budget, functions: FunctionInfo) -> Result<(Workbook, ReadReport), IoError> { let cf = Compound::open(bytes).map_err(invalid)?; let stream = match cf.root_stream("Workbook").map_err(invalid)? { Some(s) => s, @@ -201,6 +211,10 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); + // Every .xls formula is a legacy (pre-dynamic-array) one: read with Excel's implicit `@`, like + // an .xlsx formula without `cm`. Ctrl+Shift+Enter arrays are read as they are. + let legacy = wb.sheets.iter().enumerate().map(|(si, s)| (si, legacy_cells(s))).collect(); + crate::array_formula::apply_legacy(&mut wb, legacy, functions); wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); // The overview comes first. report.warnings.insert( @@ -210,6 +224,11 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo Ok((wb, report)) } +/// The cells holding a formula that is not a Ctrl+Shift+Enter array. +fn legacy_cells(sheet: &Sheet) -> Vec { + sheet.cells.iter().filter(|(_, c)| c.formula.as_ref().is_some_and(|f| f.array.is_none())).map(|(at, _)| at).collect() +} + fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { if rec.id != BOF { return Err(invalid("a substream does not start with a BOF record")); @@ -463,7 +482,9 @@ impl SheetState<'_> { self.unsupported("syntax GridCraft can't parse yet"); return; } - f.array = array.filter(|r| !r.is_single()); + // A one-cell Ctrl+Shift+Enter array keeps its range, as when an .xlsx is read: + // it evaluates as an array, not with implicit intersection. + f.array = array; if let Some(cell) = self.sheet.cells.get_mut(pos) { cell.formula = Some(Arc::new(f)); } else { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 12597f313..40942a539 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -92,7 +92,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -161,6 +161,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) | | 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) | | 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) |