diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 96d100aea..69b1ebfa7 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 97087999e..e4841b562 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -575,3 +575,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 966088d5b..29dd20870 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -140,15 +140,119 @@ 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) => 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, 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") { + 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; + } + 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) +} + +/// `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, known) { + return Some(inner); + } + 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( + inner @ (Expr::Call(..) + | Expr::Ref(_) + | Expr::Name(_) + | Expr::Number(_) + | Expr::Text(_) + | Expr::Bool(_) + | Expr::Error(_) + | Expr::Paren(_) + | Expr::Array(_) + | Expr::Struct(_)), + ) => { + found = true; + inner + } + Some(inner) => { + found = true; + Expr::Paren(Box::new(inner)) + } + None => x, + }); + 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)] mod tests { use super::*; @@ -284,6 +388,76 @@ 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] + 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!(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!(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!( + 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, 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\"))", + "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!(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!(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 = read(&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/lib.rs b/crates/xlsx/src/lib.rs index dab1bb5af..94be6a235 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 c3d16a19e..e6ba1ce27 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -34,6 +34,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<'_> { @@ -230,7 +233,7 @@ fn selected_workbook_view(workbook: &El) -> Option<&El> { views.kids("workbookView").find(|view| view.attr("visibility").unwrap_or("visible") == "visible").or_else(|| views.child("workbookView")) } -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, @@ -247,6 +250,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.rs b/crates/xlsx/src/tests.rs index b4f2eafa4..8d7bc6572 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..921f46242 --- /dev/null +++ b/crates/xlsx/src/tests/google_sheets_dummy.rs @@ -0,0 +1,103 @@ +//! #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 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, read_xlsx_with, 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, 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!( + 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#"IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")bonjour"#, + 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"); + // 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!(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 = 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 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 be8631dbf..089bb20f7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: `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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: 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; earlier: `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; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** @@ -60,6 +60,20 @@ 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"` 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 | |---|---|---|---|---|---| | `.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` | @@ -133,6 +147,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. |---|---|---| | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | | 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | +| 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 |