XLSX: read cell formulas via file→model conversion (_xlpm), keep line breaks in table columns, allow leading backslash in table names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
neilcawse 2026-10-11 16:39:31 -04:00
parent a5137872c1
commit 13afa8c773
4 changed files with 66 additions and 11 deletions

View File

@ -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<Formula> {
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")?;

View File

@ -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<String>, 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<String> {
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()) {

View File

@ -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");
}

View File

@ -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) |