mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 12:46:48 -06:00
Preserve array-sensitive XLSX formulas (#64)
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! 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 saved = self.locals.len();
|
||||
let mut affected = false;
|
||||
for pair in args[..args.len() - 1].chunks_exact(2) {
|
||||
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 = args.last().map(|e| self.expr(e, sheet, array_context, depth)).unwrap_or_default();
|
||||
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 chart;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
|
||||
@@ -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 {
|
||||
@@ -581,9 +582,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user