diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 0c09fa2..f6ca944 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -454,7 +454,8 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos } fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { - let text = f.text.trim(); + let model = from_file(f.text.trim()); + let text = model.as_str(); match f.attr("t").unwrap_or("normal") { "shared" => { let si = f.attr_u32("si")?; diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b336096..a58df0b 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -35,10 +35,12 @@ fn cf_op_name(op: CfOperator) -> &'static str { } } -/// Valid table name: letters, digits, `_` and `.`, starting with a letter or `_`. +/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` +/// (Microsoft "Names in formulas" rules). fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { - let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || c == '_' || c == '.' { c } else { '_' }).collect(); - if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') || gridcraft_core::CellRef::parse(&s).is_some() { + let mut s: String = + name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { s = format!("Table{n}"); } let mut out = s.clone(); @@ -56,12 +58,7 @@ fn table_columns(t: &Table) -> Vec { let mut used = HashSet::new(); (0..w) .map(|i| { - let base = t - .columns - .get(i) - .map(|c| c.name.replace(['\r', '\n'], " ")) - .filter(|n| !n.trim().is_empty()) - .unwrap_or_else(|| format!("Column{}", i + 1)); + let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); let mut name = base.clone(); let mut k = 2; while !used.insert(name.to_lowercase()) { diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index d2ecec7..920c8d8 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -869,3 +869,59 @@ fn scroll_position_round_trips_with_and_without_frozen_panes() { let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap()); assert!(xml.contains(r#"topLeftCell="A2""#), "{xml}"); } + +/// Excel stores LET/LAMBDA parameters as `_xlpm.name`; the model shows bare names. +#[test] +fn let_lambda_parameters_drop_xlpm_on_read() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.cells.set(at("A1"), Cell { value: Value::Number(2.0), ..Cell::formula(Formula::new("LET(x,1,x+1)")) }); + s.cells.set(at("A2"), Cell { value: Value::Number(6.0), ..Cell::formula(Formula::new("LAMBDA(a,a*2)(3)")) }); + let mut arr = Formula::new("MAP(B1:B2,LAMBDA(v,v+1))"); + arr.array = Some(rr("C1:C2")); + s.cells.set(at("C1"), Cell::formula(arr)); + let bytes = write_xlsx(&wb).unwrap(); + let (xml, _) = sheet1_xml(&bytes); + assert!(xml.contains("_xlfn.LET(_xlpm.x,1,_xlpm.x+1)"), "{xml}"); + assert!(xml.contains("_xlpm.v"), "{xml}"); + let (back, _) = read_xlsx(&bytes).unwrap(); + let sh = back.sheet(0).unwrap(); + let text = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + assert_eq!(text("A1").as_deref(), Some("LET(x,1,x+1)")); + assert_eq!(text("A2").as_deref(), Some("LAMBDA(a,a*2)(3)")); + assert_eq!(text("C1").as_deref(), Some("MAP(B1:B2,LAMBDA(v,v+1))")); +} + +/// A line break in a table column name (and its header cell) survives; a +/// leading backslash in a table name is legal and kept. +#[test] +fn table_newline_column_and_backslash_name_survive() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + s.set_value(at("A1"), Value::text("Handle\nBars")); + s.set_value(at("B1"), Value::text("Name")); + s.set_value(at("B2"), Value::text("x")); + let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None }; + s.tables.push(Table { + id: 1, + name: "\\_Prime.1".into(), + range: rr("A1:B2"), + header_row: true, + totals_row: false, + columns: vec![col("Handle\nBars"), col("Name")], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + }); + s.cells.set(at("D1"), Cell::formula(Formula::new("COUNTA(\\_Prime.1[Name])"))); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let s = back.sheet(0).unwrap(); + assert_eq!(s.tables[0].name, "\\_Prime.1"); + assert_eq!(s.tables[0].columns[0].name, "Handle\nBars"); + assert_eq!(s.value(at("A1")), Value::text("Handle\nBars")); + assert_eq!(s.cell(at("D1")).and_then(|c| c.formula.as_ref()).map(|f| f.text.as_str()), Some("COUNTA(\\_Prime.1[Name])")); + assert!(gridcraft_formula::parse("COUNTA(\\_Prime.1[Name])").is_ok(), "backslash table name parses as a structured reference"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06dd452..d336082 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (`.xls` BIFF8 import, #121) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX round-trip: `_xlpm.` LET/LAMBDA parameters, multi-line table columns, `\`-prefixed table names) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -76,6 +76,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) | | 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) |