Merge pull request #83 from harrisonoconnorhover/codex/nonspilling-array-export
Preserve array evaluation for non-spilling XLSX formulas Rebased onto main with a maintainer fix-up (LET bindings via split_last/as_chunks for clippy, no slice indexing); the contributor's branch does not allow maintainer edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
59c8e36d36
|
|
@ -0,0 +1,220 @@
|
|||
//! Export-time implicit-intersection analysis, without evaluating the workbook.
|
||||
//!
|
||||
//! A range is harmless in SUM, but not in SQRT or arithmetic. Keep the result's shape separate
|
||||
//! from affected operations inside it: SUM(range * range) returns a scalar yet needs array
|
||||
//! evaluation. Legacy array-aware contexts such as SUMPRODUCT already preserve those operations.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_formula::{BinOp, Expr, SheetSel, StructItem, UnOp};
|
||||
use gridcraft_model::{Formula, Workbook};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Shape {
|
||||
array: bool,
|
||||
affected: bool,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn array() -> Self {
|
||||
Self { array: true, affected: false }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula) -> bool {
|
||||
let Some(expr) = formula.expr_arc() else { return false };
|
||||
let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() };
|
||||
let shape = scan.expr(&expr, sheet, false, 0);
|
||||
shape.array || shape.affected
|
||||
}
|
||||
|
||||
struct Scan<'a> {
|
||||
wb: &'a Workbook,
|
||||
at: CellRef,
|
||||
remaining: usize,
|
||||
locals: Vec<(String, Shape)>,
|
||||
}
|
||||
|
||||
impl Scan<'_> {
|
||||
fn expr(&mut self, e: &Expr, sheet: usize, array_context: bool, depth: usize) -> Shape {
|
||||
// Names can be cyclic, and a short chain of repeated names can expand exponentially.
|
||||
// Preserve array evaluation when analysis cannot safely finish.
|
||||
if depth >= 64 || self.remaining == 0 {
|
||||
return Shape { array: true, affected: true };
|
||||
}
|
||||
self.remaining -= 1;
|
||||
match e {
|
||||
Expr::Ref(r) => Shape { array: !r.range().is_single() || matches!(r.sheet, SheetSel::Span(_, _)), affected: false },
|
||||
Expr::Array(rows) => Shape { array: rows.len() > 1 || rows.first().is_some_and(|r| r.len() > 1), affected: false },
|
||||
Expr::Name(n) => {
|
||||
if let Some((_, shape)) = self.locals.iter().rev().find(|(key, _)| key.eq_ignore_ascii_case(n)) {
|
||||
return *shape;
|
||||
}
|
||||
let (scope, bare) = match n.rsplit_once('!') {
|
||||
Some((s, name)) => (self.wb.sheet_index(&s.trim_matches('\'').replace("''", "'")).unwrap_or(sheet), name),
|
||||
None => (sheet, n.as_str()),
|
||||
};
|
||||
if let Some(def) = self.wb.name(bare, scope) {
|
||||
return match gridcraft_formula::parse(def.formula.trim_start_matches('=')) {
|
||||
Ok(expr) => self.expr(&expr, def.scope.unwrap_or(scope), array_context, depth + 1),
|
||||
Err(_) => Shape::array(),
|
||||
};
|
||||
}
|
||||
Shape { array: self.wb.table(bare).is_some(), affected: false }
|
||||
}
|
||||
Expr::Struct(r) => {
|
||||
let table = if r.table.is_empty() {
|
||||
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)))
|
||||
};
|
||||
let one_row = r.specifiers.iter().any(|s| matches!(s, StructItem::ThisRow | StructItem::Headers | StructItem::Totals));
|
||||
let one_col = match (&r.col_start, &r.col_end) {
|
||||
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
|
||||
(Some(_), None) => true,
|
||||
_ => table.is_some_and(|t| t.columns.len() == 1),
|
||||
};
|
||||
Shape { array: !(one_row && one_col), affected: false }
|
||||
}
|
||||
Expr::Paren(x) => self.expr(x, sheet, array_context, depth + 1),
|
||||
Expr::Unary(op, x) => {
|
||||
let child = self.expr(x, sheet, array_context, depth + 1);
|
||||
match op {
|
||||
UnOp::At => Shape { array: false, affected: child.affected },
|
||||
UnOp::Spill => Shape { array: true, affected: child.affected },
|
||||
_ => Shape { array: child.array, affected: child.affected || (child.array && !array_context) },
|
||||
}
|
||||
}
|
||||
Expr::Binary(op, a, b) => {
|
||||
let a = self.expr(a, sheet, array_context, depth + 1);
|
||||
let b = self.expr(b, sheet, array_context, depth + 1);
|
||||
let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect);
|
||||
let array = reference || a.array || b.array;
|
||||
Shape { array, affected: a.affected || b.affected || (!reference && array && !array_context) }
|
||||
}
|
||||
Expr::Call(name, args) => self.call(name, args, sheet, array_context, depth + 1),
|
||||
Expr::Invoke(_, _) => Shape { array: true, affected: false },
|
||||
_ => Shape::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, name: &str, args: &[Expr], sheet: usize, array_context: bool, depth: usize) -> Shape {
|
||||
if name == "LET"
|
||||
&& args.len() >= 3
|
||||
&& let Some((last, bindings)) = args.split_last()
|
||||
{
|
||||
let saved = self.locals.len();
|
||||
let mut affected = false;
|
||||
for pair in bindings.as_chunks::<2>().0 {
|
||||
if let [Expr::Name(n), value] = pair {
|
||||
let shape = self.expr(value, sheet, array_context, depth);
|
||||
affected |= shape.affected;
|
||||
self.locals.push((n.clone(), shape));
|
||||
}
|
||||
}
|
||||
let mut result = self.expr(last, sheet, array_context, depth);
|
||||
result.affected |= affected;
|
||||
self.locals.truncate(saved);
|
||||
return result;
|
||||
}
|
||||
let legacy_array =
|
||||
matches!(name, "SUMPRODUCT" | "MMULT" | "MINVERSE" | "MDETERM" | "TRANSPOSE" | "FREQUENCY" | "LINEST" | "LOGEST" | "TREND" | "GROWTH");
|
||||
let mut affected = false;
|
||||
let mut lifted = false;
|
||||
let mut shapes = Vec::with_capacity(args.len().min(256));
|
||||
if args.len() > 255 {
|
||||
return Shape { array: true, affected: true };
|
||||
}
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
let context = array_context || legacy_array || (name == "INDEX" && i == 0);
|
||||
let shape = self.expr(arg, sheet, context, depth);
|
||||
affected |= shape.affected;
|
||||
if shape.array && scalar_arg(name, i) {
|
||||
lifted = true;
|
||||
affected |= !context;
|
||||
}
|
||||
shapes.push(shape);
|
||||
}
|
||||
let first_array = shapes.first().is_some_and(|s| s.array);
|
||||
let array = match name {
|
||||
"INDIRECT" => match args.first() {
|
||||
Some(Expr::Text(text)) if !matches!(args.get(1), Some(Expr::Bool(false))) => {
|
||||
gridcraft_formula::parse(text).map(|e| self.expr(&e, sheet, array_context, depth).array).unwrap_or(true)
|
||||
}
|
||||
_ => true,
|
||||
},
|
||||
"OFFSET" => {
|
||||
// Omitted dimensions retain the source reference's shape; unknown dimensions
|
||||
// might become multiple cells on a later calculation.
|
||||
if args.len() <= 3 { first_array } else { !args.get(3).is_some_and(one) || args.get(4).map_or(first_array, |e| !one(e)) }
|
||||
}
|
||||
"INDEX" => {
|
||||
let row = args.get(1);
|
||||
let col = args.get(2);
|
||||
!row.is_some_and(positive) || col.is_some_and(|e| !positive(e)) || (col.is_none() && !args.first().is_some_and(single_column))
|
||||
}
|
||||
"ROW" | "COLUMN" => {
|
||||
affected |= first_array && !array_context;
|
||||
first_array
|
||||
}
|
||||
"IF" | "IFERROR" | "IFNA" | "IFS" | "SWITCH" | "CHOOSE" => shapes.iter().any(|s| s.array),
|
||||
"XLOOKUP" => {
|
||||
lifted
|
||||
|| match (args.get(1), args.get(2)) {
|
||||
(Some(Expr::Ref(lookup)), Some(Expr::Ref(result))) => {
|
||||
if lookup.range().width() == 1 {
|
||||
result.range().width() > 1
|
||||
} else {
|
||||
result.range().height() > 1
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
"FILTER" | "SORT" | "SORTBY" | "UNIQUE" | "SEQUENCE" | "RANDARRAY" | "VSTACK" | "HSTACK" | "TOROW" | "TOCOL" | "WRAPROWS"
|
||||
| "WRAPCOLS" | "TAKE" | "DROP" | "CHOOSEROWS" | "CHOOSECOLS" | "EXPAND" | "GROUPBY" | "PIVOTBY" | "TRANSPOSE" | "MMULT" | "MINVERSE"
|
||||
| "FREQUENCY" | "LINEST" | "LOGEST" | "TREND" | "GROWTH" | "MAP" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" | "ANCHORARRAY" => true,
|
||||
_ => lifted || self.wb.name(name, sheet).is_some(),
|
||||
};
|
||||
Shape { array, affected }
|
||||
}
|
||||
}
|
||||
|
||||
fn positive(e: &Expr) -> bool {
|
||||
matches!(e, Expr::Number(n) if *n >= 1.0)
|
||||
}
|
||||
|
||||
fn one(e: &Expr) -> bool {
|
||||
matches!(e, Expr::Number(n) if *n == 1.0)
|
||||
}
|
||||
|
||||
fn single_column(e: &Expr) -> bool {
|
||||
matches!(e, Expr::Ref(r) if r.range().width() == 1)
|
||||
}
|
||||
|
||||
/// Legacy reference/array-taking parameters. Other parameters consume a single value.
|
||||
/// This is file-format policy, not a second evaluator: unknown results are never calculated.
|
||||
fn scalar_arg(name: &str, i: usize) -> bool {
|
||||
match name {
|
||||
"SUM" | "AVERAGE" | "AVERAGEA" | "MIN" | "MINA" | "MAX" | "MAXA" | "COUNT" | "COUNTA" | "COUNTBLANK" | "PRODUCT" | "SUMSQ" | "MEDIAN"
|
||||
| "MODE" | "MODE.SNGL" | "STDEV" | "STDEV.S" | "STDEV.P" | "STDEVP" | "VAR" | "VAR.S" | "VAR.P" | "VARP" | "AVEDEV" | "DEVSQ" | "GEOMEAN"
|
||||
| "HARMEAN" | "KURT" | "SKEW" | "SKEW.P" | "AND" | "OR" | "XOR" | "SUMPRODUCT" | "SUMX2MY2" | "SUMX2PY2" | "SUMXMY2" | "CORREL" | "COVAR"
|
||||
| "COVARIANCE.P" | "COVARIANCE.S" | "PEARSON" | "RSQ" | "SLOPE" | "INTERCEPT" | "STEYX" | "ROWS" | "COLUMNS" | "AREAS" | "ROW" | "COLUMN"
|
||||
| "TRANSPOSE" | "ISREF" | "MMULT" | "MINVERSE" | "MDETERM" | "FREQUENCY" | "CONCAT" | "VSTACK" | "HSTACK" | "ANCHORARRAY" => false,
|
||||
"SUMIF" | "AVERAGEIF" => i == 1,
|
||||
"COUNTIF" => i != 0,
|
||||
"SUMIFS" | "AVERAGEIFS" | "MINIFS" | "MAXIFS" => i > 0 && i.is_multiple_of(2),
|
||||
"COUNTIFS" => i % 2 == 1,
|
||||
"INDEX" | "LARGE" | "SMALL" | "PERCENTILE" | "PERCENTILE.INC" | "PERCENTILE.EXC" | "QUARTILE" | "QUARTILE.INC" | "QUARTILE.EXC" | "IRR"
|
||||
| "MIRR" | "RANK" | "RANK.EQ" | "RANK.AVG" => i != if name.starts_with("RANK") { 1 } else { 0 },
|
||||
"VLOOKUP" | "HLOOKUP" | "MATCH" | "XMATCH" => i != 1,
|
||||
"LOOKUP" | "NPV" | "XNPV" | "SUBTOTAL" => i == 0,
|
||||
"XLOOKUP" => i != 1 && i != 2,
|
||||
"XIRR" => i > 1,
|
||||
"OFFSET" => i != 0,
|
||||
"AGGREGATE" | "TEXTJOIN" => i < 2,
|
||||
"FILTER" | "SORTBY" => i > 1,
|
||||
"SORT" | "UNIQUE" | "TAKE" | "DROP" | "CHOOSECOLS" | "CHOOSEROWS" | "TOROW" | "TOCOL" | "WRAPROWS" | "WRAPCOLS" | "EXPAND" => i != 0,
|
||||
"LINEST" | "LOGEST" | "TREND" | "GROWTH" => i > 1,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod csv;
|
||||
|
|
|
|||
|
|
@ -291,7 +291,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
|||
let cell = sheet.cells.get(*c);
|
||||
let ov = overrides.get(c);
|
||||
let ex = extra.get(c);
|
||||
write_cell(&mut s, *c, cell, ov, ex, dynamic.get(c), &sid, out);
|
||||
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))
|
||||
});
|
||||
write_cell(&mut s, *c, cell, ov, ex, array.as_ref(), &sid, out);
|
||||
}
|
||||
s.push_str("</row>");
|
||||
}
|
||||
|
|
@ -564,7 +568,8 @@ fn write_cell(
|
|||
Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value),
|
||||
v => v,
|
||||
};
|
||||
if formula.is_some() && dynamic.is_some() {
|
||||
// A non-spilling formula may still need array evaluation to avoid implicit intersection.
|
||||
if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) {
|
||||
s.push_str(" cm=\"1\"");
|
||||
out.dynamic_arrays = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
//! Array evaluation must survive export even when a formula returns one value.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, DefinedName, Formula, Sheet, Table, TableColumn, TotalsFn, Workbook};
|
||||
|
||||
use crate::package::Package;
|
||||
use crate::xml::El;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
fn at(s: &str) -> CellRef {
|
||||
CellRef::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn range(s: &str) -> RangeRef {
|
||||
RangeRef::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn formula(text: &str, value: f64) -> Cell {
|
||||
let mut cell = Cell::formula(Formula::new(text));
|
||||
cell.value = Value::Number(value);
|
||||
cell
|
||||
}
|
||||
|
||||
fn cell<'a>(sheet: &'a El, address: &str) -> &'a El {
|
||||
sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap()
|
||||
}
|
||||
|
||||
fn assert_scalar(sheet: &El, address: &str) {
|
||||
let c = cell(sheet, address);
|
||||
assert_eq!(c.attr("cm"), None, "scalar formula must not have dynamic metadata at {address}");
|
||||
let f = c.child("f").unwrap();
|
||||
assert_eq!(f.attr("t"), None);
|
||||
assert_eq!(f.attr("ref"), None);
|
||||
}
|
||||
|
||||
fn assert_dynamic(sheet: &El, address: &str, reference: &str) {
|
||||
let c = cell(sheet, address);
|
||||
assert_eq!(c.attr("cm"), Some("1"), "array evaluation metadata at {address}");
|
||||
let f = c.child("f").unwrap();
|
||||
assert_eq!(f.attr("t"), Some("array"));
|
||||
assert_eq!(f.attr("ref"), Some(reference));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonspilling_formulas_preserve_array_evaluation_and_metadata() {
|
||||
let mut wb = Workbook::new();
|
||||
let data = wb.sheet_mut(0).unwrap();
|
||||
data.name = "Data".into();
|
||||
for (address, value) in [("A1", 1.0), ("A2", 4.0), ("A3", 9.0), ("B1", 2.0), ("B2", 3.0), ("B3", 4.0)] {
|
||||
data.cells.set(at(address), Cell::value(Value::Number(value)));
|
||||
}
|
||||
let cases = [("B107", "SUM(Data!A1:A3*Data!B1:B3)", 50.0), ("B108", "SUM(SQRT(Data!A1:A3))", 6.0), ("B109", "SUM(ABS(Data!A1:A3))", 14.0)];
|
||||
let mut results = Sheet::new("Results");
|
||||
for (address, text, value) in cases {
|
||||
results.cells.set(at(address), formula(text, value));
|
||||
}
|
||||
results.cells.set(at("C107"), Cell::value(Value::text("neighbor")));
|
||||
assert!(results.spill_ranges.is_empty());
|
||||
wb.sheets.push(Arc::new(results));
|
||||
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet2.xml").unwrap().unwrap();
|
||||
for (address, text, _) in cases {
|
||||
assert_dynamic(&sheet, address, address);
|
||||
assert_eq!(cell(&sheet, address).child("f").unwrap().text, text);
|
||||
}
|
||||
assert_eq!(cell(&sheet, "C107").attr("cm"), None);
|
||||
|
||||
let metadata = pkg.read_xml("xl/metadata.xml").unwrap().unwrap();
|
||||
let kind = metadata.path(&["metadataTypes", "metadataType"]).unwrap();
|
||||
assert_eq!(kind.attr("name"), Some("XLDAPR"));
|
||||
assert_eq!(kind.attr("cellMeta"), Some("1"));
|
||||
let dynamic = metadata.path(&["futureMetadata", "bk", "extLst", "ext", "dynamicArrayProperties"]).unwrap();
|
||||
assert_eq!(dynamic.attr("fDynamic"), Some("1"));
|
||||
assert_eq!(dynamic.attr("fCollapsed"), Some("0"));
|
||||
let record = metadata.path(&["cellMetadata", "bk", "rc"]).unwrap();
|
||||
assert_eq!((record.attr("t"), record.attr("v")), (Some("1"), Some("0")));
|
||||
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
|
||||
assert!(types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")
|
||||
&& e.attr("ContentType") == Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml")));
|
||||
assert!(pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata" && r.target == "xl/metadata.xml"));
|
||||
|
||||
let (back, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
||||
let results = back.sheet(1).unwrap();
|
||||
for (address, text, value) in cases {
|
||||
let c = results.cell(at(address)).unwrap();
|
||||
let f = c.formula.as_ref().unwrap();
|
||||
assert_eq!(f.text, text);
|
||||
assert_eq!(f.array, None, "dynamic formula must not become legacy CSE");
|
||||
assert_eq!(c.value, Value::Number(value));
|
||||
}
|
||||
assert_eq!(results.value(at("C107")), Value::text("neighbor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_spills_and_legacy_cse_keep_distinct_ranges() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let mut legacy = Formula::new("ROW(A1:A2)");
|
||||
legacy.array = Some(range("A1:A2"));
|
||||
sh.cells.set(at("A1"), Cell::formula(legacy));
|
||||
sh.cells.set(at("A2"), Cell::value(Value::Number(2.0)));
|
||||
sh.cells.set(at("B1"), formula("SEQUENCE(3)", 1.0));
|
||||
sh.spill_ranges.insert(at("B1"), range("B1:B3"));
|
||||
sh.spill.insert(at("B2"), Value::Number(2.0));
|
||||
sh.spill.insert(at("B3"), Value::Number(3.0));
|
||||
sh.cells.set(at("C1"), formula("SUM(B1:B3)", 6.0));
|
||||
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
|
||||
let legacy = cell(&sheet, "A1").child("f").unwrap();
|
||||
assert_eq!((legacy.attr("t"), legacy.attr("ref")), (Some("array"), Some("A1:A2")));
|
||||
assert_dynamic(&sheet, "B1", "B1:B3");
|
||||
assert_scalar(&sheet, "C1");
|
||||
assert_eq!(cell(&sheet, "B2").child("v").unwrap().text, "2");
|
||||
assert_eq!(cell(&sheet, "B2").attr("cm"), None);
|
||||
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
let sh = back.sheet(0).unwrap();
|
||||
assert_eq!(sh.cell(at("A1")).unwrap().formula.as_ref().unwrap().array, Some(range("A1:A2")));
|
||||
assert_eq!(sh.value(at("A2")), Value::Number(2.0));
|
||||
assert_eq!(sh.cell(at("B1")).unwrap().formula.as_ref().unwrap().array, None);
|
||||
assert!(sh.cell(at("B2")).is_none());
|
||||
assert!(sh.cell(at("B3")).is_none());
|
||||
assert_eq!(sh.value(at("C1")), Value::Number(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.name = "Data".into();
|
||||
sh.cells.set(at("A1"), Cell::value(Value::Number(3.0)));
|
||||
for (address, text, value) in [("C1", "1+2", 3.0), ("C2", "Data!A1", 3.0), ("C3", "SUM(A1:A3)", 3.0), ("C4", "A1*2", 6.0)] {
|
||||
sh.cells.set(at(address), formula(text, value));
|
||||
}
|
||||
for legacy in [false, true] {
|
||||
if legacy {
|
||||
let mut f = Formula::new("SUM(A1:A2)");
|
||||
f.array = Some(range("B1"));
|
||||
wb.sheet_mut(0).unwrap().cells.set(at("B1"), Cell::formula(f));
|
||||
}
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
assert!(!pkg.has("xl/metadata.xml"));
|
||||
assert!(!pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata"));
|
||||
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
|
||||
assert!(!types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")));
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
|
||||
for address in ["C1", "C2", "C3", "C4"] {
|
||||
assert_scalar(&sheet, address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_table_column_keeps_formula_and_cell_value() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
|
||||
sh.cells.set(at("B2"), formula("A2*2", 6.0));
|
||||
sh.tables.push(Table {
|
||||
id: 1,
|
||||
name: "Data".into(),
|
||||
range: range("A1:B2"),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: vec![
|
||||
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
|
||||
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("A2*2".into()) },
|
||||
],
|
||||
style: "TableStyleLight1".into(),
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
});
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
assert_scalar(&sheet, "B2");
|
||||
assert_eq!(cell(&sheet, "B1").attr("cm"), None, "table headers are text");
|
||||
let table = pkg.read_xml("xl/tables/table1.xml").unwrap().unwrap();
|
||||
let column = table.child("tableColumns").unwrap().kids("tableColumn").nth(1).unwrap();
|
||||
assert_eq!(column.child("calculatedColumnFormula").unwrap().text, "A2*2");
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
let sh = back.sheet(0).unwrap();
|
||||
assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("A2*2"));
|
||||
assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().array, None);
|
||||
assert_eq!(sh.value(at("B2")), Value::Number(6.0));
|
||||
}
|
||||
|
||||
/// Compare both cell and formula metadata, collecting all boundaries in the group on failure.
|
||||
fn check_formula_metadata(mut wb: Workbook, sheet_index: usize, cases: &[(&str, bool)]) {
|
||||
for (i, (text, _)) in cases.iter().enumerate() {
|
||||
let f = Formula::new(text);
|
||||
assert!(f.expr().is_some(), "test formula must parse: {text}");
|
||||
wb.sheet_mut(sheet_index).unwrap().cells.set(CellRef::new(i as u32 + 100, 5), Cell::formula(f));
|
||||
}
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml(&format!("xl/worksheets/sheet{}.xml", sheet_index + 1)).unwrap().unwrap();
|
||||
let mut mismatches = vec![];
|
||||
for (i, (text, dynamic)) in cases.iter().enumerate() {
|
||||
let address = CellRef::new(i as u32 + 100, 5).to_string();
|
||||
let c = cell(&sheet, &address);
|
||||
let f = c.child("f").unwrap();
|
||||
let expected = if *dynamic { (Some("1"), Some("array"), Some(address.as_str())) } else { (None, None, None) };
|
||||
let actual = (c.attr("cm"), f.attr("t"), f.attr("ref"));
|
||||
if actual != expected {
|
||||
mismatches.push(format!("{text}: expected {expected:?}, got {actual:?}"));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_intersection_metadata_respects_function_argument_context() {
|
||||
check_formula_metadata(
|
||||
Workbook::new(),
|
||||
0,
|
||||
&[
|
||||
("SQRT(A1:A3)", true),
|
||||
("ABS(A1:A3)", true),
|
||||
("SUM(A1:A3*B1:B3)", true),
|
||||
("SUM(SQRT(A1:A3))", true),
|
||||
("SUM(A1:A3)", false),
|
||||
("SUMPRODUCT(A1:A3*B1:B3)", false),
|
||||
("SUMPRODUCT(SQRT(A1:A3))", false),
|
||||
("SQRT(@A1:A3)", false),
|
||||
("@A1:A3*2", false),
|
||||
("@(A1:A3*2)", true),
|
||||
("SQRT(INDIRECT(\"A1\"))", false),
|
||||
("SUM(INDIRECT(\"A1:A3\"))", false),
|
||||
("SQRT(INDIRECT(\"A1:A3\"))", true),
|
||||
("SUM(INDIRECT(A1))", false),
|
||||
("SQRT(INDIRECT(A1))", true),
|
||||
("INDEX(A1:B3,1,1)", false),
|
||||
("INDEX(A1:A3,1)", false),
|
||||
("SUM(INDEX(A1:B3,0,1))", false),
|
||||
("SQRT(INDEX(A1:B3,0,1))", true),
|
||||
("SUM(ROW(A1:A3))", true),
|
||||
("SUMPRODUCT(ROW(A1:A3))", false),
|
||||
("OFFSET(A1,0,0,1,1)*2", false),
|
||||
("SUM(OFFSET(A1,0,0,3,1)*2)", true),
|
||||
("SUM(OFFSET(A1,0,0,3,1))", false),
|
||||
("LET(x,A1:A3,SUM(x))", false),
|
||||
("LET(x,A1:A3,SUM(x*2))", true),
|
||||
("XLOOKUP(1,A1:A3,B1:B3)", false),
|
||||
("XLOOKUP(1,A1:A3,B1:C3)", true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_intersection_metadata_resolves_names_in_formula_sheet_scope() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheets.push(Arc::new(Sheet::new("Results")));
|
||||
for (name, scope, text) in [
|
||||
("Rate", None, "2"),
|
||||
("Inputs", None, "Sheet1!$A$1:$A$3"),
|
||||
("Single", None, "Sheet1!$A$1"),
|
||||
("Alias", None, "Inputs"),
|
||||
("Scoped", None, "Sheet1!$A$1:$A$3"),
|
||||
("Scoped", Some(0), "2"),
|
||||
("LocalInputs", Some(1), "Results!$A$1:$A$3"),
|
||||
] {
|
||||
wb.names.push(DefinedName { name: name.into(), scope, formula: text.into(), comment: String::new(), hidden: false });
|
||||
}
|
||||
check_formula_metadata(
|
||||
wb.clone(),
|
||||
0,
|
||||
&[
|
||||
("Rate", false),
|
||||
("SQRT(Rate)", false),
|
||||
("Single*2", false),
|
||||
("Inputs", true),
|
||||
("SUM(Inputs)", false),
|
||||
("SUM(Inputs*2)", true),
|
||||
("SUM(SQRT(Alias))", true),
|
||||
("SQRT(Scoped)", false),
|
||||
],
|
||||
);
|
||||
check_formula_metadata(wb, 1, &[("SQRT(Scoped)", true), ("SUM(LocalInputs)", false), ("LocalInputs*2", true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_names_preserve_array_evaluation_without_unbounded_export_recursion() {
|
||||
let mut wb = Workbook::new();
|
||||
for (name, formula) in [("First", "Second"), ("Second", "First")] {
|
||||
wb.names.push(DefinedName { name: name.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: false });
|
||||
}
|
||||
check_formula_metadata(wb, 0, &[("First", true), ("SUM(First)", true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.tables.push(Table {
|
||||
id: 1,
|
||||
name: "Data".into(),
|
||||
range: range("A1:B3"),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: vec![
|
||||
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
|
||||
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) },
|
||||
],
|
||||
style: "TableStyleLight1".into(),
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
});
|
||||
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
|
||||
sh.cells.set(at("B2"), formula("[@Input]*2", 6.0));
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
assert_scalar(&sheet, "B2");
|
||||
check_formula_metadata(
|
||||
wb,
|
||||
0,
|
||||
&[("Data[@Input]*2", false), ("SUM(Data[Input])", false), ("SUM(Data[Input]*2)", true), ("SQRT(Data[Input])", true)],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV.
|
||||
|
||||
mod array_export;
|
||||
mod csv_tests;
|
||||
mod fixtures;
|
||||
mod malformed;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::sync::Arc;
|
|||
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::package::Package;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
|
|
@ -588,9 +589,15 @@ fn spill_and_calc_errors_are_cached_as_value_errors() {
|
|||
s.cells.set(at("C1"), Cell { value: Value::Error(CellError::Spill), ..Cell::formula(Formula::new("A1:A3*2")) });
|
||||
s.cells.set(at("C2"), Cell { value: Value::Error(CellError::Calc), ..Cell::formula(Formula::new("FILTER(A1:A3,A1:A3>5)")) });
|
||||
s.cells.set(at("C3"), Cell { value: Value::Error(CellError::NA), ..Cell::formula(Formula::new("NA()")) });
|
||||
let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap());
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let (xml, _) = sheet1_xml(&bytes);
|
||||
assert!(!xml.contains("#SPILL!") && !xml.contains("#CALC!"), "{xml}");
|
||||
assert!(xml.contains(r#"<f>A1:A3*2</f><v>#VALUE!</v></c>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<f>_xlfn._xlws.FILTER(A1:A3,A1:A3>5)</f><v>#VALUE!</v></c>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<c r="C3" t="e"><f>NA()</f><v>#N/A</v></c>"#), "{xml}");
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
|
||||
for (address, text, value) in [("C1", "A1:A3*2", "#VALUE!"), ("C2", "_xlfn._xlws.FILTER(A1:A3,A1:A3>5)", "#VALUE!"), ("C3", "NA()", "#N/A")] {
|
||||
let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|row| row.kids("c")).find(|cell| cell.attr("r") == Some(address)).unwrap();
|
||||
assert_eq!(cell.attr("t"), Some("e"));
|
||||
assert_eq!(cell.child("f").unwrap().text, text);
|
||||
assert_eq!(cell.child("v").unwrap().text, value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,13 @@ for display. API, clipboard and XLSX syntax remain stable. Existing names overri
|
|||
|
||||
The revision is based directly on main after #31/#104 and Portuguese interface support (#82)
|
||||
merged. Those locales retain their existing formula fallback; this change adds only Spanish.
|
||||
|
||||
## Preserve array evaluation when exporting formulas (#64)
|
||||
|
||||
GridCraft evaluates ordinary formulas with array semantics even when the final result occupies one cell. XLSX export marks actual spills and formulas whose results may differ under legacy implicit intersection as dynamic, using the spill range or a single-cell array reference. Legacy CSE array ranges keep their existing representation.
|
||||
|
||||
The exporter inspects the parsed formula without evaluating it. It distinguishes range-taking functions such as `SUM` from scalar arguments and operators: `SUM(A1:A3)` and `A1*2` stay ordinary, while `SUM(A1:A3*B1:B3)` and `SQRT(A1:A3)` retain array evaluation. Legacy array-aware contexts such as `SUMPRODUCT` do not need an extra marker. Explicit `@` scalarizes its result without hiding array-sensitive operations inside it.
|
||||
|
||||
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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue