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.
This commit is contained in:
Marco Arnulfo
2026-10-12 19:07:32 +02:00
parent fc45b7f6d8
commit f63a437d5b
5 changed files with 40 additions and 4 deletions
+5
View File
@@ -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));
}
+7 -3
View File
@@ -88,15 +88,19 @@ fn word_is_cell(w: &str) -> Option<Anchor> {
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 {
+1
View File
@@ -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");
}
+2 -1
View File
@@ -197,7 +197,8 @@ fn registry() -> &'static (Vec<FnSpec>, 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 {
+25
View File
@@ -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#"<row r="{}"><c r="{a}"><f>{}</f></c></row>"#, i + 1, f.replace('"', "&quot;")))
.collect();
let (wb, _) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).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}");
}
}