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