#2871: open Google Sheets' DUMMYFUNCTION export wrapper as the original formula

Google Sheets' XLSX export writes a formula that uses a function it thinks
Excel lacks as IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),
<cached value>). GridCraft showed the wrapper as the formula.

fmla::from_file, which every cell formula read goes through (plain, shared
master and t="array"), now recognises exactly that shape: IFERROR (with or
without _xlfn.) of a single-string __xludf.DUMMYFUNCTION call, any case.
If the string parses, the formula inside replaces the wrapper, as the whole
formula or nested in a larger one (parenthesised unless it is a single
operand). The cell keeps the file's cached value.

Bounded on hostile input: inner text over MAX_FORMULA_LEN is not parsed,
only one level is unwrapped, and a result that no longer parses (nesting
past the parser's limit) keeps the file's formula. Text that doesn't parse
keeps the wrapper.

Saving writes the real formula with Excel's _xlfn. prefixes, as Excel does
with the _xlfn functions it knows. Keeping cached results for functions
GridCraft can't calculate is #2770 and not part of this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Harrison Smith
2026-10-31 19:19:20 -06:00
co-authored by Claude Opus 5.5
parent a85b1aea3a
commit 2c0a00fbae
4 changed files with 237 additions and 6 deletions
+129 -5
View File
@@ -140,15 +140,86 @@ fn strip_param_prefix(n: String) -> String {
pub fn from_file(text: &str) -> String {
let body = text.strip_prefix('=').unwrap_or(text);
match gridcraft_formula::parse(body) {
Ok(e) => gridcraft_formula::print(&e.map(&mut |x| match x {
Expr::Name(n) => Expr::Name(strip_param_prefix(n)),
Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args),
other => other,
})),
Ok(e) => {
let e = unwrap_dummy_functions(e);
gridcraft_formula::print(&e.map(&mut |x| match x {
Expr::Name(n) => Expr::Name(strip_param_prefix(n)),
Expr::Call(n, args) => Expr::Call(strip_param_prefix(n), args),
other => other,
}))
}
Err(_) => body.to_string(),
}
}
/// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of
/// their functions.
const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION";
/// The original formula inside Google Sheets' export wrapper
/// `IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),<cached value>)`, if `e` is that
/// wrapper and the text parses. The parser has already undone the doubled quotes. The cached value
/// is also the cell's `<v>`, which the reader keeps as the cell's value.
fn dummy_function_inner(e: &Expr) -> Option<Expr> {
let Expr::Call(name, args) = e else { return None };
// The parser has already dropped an `_xlfn.` prefix from IFERROR.
if !name.eq_ignore_ascii_case("IFERROR") {
return None;
}
let [Expr::Call(dummy, inner), _fallback] = args.as_slice() else { return None };
if !dummy.eq_ignore_ascii_case(DUMMY_FUNCTION) {
return None;
}
let [Expr::Text(src)] = inner.as_slice() else { return None };
// The text came out of a formula within the length limit, so it is within it too; check
// anyway rather than rely on that.
if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN {
return None;
}
gridcraft_formula::parse(src).ok()
}
/// Replaces each Google Sheets export wrapper (see [`dummy_function_inner`]) with the formula it
/// holds, for the whole formula or anywhere inside it. One level only: wrappers in the unwrapped
/// text stay as they are, so the work is bounded by the formula's length. If the result no longer
/// parses (nesting beyond the parser's limit), the formula is kept as the file had it.
fn unwrap_dummy_functions(e: Expr) -> Expr {
let mut found = false;
e.walk(&mut |x| {
found |= matches!(x, Expr::Call(n, _) if n.eq_ignore_ascii_case(DUMMY_FUNCTION));
});
if !found {
return e;
}
// The whole formula: no parentheses needed.
if let Some(inner) = dummy_function_inner(&e) {
return inner;
}
let original = e.clone();
let rewritten = e.map(&mut |x| match dummy_function_inner(&x) {
// The printer writes no parentheses of its own, so keep the inner formula's precedence
// with explicit ones unless it is a single operand.
Some(
inner @ (Expr::Call(..)
| Expr::Ref(_)
| Expr::Name(_)
| Expr::Number(_)
| Expr::Text(_)
| Expr::Bool(_)
| Expr::Error(_)
| Expr::Paren(_)
| Expr::Array(_)
| Expr::Struct(_)),
) => inner,
Some(inner) => Expr::Paren(Box::new(inner)),
None => x,
});
match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) {
Ok(_) => rewritten,
Err(_) => original,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -284,6 +355,59 @@ mod tests {
assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")");
}
/// #2871: Google Sheets' export wraps formulas as
/// `IFERROR(__xludf.DUMMYFUNCTION("<formula>"),<cached value>)`.
#[test]
fn google_sheets_dummy_function_wrapper() {
// The issue's formula, with its embedded newlines and doubled quotes.
let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)";
let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))";
assert_eq!(from_file(file), want);
assert_eq!(from_file(&format!("={file}")), want);
// Any case, and IFERROR with its `_xlfn.` prefix.
assert_eq!(from_file("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\"");
assert_eq!(from_file("iferror(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)"), "ARRAYFORMULA(A1:A3*2)");
// Nested: an operator expression keeps its precedence; a call or operand needs no parentheses.
assert_eq!(from_file("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3");
assert_eq!(from_file("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)");
assert_eq!(from_file("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)");
// One level only: a wrapper inside the unwrapped text stays.
assert_eq!(
from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"),
"IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)"
);
}
/// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were.
#[test]
fn google_sheets_dummy_function_kept_when_not_the_wrapper() {
for file in [
"IFERROR(__xludf.DUMMYFUNCTION(\"SUM(\"),5)",
"IFERROR(__xludf.DUMMYFUNCTION(\"1+\"),5)+1",
"IFERROR(__xludf.DUMMYFUNCTION(A1),5)",
"IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)",
"IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))",
"IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),5,6)",
"IFNA(__xludf.DUMMYFUNCTION(\"A1\"),5)",
"IFERROR(_xludf.DUMMYFUNCTION(\"A1\"),5)",
"__xludf.DUMMYFUNCTION(\"A1\")",
] {
let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap());
assert_eq!(from_file(file), kept, "{file}");
}
// Inner text over the formula length limit.
let long = format!("IFERROR(__xludf.DUMMYFUNCTION(\"{}\"),5)", "1".repeat(gridcraft_formula::MAX_FORMULA_LEN + 1));
assert!(from_file(&long).contains("DUMMYFUNCTION"));
// Nesting that would go past the parser's depth limit once unwrapped is kept as well.
let deep = |n: usize, mid: &str| format!("{}{mid}{}", "(".repeat(n), ")".repeat(n));
let inner = deep(200, "1").replace('"', "\"\"");
let file = deep(200, &format!("1+IFERROR(__xludf.DUMMYFUNCTION(\"{inner}\"),5)"));
assert!(gridcraft_formula::parse(&file).is_ok());
let read = from_file(&file);
assert!(read.contains("DUMMYFUNCTION"));
assert!(gridcraft_formula::parse(&read).is_ok());
}
#[test]
fn let_lambda_params() {
assert_eq!(to_file(&Formula::new("LET(x,1,x+1)")), "_xlfn.LET(_xlpm.x,1,_xlpm.x+1)");
+1
View File
@@ -9,6 +9,7 @@ mod chart_ref_offsets;
mod csv_tests;
mod encrypted_tests;
mod fixtures;
mod google_sheets_dummy;
mod ink_roundtrip;
mod literal_category_roundtrip;
mod locale_numfmt;
@@ -0,0 +1,92 @@
//! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks
//! as `IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),<cached value>)`. GridCraft
//! reads the formula inside, keeps the file's cached value, and saves the real formula.
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{Formula, Workbook};
use crate::package::Package;
use crate::{read_xlsx, write_xlsx};
fn at(s: &str) -> CellRef {
CellRef::parse(s).unwrap()
}
/// The issue's formula as Google writes it, with real newlines in the string literal.
const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)";
const HEBREW_MODEL: &str = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))";
/// A sheet as Google's export writes it: plain, `t="array"` and shared formulas, a wrapper inside
/// a larger formula, and one whose inner text doesn't parse.
fn google_book() -> Vec<u8> {
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
let body = format!(
concat!(
"<sheetData>",
r#"<row r="1"><c r="A1"><f>{hebrew}</f><v>40</v></c><c r="B1"><v>1</v></c>"#,
r#"<c r="E1"><f t="array" ref="E1:E2">_xlfn.IFERROR(__xludf.DUMMYFUNCTION("FILTER(B1:B3,B1:B3>1)"),2)</f><v>2</v></c>"#,
r#"<c r="F1"><f t="shared" ref="F1:F3" si="0">IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)</f><v>2</v></c>"#,
r#"<c r="G1"><f>1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)</f><v>6</v></c>"#,
r#"<c r="H1"><f>IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)</f><v>5</v></c>"#,
r#"<c r="I1" t="str"><f>IFERROR(__xludf.dummyfunction("""a""&amp;B1&amp;CHAR(10)&amp;&#10;B2"),"a1")</f><v>a1</v></c></row>"#,
r#"<row r="2"><c r="B2"><v>4</v></c><c r="E2"><v>3</v></c><c r="F2"><f t="shared" si="0"/><v>8</v></c></row>"#,
r#"<row r="3"><c r="B3"><v>3</v></c><c r="F3"><f t="shared" si="0"/><v>6</v></c></row>"#,
"</sheetData>"
),
hebrew = esc(HEBREW),
);
super::minimal(&body, &[], "", "")
}
fn formula(wb: &Workbook, cell: &str) -> Formula {
let c = wb.sheets[0].cell(at(cell)).unwrap();
Formula::clone(c.formula.as_ref().unwrap_or_else(|| panic!("{cell} has no formula")))
}
fn value(wb: &Workbook, cell: &str) -> Value {
wb.sheets[0].cell(at(cell)).unwrap().value.clone()
}
fn assert_google_book(wb: &Workbook) {
assert_eq!(formula(wb, "A1").text, HEBREW_MODEL);
let e1 = formula(wb, "E1");
assert_eq!(e1.text, "FILTER(B1:B3,B1:B3>1)");
assert_eq!(e1.array, Some(gridcraft_core::RangeRef::parse("E1:E2").unwrap()));
assert_eq!(formula(wb, "F1").text, "B1*2");
assert_eq!(formula(wb, "F2").text, "B2*2");
assert_eq!(formula(wb, "F3").text, "B3*2");
assert_eq!(formula(wb, "G1").text, "1+(B1+B2)");
assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)");
assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2");
// The cached values are the file's (inside an array range the reader keeps only the anchor's).
for (cell, v) in [("A1", 40.0), ("E1", 2.0), ("F1", 2.0), ("F2", 8.0), ("F3", 6.0), ("G1", 6.0), ("H1", 5.0)] {
assert_eq!(value(wb, cell), Value::Number(v), "{cell}");
}
assert_eq!(value(wb, "I1"), Value::text("a1"));
// Every unwrapped formula parses.
for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] {
assert!(formula(wb, cell).expr().is_some(), "{cell}");
}
}
#[test]
fn google_sheets_wrapper_reads_as_the_original_formula() {
let (wb, _) = read_xlsx(&google_book()).unwrap();
assert_google_book(&wb);
}
/// Saving writes the real formula, with the file prefixes Excel expects, as for any formula
/// GridCraft reads; reopening gives the same workbook.
#[test]
fn google_sheets_wrapper_saves_the_real_formula() {
let (wb, _) = read_xlsx(&google_book()).unwrap();
let saved = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
assert!(xml.contains("MAX(IFERROR(MAX(_xlfn._xlws.FILTER(ROW(C:C),C:C=\"שולם\")),0)"), "{xml}");
assert!(xml.contains("_xlfn._xlws.FILTER(B1:B3,B1:B3&gt;1)"), "{xml}");
// Only the formula whose inner text doesn't parse keeps its wrapper.
assert_eq!(xml.matches("DUMMYFUNCTION").count(), 1, "{xml}");
let (reopened, _) = read_xlsx(&saved).unwrap();
assert_google_book(&reopened);
}
+15 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)`, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **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**
@@ -39,6 +39,19 @@ names keep the mark; retyping the formula drops it, and the writer then decides
formula's shape. Rich-value metadata other than cell pictures (data types, `#SPILL!` error
values) is still dropped.
Formulas from Google Sheets' XLSX export open as the formulas written in Google Sheets (#2871).
Google writes a formula that uses a function it thinks Excel lacks as
`IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),<cached value>)`, which Excel shows
as the cached fallback. GridCraft recognises exactly that shape (any case, with or without
`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` formulas, as the whole formula or inside a
larger one (`=1+IFERROR(__xludf.DUMMYFUNCTION("A1+2"),5)` opens as `=1+(A1+2)`), and reads the
formula inside when it parses. The cell keeps the file's cached value. Inner text that doesn't
parse, is longer than 8,192 characters or would nest past the parser's limit keeps the wrapper,
and only one level is unwrapped. **Saving writes the real formula** (with `_xlfn.` prefixes, e.g.
`_xlfn._xlws.FILTER`), not the wrapper: this is what Excel does with the `_xlfn.` functions it
knows. A function GridCraft can't calculate then shows its error rather than the cached value;
keeping saved results for those is #2770 (`google_sheets_dummy.rs`, `fmla.rs`).
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|---|---|---|---|---|---|
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
@@ -110,6 +123,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside (plain, shared and array formulas, whole or nested; unparseable, over-long or over-deep inner text keeps the wrapper) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) |
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |