From f63a437d5b9d0d26c718c96779637762ea71c181 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:07:32 +0200 Subject: [PATCH] Formulas: keep _xludf. so user-defined calls stay user-defined (fixes #726) The parser stripped _xludf. like _xlfn., but the two mean opposite things: _xludf. marks a name Excel must not treat as a built-in. Keeping it in the call name makes the call an unknown function (#NAME?, as in Excel) and the writer emits it unchanged, so _xludf.EOMONTH() no longer saves as an EOMONTH() call Excel repairs away. --- crates/calc/src/tests.rs | 5 +++++ crates/formula/src/parser.rs | 10 +++++++--- crates/formula/src/tests.rs | 1 + crates/functions/src/lib.rs | 3 ++- crates/xlsx/src/tests/fixtures.rs | 25 +++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 513340983..6214206cb 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -167,6 +167,11 @@ fn errors_and_cycles() { assert!(t.get("B1").is_error()); t.set("C1", "=NOSUCHFN(1)"); assert_eq!(t.get("C1"), Value::Error(CellError::Name)); + // `_xludf.` marks a user-defined function, never the built-in of the same name (#726). + for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] { + t.set("C3", f); + assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}"); + } t.set("C2", "=1+"); assert_eq!(t.get("C2"), Value::Error(CellError::Name)); } diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 4fd3b7bb2..81b8fe0da 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -88,15 +88,19 @@ fn word_is_cell(w: &str) -> Option { Some(Anchor { row, col, row_abs, col_abs }) } -/// `_xlfn.XLOOKUP` → `XLOOKUP`. +/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined +/// function, never the built-in of the same name, and files need it back. pub fn normalize_function_name(name: &str) -> String { let u = name.to_ascii_uppercase(); - for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] { + for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] { if let Some(r) = u.strip_prefix(p) { return r.to_string(); } } - u + match u.strip_prefix("_XLUDF.") { + Some(r) => format!("_xludf.{r}"), + None => u, + } } impl Parser { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index d3dd4c231..69491f6f9 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -48,6 +48,7 @@ fn normalises() { assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)"); assert_eq!(rt("= 1 + 2"), "1+2"); assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)"); + assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")"); assert_eq!(rt("true"), "TRUE"); assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230ce..65612b4b8 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -197,7 +197,8 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { }) } -/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names). +/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A +/// `_xludf.` name is user-defined and never found. pub fn lookup(name: &str) -> Option<&'static FnSpec> { let mut n = name.trim().to_ascii_uppercase(); loop { diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 47dde5adc..99221dd19 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -495,3 +495,28 @@ fn text_box_formatting_is_read_and_written() { assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0))); assert_eq!(g.text_outline.map(|o| o.1), Some(1.5)); } + +/// `_xludf.` marks a user-defined call even when a built-in has the same name; dropping it turns +/// the call into the built-in, and `_xludf.EOMONTH()` into a call Excel repairs away (#726). +#[test] +fn user_defined_function_prefix_round_trips() { + let cases = + [("A1", "_xludf.EOMONTH()"), ("A2", "_xludf.CONCAT(\"a\",\"b\")"), ("A3", "_xludf.SUM(1,2)"), ("A4", "MYUDF(1)"), ("A5", "_xludf.MYUDF(1)")]; + let rows: String = cases + .iter() + .enumerate() + .map(|(i, (a, f))| format!(r#"{}"#, i + 1, f.replace('"', """))) + .collect(); + let (wb, _) = read_xlsx(&minimal(&format!("{rows}"), &[], "", "")).unwrap(); + let s = wb.sheet(0).unwrap(); + for (a, f) in cases { + assert_eq!(s.cell(at(a)).unwrap().formula.as_ref().unwrap().text, f, "read {a}"); + } + let bytes = crate::write_xlsx(&wb).unwrap(); + let mut pkg = crate::package::Package::open(&bytes).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + for (a, f) in cases { + let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(a)).unwrap(); + assert_eq!(cell.child("f").unwrap().text, f, "written {a}"); + } +}