From e82e91b4513b89f26af7053ba7be8c4c6999bbf3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:56:55 -0500 Subject: [PATCH] XLSX: a Python cell without its code keeps its whole cached result as values A PY formula whose script the file doesn't hold (the Python part missing or damaged, or the script over the code budget) keeps only its cached value, but its array range stayed recorded, so the cached values over the rest of its spill were dropped as a formula's spill would be, and nothing filled them again. The range of such a cell is now forgotten when it is read: the anchor and the cells of its spill keep their cached values as plain values, as the file shows them, with no live spill. An ordinary formula's dynamic-array spill is handled as before. --- crates/engine/src/tests/python_files.rs | 25 +++++++++++++++++++++++++ crates/xlsx/src/sheet_read.rs | 22 ++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/python_files.rs b/crates/engine/src/tests/python_files.rs index 5f6d3343c..d540b1bba 100644 --- a/crates/engine/src/tests/python_files.rs +++ b/crates/engine/src/tests/python_files.rs @@ -292,6 +292,31 @@ fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() { assert_eq!(warnings.iter().filter(|w| w.contains("Python part is damaged")).count(), 1, "{warnings:?}"); } +#[test] +fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() { + // A1's cached result spills over A1:A3; C1 is an ordinary dynamic array with a stale cache. + let data = "_xlfn._xlws.PY(0,0)1\ + SEQUENCE(3)1\ + 29\ + 39"; + let missing = excel_shaped(data, None); + let damaged = with_damaged_part(&excel_shaped(data, Some(&["[1, 2, 3]"])), "xl/python/python1.xml", ""); + for (name, bytes) in [("missing.xlsx", missing), ("damaged.xlsx", damaged)] { + let mut s = s(); + open(&mut s, name, &bytes); + assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0), Value::Number(3.0)), "{name}"); + let sh = s.doc().unwrap().wb.active().unwrap(); + for a in ["A1", "A2", "A3"] { + let cell = sh.cell(CellRef::parse(a).unwrap()).unwrap_or_else(|| panic!("{name}: {a} is stored")); + assert!(cell.py.is_none() && cell.formula.is_none(), "{name}: {a} is a plain value"); + } + assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "{name}: no live spill"); + // The ordinary formula fills its spill again, over the stale cached values. + assert_eq!((v(&s, "C2"), v(&s, "C3")), (Value::Number(2.0), Value::Number(3.0)), "{name}"); + assert!(sh.cell(CellRef::parse("C2").unwrap()).is_none(), "{name}: C2 is the formula's spill, not a cell"); + } +} + #[test] fn many_cells_naming_one_long_script_take_from_one_budget() { // 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy. diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 864c97c6d..7417e772a 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -79,6 +79,20 @@ fn push_dynamic(st: &mut RowState, range: RangeRef) { st.dynamic.push(range); } +/// Forgets the range anchored at `pos` (a Python cell whose code the file doesn't hold, which +/// keeps only its cached value): the cached values over the rest of its spill are kept as plain +/// values too, as the file shows them, rather than left for a result that won't come. Charged +/// like [`dynamic_range`]'s scans. +fn forget_dynamic(st: &mut RowState, pos: CellRef) { + if st.dynamic_checks > MAX_DYNAMIC_CHECKS { + return; + } + st.dynamic_checks = st.dynamic_checks.saturating_add(st.dynamic.len() as u64); + if let Some(i) = st.dynamic.iter().rposition(|r| r.start == pos) { + st.dynamic.remove(i); + } +} + const MAX_HYPERLINK_CELLS: u64 = 10_000; /// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is /// copied for every cell of its range, so length × cells must stay bounded whatever the file. @@ -527,7 +541,8 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic)); // A `PY(index,0|1)` formula is a Python cell whose code is a script of the Python part. The // older `PY("…",0|1)` form is read from the formula's own text: the formula parser would fold - // line breaks in the code. A formula naming a script the part lacks keeps only its value. + // line breaks in the code. A formula naming a script the part lacks keeps only its cached + // values, its own and its spill's, as plain values. let py = match (&formula, c.child("f")) { (Some(fm), Some(f)) => crate::python_part::cell_code(&mut cx.python, fm, &f.text), _ => crate::python_part::PyFormula::Not, @@ -535,7 +550,10 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos let (formula, py) = match py { crate::python_part::PyFormula::Not => (formula, None), crate::python_part::PyFormula::Code(py) => (None, Some(Arc::new(py))), - crate::python_part::PyFormula::Missing => (None, None), + crate::python_part::PyFormula::Missing => { + forget_dynamic(st, pos); + (None, None) + } }; let spilled_from = if formula.is_none() && py.is_none() { dynamic_range(st, pos) } else { None }; let value = match spilled_from {