From 2c0a00fbae58d90e194d9370b29f820ad3cb5c15 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:19:20 -0600 Subject: [PATCH] #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(""), ). 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 --- crates/xlsx/src/fmla.rs | 134 ++++++++++++++++++- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/google_sheets_dummy.rs | 92 +++++++++++++ docs/file-format-parity.md | 16 ++- 4 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 crates/xlsx/src/tests/google_sheets_dummy.rs diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 966088d5b..77c13511b 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -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(""),)`, 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 ``, which the reader keeps as the cell's value. +fn dummy_function_inner(e: &Expr) -> Option { + 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(""),)`. + #[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)"); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 73be1a064..cf138fcf8 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -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; diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs new file mode 100644 index 000000000..37e27ecc4 --- /dev/null +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -0,0 +1,92 @@ +//! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks +//! as `IFERROR(__xludf.DUMMYFUNCTION(""),)`. 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 { + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); + let body = format!( + concat!( + "", + r#"{hebrew}401"#, + r#"_xlfn.IFERROR(__xludf.DUMMYFUNCTION("FILTER(B1:B3,B1:B3>1)"),2)2"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)2"#, + r#"1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)6"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)5"#, + r#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"438"#, + r#"36"#, + "" + ), + 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>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); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..9ad905a96 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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(""),)`, 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: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` 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 ``) 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 (ふりがな, `` with ``) 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 `` without runs is kept on its own, and when duplicate `` 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 (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters |