diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index eb981ef..ed92ded 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -91,6 +91,16 @@ fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String { } pub fn struct_ref(s: &StructRef) -> String { + write_struct_ref(s, true) +} + +/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files +/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`). +pub fn struct_ref_file(s: &StructRef) -> String { + write_struct_ref(s, false) +} + +fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { StructItem::All => "#All", @@ -99,7 +109,7 @@ pub fn struct_ref(s: &StructRef) -> String { StructItem::Totals => "#Totals", StructItem::ThisRow => "#This Row", }; - let this_row = s.specifiers == [StructItem::ThisRow]; + let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow]; let mut out = s.table.clone(); let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':')); match (&s.col_start, &s.col_end) { diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 459364e..ef26387 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -82,6 +82,9 @@ fn expr_to_file(e: Expr) -> String { Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), + // Nor the `[@Col]` shorthand: files spell it `[[#This Row],[Col]]`, or Excel repairs + // the file and drops the table. Carried as a name, which prints verbatim. + Expr::Struct(st) => Expr::Name(gridcraft_formula::printer::struct_ref_file(&st)), // Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which // every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is // not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.) @@ -152,6 +155,20 @@ mod tests { assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]"); } + #[test] + fn this_row_long_form() { + // `[@Col]` is only Excel's display shorthand; a file holding it is repaired by Excel, + // which drops the table. + assert_eq!(to_file(&Formula::new("Sales[@Units]*Sales[@[Unit Price]]")), "Sales[[#This Row],[Units]]*Sales[[#This Row],[Unit Price]]"); + assert_eq!(text_to_file("=[@Units]&COUNTA(Sales[@])"), "[[#This Row],[Units]]&COUNTA(Sales[#This Row])"); + assert_eq!(text_to_file("=SUM(Sales[Units])+[#Totals]"), "SUM(Sales[Units])+[#Totals]"); + for f in ["Sales[@Units]*[@[Unit Price]]", "Sales[@]", "[@[Unit Price]]+1"] { + let file = to_file(&Formula::new(f)); + assert!(!file.contains("[@"), "{file}"); + assert_eq!(from_file(&file), f); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index 0491efd..45764e4 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -200,6 +200,43 @@ fn calculated_table_column_keeps_formula_and_cell_value() { assert_eq!(sh.value(at("B2")), Value::Number(6.0)); } +#[test] +fn this_row_references_are_written_in_long_form() { + 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("Data[@Input]*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("[@Input]*2".into()) }, + ], + style: "TableStyleLight1".into(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + }); + 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, "B2").child("f").unwrap().text, "Data[[#This Row],[Input]]*2"); + 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, "[[#This Row],[Input]]*2"); + let (back, _) = read_xlsx(&bytes).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("[@Input]*2")); + assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().text, "Data[@Input]*2"); +} + /// 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() {