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:
parent
a5137872c1
commit
13afa8c773
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue