From 2c0a00fbae58d90e194d9370b29f820ad3cb5c15 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sat, 31 Oct 2026 19:19:20 -0600 Subject: [PATCH 1/2] #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 | From a1e0383056741404d49d253e407b6d444499253f Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Sun, 1 Nov 2026 00:08:40 -0600 Subject: [PATCH 2/2] #2871: unwrap Google Sheets' wrapper only when GridCraft calculates the formula A formula calling a Google-only function (GOOGLETRANSLATE, QUERY, ARRAYFORMULA...) opened as #NAME? where the wrapper had shown the cached value, as Excel does. The XLSX reader now unwraps a cell formula only when every function inside is known: read_xlsx_with takes the predicate (the xlsx crate can't depend on the function library) and the engine passes gridcraft_calc::is_known_function. Unwrapping stays in the reader so shared formulas are shifted from the unwrapped master. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 3 +- crates/engine/src/io/tests.rs | 29 ++++ crates/xlsx/src/fmla.rs | 146 +++++++++++++------ crates/xlsx/src/lib.rs | 17 ++- crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 9 +- crates/xlsx/src/tests/google_sheets_dummy.rs | 31 ++-- docs/file-format-parity.md | 21 +-- 8 files changed, 189 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..1c4c30095 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -153,7 +153,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, } // Text named `.xlsx` is read as text. if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) { - let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| { + // Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871). + let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| { EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) { (gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size), _ => format!("We can't open '{name}': {e}"), diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 130b1a6d9..77d61a9fe 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -561,3 +561,32 @@ fn content_wins_over_a_mismatched_extension() { assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); } } + +/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` +/// wrapper as the formula inside when GridCraft calculates every function it calls (including +/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a +/// Google-only function. +#[test] +fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { + use gridcraft_core::{CellRef, Value}; + let mut wb = Workbook::new(); + let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]); + let wrapped = [ + ("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)), + ("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")), + ]; + for (at, text, value) in &wrapped { + let cell = gridcraft_model::Cell { + value: value.clone(), + formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))), + ..Default::default() + }; + sh.cells.set(CellRef::parse(at).unwrap(), cell); + } + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap(); + let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()); + assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))")); + assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); + assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); +} diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 77c13511b..29dd20870 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,27 +140,45 @@ 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) => { - 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, - })) - } + Ok(e) => gridcraft_formula::print(&strip_param_prefixes(e)), Err(_) => body.to_string(), } } +fn strip_param_prefixes(e: Expr) -> Expr { + 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, + }) +} + /// The function Google Sheets' XLSX export wraps formulas in when it thinks Excel lacks one of /// their functions. const DUMMY_FUNCTION: &str = "__xludf.DUMMYFUNCTION"; +/// Whether `text` may hold Google Sheets' export wrapper: a cheap test before parsing. +fn mentions_dummy_function(text: &str) -> bool { + const NEEDLE: &[u8] = b"DUMMYFUNCTION"; + text.as_bytes().windows(NEEDLE.len()).any(|w| w.eq_ignore_ascii_case(NEEDLE)) +} + +/// The model text of `text` (a formula as read, see [`from_file`]) with its Google Sheets export +/// wrappers replaced (see [`crate::read_xlsx_with`]), or `None` when nothing changes. +pub(crate) fn unwrap_google_sheets(text: &str, known: &dyn Fn(&str) -> bool) -> Option { + if !mentions_dummy_function(text) { + return None; + } + let e = gridcraft_formula::parse(text.strip_prefix('=').unwrap_or(text)).ok()?; + let unwrapped = unwrap_dummy_functions(e, known)?; + Some(gridcraft_formula::print(&strip_param_prefixes(unwrapped))) +} + /// 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 { +/// wrapper, the text parses and every function it calls is `known`. The parser has already undone +/// the doubled quotes. The cached value is also the cell's ``, which the reader keeps. +fn dummy_function_inner(e: &Expr, known: &dyn Fn(&str) -> bool) -> 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") { @@ -176,27 +194,28 @@ fn dummy_function_inner(e: &Expr) -> Option { if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN { return None; } - gridcraft_formula::parse(src).ok() + let parsed = gridcraft_formula::parse(src).ok()?; + // A function GridCraft can't calculate would show `#NAME?` where Excel shows the cached + // value; a wrapper inside the text is one too, so only one level is ever unwrapped. + let mut all_known = true; + parsed.walk(&mut |x| { + if let Expr::Call(n, _) = x { + all_known &= known(n); + } + }); + all_known.then_some(parsed) } -/// 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; - } +/// `e` with each Google Sheets export wrapper (see [`dummy_function_inner`]) replaced by the +/// formula it holds, for the whole formula or anywhere inside it, or `None` when none is. If the +/// result no longer parses (nesting beyond the parser's limit), nothing is replaced. +fn unwrap_dummy_functions(e: Expr, known: &dyn Fn(&str) -> bool) -> Option { // The whole formula: no parentheses needed. - if let Some(inner) = dummy_function_inner(&e) { - return inner; + if let Some(inner) = dummy_function_inner(&e, known) { + return Some(inner); } - let original = e.clone(); - let rewritten = e.map(&mut |x| match dummy_function_inner(&x) { + let mut found = false; + let rewritten = e.map(&mut |x| match dummy_function_inner(&x, known) { // 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( @@ -210,14 +229,28 @@ fn unwrap_dummy_functions(e: Expr) -> Expr { | Expr::Paren(_) | Expr::Array(_) | Expr::Struct(_)), - ) => inner, - Some(inner) => Expr::Paren(Box::new(inner)), + ) => { + found = true; + inner + } + Some(inner) => { + found = true; + Expr::Paren(Box::new(inner)) + } None => x, }); - match gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)) { - Ok(_) => rewritten, - Err(_) => original, + if !found { + return None; } + gridcraft_formula::parse(&gridcraft_formula::print(&rewritten)).ok()?; + Some(rewritten) +} + +/// What the engine passes as `known` (`gridcraft_calc::is_known_function`), for tests here: the +/// function library plus the evaluator's own functions the tests use. +#[cfg(test)] +pub(crate) fn test_known_function(n: &str) -> bool { + gridcraft_functions::lookup(n).is_some() || ["IF", "IFERROR", "ROW", "INDEX", "LET", "LAMBDA"].iter().any(|s| s.eq_ignore_ascii_case(n)) } #[cfg(test)] @@ -355,6 +388,13 @@ mod tests { assert_eq!(to_file(&Formula::new("GROUPBY(A1:A3,B1:B3,\"SUM\")")), "_xlfn.GROUPBY(A1:A3,B1:B3,\"SUM\")"); } + /// A formula as the reader leaves it, then with Google Sheets' wrappers unwrapped against + /// the real function library. + fn read(file: &str) -> String { + let text = from_file(file); + unwrap_google_sheets(&text, &test_known_function).unwrap_or(text) + } + /// #2871: Google Sheets' export wraps formulas as /// `IFERROR(__xludf.DUMMYFUNCTION(""),)`. #[test] @@ -362,28 +402,37 @@ mod tests { // 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)"); + assert_eq!(read(file), want); + assert_eq!(read(&format!("={file}")), want); + // The reader itself leaves the wrapper: unwrapping is a separate pass. + assert!(from_file(file).contains("DUMMYFUNCTION")); + // Any case, and IFERROR with its `_xlfn.` prefix; doubled quotes inside. + assert_eq!(read("_xlfn.IFERROR(__XLUDF.dummyFunction(\"A1&\"\"x\"\"\"),\"1x\")"), "A1&\"x\""); + assert_eq!(read("iferror(__xludf.DUMMYFUNCTION(\"SORT(A1:A3*2)\"),2)"), "SORT(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!(read("1+IFERROR(__xludf.DUMMYFUNCTION(\"A1+2\"),5)*3"), "1+(A1+2)*3"); + assert_eq!(read("SUM(IFERROR(__xludf.DUMMYFUNCTION(\"FILTER(A1:A3,B1:B3)\"),0),B1)"), "SUM(FILTER(A1:A3,B1:B3),B1)"); + assert_eq!(read("IFERROR(__xludf.DUMMYFUNCTION(\"A1\"),0)&IFERROR(__xludf.DUMMYFUNCTION(\"-B1\"),0)"), "A1&(-B1)"); + // Each wrapper on its own: a Google-only one stays next to one that is unwrapped. assert_eq!( - from_file("IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)"), - "IFERROR(__XLUDF.DUMMYFUNCTION(\"A1\"),0)" + read("IFERROR(__xludf.DUMMYFUNCTION(\"A1*2\"),2)+IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)"), + "(A1*2)+IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1)\"),0)" ); } - /// Text that doesn't parse, and shapes that aren't exactly the wrapper, are kept as they were. + /// Text that doesn't parse, calls a function GridCraft lacks, or shapes that aren't exactly + /// the wrapper, are kept as they were, so the cell keeps the cached value as in Excel. #[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", + // Google-only functions: GridCraft would show #NAME? instead of the cached value. + "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(A1,\"\"en\"\",\"\"fr\"\")\"),\"x\")", + "IFERROR(__xludf.DUMMYFUNCTION(\"ARRAYFORMULA(A1:A3*2)\"),2)", + "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(QUERY(A1:B3,\"\"select B\"\"))\"),2)", + // A wrapper inside the text: only one level is ever unwrapped. + "IFERROR(__xludf.DUMMYFUNCTION(\"IFERROR(__xludf.DUMMYFUNCTION(\"\"A1\"\"),0)\"),0)", "IFERROR(__xludf.DUMMYFUNCTION(A1),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\",\"B1\"),5)", "IFERROR(__xludf.DUMMYFUNCTION(\"A1\"))", @@ -393,17 +442,18 @@ mod tests { "__xludf.DUMMYFUNCTION(\"A1\")", ] { let kept = gridcraft_formula::print(&gridcraft_formula::parse(file).unwrap()); - assert_eq!(from_file(file), kept, "{file}"); + assert_eq!(read(file), kept, "{file}"); + assert_eq!(unwrap_google_sheets(&from_file(file), &test_known_function), None, "{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")); + assert!(read(&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); + let read = read(&file); assert!(read.contains("DUMMYFUNCTION")); assert!(gridcraft_formula::parse(&read).is_ok()); } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..19ad5074e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -83,7 +83,22 @@ impl ReadReport { /// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the /// package relationships, so every main-part content type is accepted). pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - read::read_xlsx(bytes) + read::read_xlsx(bytes, None) +} + +/// [`read_xlsx`], and opens formulas from Google Sheets' XLSX export as the formulas written in +/// Google Sheets (#2871). +/// +/// Google writes a formula that uses a function it thinks Excel lacks as +/// `IFERROR(__xludf.DUMMYFUNCTION(""),)`; Excel shows the +/// cached value. A cell formula holding that wrapper, whole or inside a larger formula, is read +/// as the formula inside when it parses and `known` says GridCraft calculates every function it +/// calls. Otherwise (a Google-only function such as `GOOGLETRANSLATE`, inner text that doesn't +/// parse, or nesting past the parser's limit) the wrapper stays, so the cell keeps showing the +/// cached value as in Excel. `known` is passed in because this crate doesn't depend on the +/// function library. +pub fn read_xlsx_with(bytes: &[u8], known: &dyn Fn(&str) -> bool) -> Result<(Workbook, ReadReport), IoError> { + read::read_xlsx(bytes, Some(known)) } /// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size: diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..2b563e26a 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -32,6 +32,9 @@ pub struct Ctx<'a> { pub cell_pictures: Vec>>, /// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`. pub cf_ext_ids: Vec<(String, usize)>, + /// Whether GridCraft calculates a function, when the caller says: cell formulas wrapped by + /// Google Sheets' export are then read as the formula inside (see [`crate::read_xlsx_with`]). + pub known_function: Option<&'a dyn Fn(&str) -> bool>, } impl Ctx<'_> { @@ -221,7 +224,7 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) { } } -pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { +pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) -> bool>) -> Result<(Workbook, ReadReport), IoError> { let pkg = Package::open(bytes)?; let mut cx = Ctx { pkg, @@ -238,6 +241,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { num_fmts: Default::default(), cell_pictures: vec![], cf_ext_ids: vec![], + known_function, }; let root_rels = cx.pkg.rels("")?; let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into()); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7a37e41d8..8dcb99d28 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -13,7 +13,7 @@ use gridcraft_model::{ }; use crate::IoError; -use crate::fmla::from_file; +use crate::fmla::{from_file, unwrap_google_sheets}; use crate::package::Rel; use crate::read::Ctx; use crate::styles::read_color; @@ -534,7 +534,12 @@ fn read_formula(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynam } fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef, dynamic: bool) -> Option { - let model = from_file(f.text.trim()); + let mut model = from_file(f.text.trim()); + if let Some(known) = cx.known_function + && let Some(unwrapped) = unwrap_google_sheets(&model, known) + { + model = unwrapped; + } let text = model.as_str(); match f.attr("t").unwrap_or("normal") { "shared" => { diff --git a/crates/xlsx/src/tests/google_sheets_dummy.rs b/crates/xlsx/src/tests/google_sheets_dummy.rs index 37e27ecc4..921f46242 100644 --- a/crates/xlsx/src/tests/google_sheets_dummy.rs +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -1,12 +1,13 @@ //! #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. +//! reads the formula inside when GridCraft calculates every function it calls, 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}; +use crate::{read_xlsx, read_xlsx_with, write_xlsx}; fn at(s: &str) -> CellRef { CellRef::parse(s).unwrap() @@ -17,7 +18,7 @@ const HEBREW: &str = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER( 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. +/// a larger formula, one whose inner text doesn't parse and one with a Google-only function. fn google_book() -> Vec { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); let body = format!( @@ -28,7 +29,8 @@ fn google_book() -> Vec { 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#"IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")a1"#, + r#"IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")bonjour"#, r#"438"#, r#"36"#, "" @@ -58,35 +60,44 @@ fn assert_google_book(wb: &Workbook) { 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"); + // GridCraft has no GOOGLETRANSLATE: the wrapper stays, so the cell shows the cached text. + assert_eq!(formula(wb, "J1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hello\"\",\"\"en\"\",\"\"fr\"\")\"),\"bonjour\")"); // 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")); + assert_eq!(value(wb, "J1"), Value::text("bonjour")); // Every unwrapped formula parses. for cell in ["A1", "E1", "F1", "F2", "F3", "G1", "I1"] { assert!(formula(wb, cell).expr().is_some(), "{cell}"); } } +/// Reads a file as the engine opens one. +fn open(bytes: &[u8]) -> Workbook { + read_xlsx_with(bytes, &crate::fmla::test_known_function).unwrap().0 +} + #[test] fn google_sheets_wrapper_reads_as_the_original_formula() { + assert_google_book(&open(&google_book())); + // Without a function list the reader keeps the file's formula. let (wb, _) = read_xlsx(&google_book()).unwrap(); - assert_google_book(&wb); + assert!(formula(&wb, "A1").text.contains("DUMMYFUNCTION")); } /// 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 wb = open(&google_book()); 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); + // Only the formula whose inner text doesn't parse and the Google-only one keep their wrapper. + assert_eq!(xml.matches("DUMMYFUNCTION").count(), 2, "{xml}"); + assert_google_book(&open(&saved)); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9ad905a96..806d12405 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-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 +> **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(…),…)` when GridCraft calculates every function in it, 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** @@ -43,14 +43,15 @@ Formulas from Google Sheets' XLSX export open as the formulas written in Google 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`). +`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` cell 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 and GridCraft calculates every function it calls. The +cell keeps the file's cached value. A Google-only function (`GOOGLETRANSLATE`, `QUERY`, +`ARRAYFORMULA`…) keeps the wrapper, so the cell shows the cached value as in Excel rather than +`#NAME?`; so does inner text that doesn't parse, is longer than 8,192 characters or would nest +past the parser's limit, and a wrapper inside the text. **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 (`google_sheets_dummy.rs`, `fmla.rs`, engine `io/tests.rs`). | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| @@ -123,7 +124,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-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) 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 |