mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-01 22:56:54 -06:00
Merge PR #3075
# Conflicts: # crates/xlsx/src/read.rs # docs/file-format-parity.md
This commit is contained in:
@@ -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}"),
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
+179
-5
@@ -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<String> {
|
||||
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("<formula, quotes doubled>"),<cached value>)`, 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 `<v>`, which the reader keeps.
|
||||
fn dummy_function_inner(e: &Expr, known: &dyn Fn(&str) -> bool) -> Option<Expr> {
|
||||
let Expr::Call(name, args) = e else { return None };
|
||||
// The parser has already dropped an `_xlfn.` prefix from IFERROR.
|
||||
if !name.eq_ignore_ascii_case("IFERROR") {
|
||||
return None;
|
||||
}
|
||||
let [Expr::Call(dummy, inner), _fallback] = args.as_slice() else { return None };
|
||||
if !dummy.eq_ignore_ascii_case(DUMMY_FUNCTION) {
|
||||
return None;
|
||||
}
|
||||
let [Expr::Text(src)] = inner.as_slice() else { return None };
|
||||
// The text came out of a formula within the length limit, so it is within it too; check
|
||||
// anyway rather than rely on that.
|
||||
if src.len() > gridcraft_formula::MAX_FORMULA_LEN && src.chars().count() > gridcraft_formula::MAX_FORMULA_LEN {
|
||||
return None;
|
||||
}
|
||||
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<Expr> {
|
||||
// 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("<formula>"),<cached value>)`.
|
||||
#[test]
|
||||
fn google_sheets_dummy_function_wrapper() {
|
||||
// The issue's formula, with its embedded newlines and doubled quotes.
|
||||
let file = "IFERROR(__xludf.DUMMYFUNCTION(\"MAX(\n IFERROR(MAX(FILTER(ROW(C:C), C:C=\"\"שולם\"\")), 0),\n IFERROR(MAX(FILTER(ROW(D:D), D:D=\"\"שולם\"\")), 0)\n)\"),40)";
|
||||
let want = "MAX(IFERROR(MAX(FILTER(ROW(C:C),C:C=\"שולם\")),0),IFERROR(MAX(FILTER(ROW(D:D),D:D=\"שולם\")),0))";
|
||||
assert_eq!(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)");
|
||||
|
||||
+16
-1
@@ -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("<formula, quotes doubled>"),<cached value>)`; 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:
|
||||
|
||||
@@ -34,6 +34,9 @@ pub struct Ctx<'a> {
|
||||
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
|
||||
/// 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());
|
||||
|
||||
@@ -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<Formula> {
|
||||
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" => {
|
||||
|
||||
@@ -9,6 +9,7 @@ mod chart_ref_offsets;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod google_sheets_dummy;
|
||||
mod ink_roundtrip;
|
||||
mod literal_category_roundtrip;
|
||||
mod locale_numfmt;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! #2871: Google Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks
|
||||
//! as `IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),<cached value>)`. GridCraft
|
||||
//! reads the formula inside 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<u8> {
|
||||
let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">");
|
||||
let body = format!(
|
||||
concat!(
|
||||
"<sheetData>",
|
||||
r#"<row r="1"><c r="A1"><f>{hebrew}</f><v>40</v></c><c r="B1"><v>1</v></c>"#,
|
||||
r#"<c r="E1"><f t="array" ref="E1:E2">_xlfn.IFERROR(__xludf.DUMMYFUNCTION("FILTER(B1:B3,B1:B3>1)"),2)</f><v>2</v></c>"#,
|
||||
r#"<c r="F1"><f t="shared" ref="F1:F3" si="0">IFERROR(__xludf.DUMMYFUNCTION("B1*2"),2)</f><v>2</v></c>"#,
|
||||
r#"<c r="G1"><f>1+IFERROR(__xludf.DUMMYFUNCTION("B1+B2"),5)</f><v>6</v></c>"#,
|
||||
r#"<c r="H1"><f>IFERROR(__xludf.DUMMYFUNCTION("SUM("),5)</f><v>5</v></c>"#,
|
||||
r#"<c r="I1" t="str"><f>IFERROR(__xludf.dummyfunction("""a""&B1&CHAR(10)& B2"),"a1")</f><v>a1</v></c>"#,
|
||||
r#"<c r="J1" t="str"><f>IFERROR(__xludf.DUMMYFUNCTION("GOOGLETRANSLATE(""hello"",""en"",""fr"")"),"bonjour")</f><v>bonjour</v></c></row>"#,
|
||||
r#"<row r="2"><c r="B2"><v>4</v></c><c r="E2"><v>3</v></c><c r="F2"><f t="shared" si="0"/><v>8</v></c></row>"#,
|
||||
r#"<row r="3"><c r="B3"><v>3</v></c><c r="F3"><f t="shared" si="0"/><v>6</v></c></row>"#,
|
||||
"</sheetData>"
|
||||
),
|
||||
hebrew = esc(HEBREW),
|
||||
);
|
||||
super::minimal(&body, &[], "", "")
|
||||
}
|
||||
|
||||
fn formula(wb: &Workbook, cell: &str) -> Formula {
|
||||
let c = wb.sheets[0].cell(at(cell)).unwrap();
|
||||
Formula::clone(c.formula.as_ref().unwrap_or_else(|| panic!("{cell} has no formula")))
|
||||
}
|
||||
|
||||
fn value(wb: &Workbook, cell: &str) -> Value {
|
||||
wb.sheets[0].cell(at(cell)).unwrap().value.clone()
|
||||
}
|
||||
|
||||
fn assert_google_book(wb: &Workbook) {
|
||||
assert_eq!(formula(wb, "A1").text, HEBREW_MODEL);
|
||||
let e1 = formula(wb, "E1");
|
||||
assert_eq!(e1.text, "FILTER(B1:B3,B1:B3>1)");
|
||||
assert_eq!(e1.array, Some(gridcraft_core::RangeRef::parse("E1:E2").unwrap()));
|
||||
assert_eq!(formula(wb, "F1").text, "B1*2");
|
||||
assert_eq!(formula(wb, "F2").text, "B2*2");
|
||||
assert_eq!(formula(wb, "F3").text, "B3*2");
|
||||
assert_eq!(formula(wb, "G1").text, "1+(B1+B2)");
|
||||
assert_eq!(formula(wb, "H1").text, "IFERROR(__XLUDF.DUMMYFUNCTION(\"SUM(\"),5)");
|
||||
assert_eq!(formula(wb, "I1").text, "\"a\"&B1&CHAR(10)&B2");
|
||||
// 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));
|
||||
}
|
||||
@@ -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("<formula, quotes doubled>"),<cached value>)`, which Excel shows
|
||||
as the cached fallback. GridCraft recognises exactly that shape (any case, with or without
|
||||
`_xlfn.` on `IFERROR`), in plain, shared and `t="array"` 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: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
@@ -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 `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
|
||||
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
|
||||
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
|
||||
|
||||
Reference in New Issue
Block a user