From 0e3efab32b1cd4f6207c2d2224f8e9c9dbc07385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingmar=20L=C3=BCbbe?= Date: Tue, 13 Oct 2026 12:17:12 +0200 Subject: [PATCH] Sheet names: one quoting rule, Excel 365's, for formulas, ADDRESS, pivots and the Name Box Sheet names were quoted in three places with three rules (the formula printer, engine::pivot::quote_sheet, ADDRESS) and unquoted in seven (two split_sheet copies in the engine, xlsx pivot and array-formula readers, chart resolve, engine cmd/pivot, the Name Box). gridcraft_core::addr now holds sheet_needs_quotes, quote_sheet, unquote_sheet and split_sheet, and every copy uses them. The rule, observed in Excel 365 (typing ='name'!A1 and reading .Formula back, and ADDRESS(1,1,1,TRUE,name)): a name is quoted when it reads as an A1 cell on the sheet (A1, XFD1, AB1048576; not XFE1), an R1C1 reference in any case (R, C, RC, R1, C1, R1C, RC1, R1C1), a boolean, starts with a digit or a dot, or holds anything but letters, digits, _ and dot. ADDRESS leaves TRUE and FALSE bare. Fixes: - A formula into a sheet named .x was saved as .x!A1, and Excel 365 can't open such a file, not even with repair. - A PivotTable made from the current region of a sheet named A1 reported its source as A1!$A$1:$B$3 (the engine's copy lacked the cell check). - ADDRESS(1,1,1,TRUE,"A1") gave A1!$A$1; Excel gives 'A1'!$A$1. The same for R1C1-like names and a leading dot. - The Name Box never showed a name on sheet Bob's: it compared Bob''s with Bob's. - Go To built '{name}'! by hand without doubling apostrophes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SAgnkqJiy3v24Hp25dQLRj --- crates/chart/src/resolve.rs | 2 +- crates/core/src/addr.rs | 108 +++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 2 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/mod.rs | 8 +-- crates/engine/src/cmd/pivot.rs | 2 +- crates/engine/src/cmd/solver.rs | 4 +- crates/engine/src/pivot.rs | 22 +------ crates/engine/src/tests.rs | 21 ++++++ crates/formula/src/printer.rs | 17 +---- crates/functions/src/lookup.rs | 14 +++- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/tests/name_box.rs | 17 +++++ crates/xlsx/src/array_formula.rs | 2 +- crates/xlsx/src/pivot.rs | 5 +- docs/file-format-parity.md | 3 +- docs/function-parity.md | 3 +- 17 files changed, 175 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/tests/name_box.rs diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e907..520e425f26 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range let (sh, addr) = match f.rfind('!') { Some(i) => { let name = f.get(..i)?.trim(); - let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string()); + let name = gridcraft_core::addr::unquote_sheet(name); (wb.sheet_index(&name)?, f.get(i + 1..)?) } None => (sheet, f), diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 2bdfe797a2..11d48afd97 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -230,6 +230,73 @@ impl fmt::Display for RangeRef { } } +// ---------------------------------------------------------------- sheet names in references + +/// Whether a sheet name needs quotes in a reference (`'My Sheet'!A1`), as Excel 365 writes +/// formulas. It does when the name: +/// - starts with a digit or `.` (`1Sheet`, `.x`), or holds anything but letters, digits, `_` and +/// `.` (`Sheet 1`, `a-b`, `Bob's`); +/// - reads as an A1 cell (`A1`, `XFD1`, `AB1048576`; not `XFE1` or `AB1048577`, which are off the +/// sheet); +/// - reads as an R1C1 reference (`R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1`, in any case); +/// - is a boolean (`TRUE`, `false`). +/// +/// Letters of any script stay bare (`Ärger`, `日本`, `Σ`). +pub fn sheet_needs_quotes(name: &str) -> bool { + let Some(first) = name.chars().next() else { return true }; + if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + CellRef::parse(name).is_some() || is_r1c1_reference(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +/// `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` (any case): what Excel reads as an R1C1 row, +/// column or cell. +fn is_r1c1_reference(name: &str) -> bool { + let b = name.as_bytes(); + let mut i = 0; + let digits = |i: &mut usize| { + while b.get(*i).is_some_and(u8::is_ascii_digit) { + *i += 1; + } + }; + let r = b.first().is_some_and(|c| c.eq_ignore_ascii_case(&b'R')); + if r { + i += 1; + digits(&mut i); + } + let c = b.get(i).is_some_and(|c| c.eq_ignore_ascii_case(&b'C')); + if c { + i += 1; + digits(&mut i); + } + (r || c) && i == b.len() +} + +/// A sheet name as a reference writes it: quoted when [`sheet_needs_quotes`], with each `'` +/// doubled (`Bob's` → `'Bob''s'`). +pub fn quote_sheet(name: &str) -> String { + if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +/// The sheet name a reference's sheet part stands for: quotes taken off and `''` read as `'` +/// (`'Bob''s'` → `Bob's`); a bare name stays as it is. +pub fn unquote_sheet(text: &str) -> String { + match text.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) { + Some(inner) => inner.replace("''", "'"), + None => text.to_string(), + } +} + +/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`); `A1` → (`None`, `A1`). The sheet part is split +/// at the last `!`, so a quoted name may hold one (`'a!b'!A1`). +pub fn split_sheet(reference: &str) -> (Option, &str) { + match reference.rsplit_once('!') { + Some((sheet, body)) => (Some(unquote_sheet(sheet)), body), + None => (None, reference), + } +} + #[cfg(test)] mod tests { use super::*; @@ -292,4 +359,45 @@ mod tests { assert_eq!(CellRef::new(0, 0).offset(-1, 0), None); assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3)); } + + /// Excel 365's quoting of sheet names in formulas (`='name'!A1` typed, `.Formula` read back). + #[test] + fn sheet_names_are_quoted_like_excel() { + for q in [ + "A1", + "XFD1", + "AB1048576", + "R", + "C", + "RC", + "R1", + "C1", + "R1C", + "RC1", + "R1C1", + "r2c3", + "TRUE", + "false", + "1Sheet", + ".x", + "Sheet 1", + "a-b", + "Bob's", + "a#b", + "a(b", + "", + ] { + assert!(sheet_needs_quotes(q), "{q} needs quotes"); + } + for b in ["A", "ABC", "XFE1", "AB1048577", "A1A", "x1y", "Sheet1", "Sheet.1", "_x", "x_", "Ärger", "日本", "Σ"] { + assert!(!sheet_needs_quotes(b), "{b} stays bare"); + } + assert_eq!(quote_sheet("Bob's"), "'Bob''s'"); + assert_eq!(quote_sheet("Sheet1"), "Sheet1"); + assert_eq!(unquote_sheet("'Bob''s'"), "Bob's"); + assert_eq!(unquote_sheet("Sheet1"), "Sheet1"); + assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1")); + assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2")); + assert_eq!(split_sheet("A1"), (None, "A1")); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07ed..833dd06e26 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -8,6 +8,6 @@ pub mod date; pub mod parse; pub mod value; -pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; +pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes, split_sheet, unquote_sheet}; pub use date::DateSystem; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be72..0a9d938e3e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -514,7 +514,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { } else if let Some((si, ti)) = d.wb.table(reference) { // As in Excel, a table's name stands for its data, without the header and totals rows. let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default(); - format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t) + format!("{}!{}", gridcraft_core::quote_sheet(d.wb.sheet(si).map(|s| s.name.as_str()).unwrap_or_default()), t) } else { return Err(EngineError::Other("Reference isn't valid.".into())); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7d..f9dec82a11 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -269,13 +269,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result { Ok(d.wb.active_sheet) } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -pub(crate) fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} +pub(crate) use gridcraft_core::addr::split_sheet; /// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not /// a cell reference, or that names a sheet other than the active one, is an error rather than a diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b4..bc5a8d6bda 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1386,7 +1386,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let (sheet, addr) = match t.rsplit_once('!') { Some((sh, a)) => { let sh = sh.trim(); - let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string()); + let sh = gridcraft_core::addr::unquote_sheet(sh); (d.wb.sheet_index(&sh), a) } None => (Some(d.wb.active_sheet), t), diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 4465874bc3..4e85347091 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -364,7 +364,7 @@ fn parse_model(s: &Session, p: &Json) -> Result { // ---------------------------------------------------------------- saved model fn ref_text(sheet_name: &str, cells: &str) -> String { - let q = crate::pivot::quote_sheet(sheet_name); + let q = gridcraft_core::quote_sheet(sheet_name); cells .split(',') .map(str::trim) @@ -491,7 +491,7 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { /// The model as `data.solver` params, in the form [`saved_model`] returns (the objective /// qualified by its sheet). fn model_params(wb: &Workbook, m: &Model) -> Json { - let sheet = wb.sheet(m.sheet).map(|s| crate::pivot::quote_sheet(&s.name)).unwrap_or_default(); + let sheet = wb.sheet(m.sheet).map(|s| gridcraft_core::quote_sheet(&s.name)).unwrap_or_default(); let (goal, value) = match m.goal { Goal::Max => ("max", 0.0), Goal::Min => ("min", 0.0), diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 6146bd4f91..cd58a0df2d 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -47,25 +47,7 @@ impl Source { } } -/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). -fn split_sheet(r: &str) -> (Option, &str) { - match r.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, r), - } -} - -/// Quotes a sheet name for a reference when needed. -pub fn quote_sheet(name: &str) -> String { - if !name.is_empty() - && name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') - && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - name.to_string() - } else { - format!("'{}'", name.replace('\'', "''")) - } -} +pub use gridcraft_core::addr::{quote_sheet, split_sheet}; /// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`. pub fn range_text(sheet_name: &str, r: RangeRef) -> String { @@ -1511,7 +1493,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> { }; let (sheet, name) = t.rsplit_once('!')?; let sheet = sheet.trim(); - let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string()); + let sheet = gridcraft_core::addr::unquote_sheet(sheet); let name = name.trim(); if sheet.is_empty() || name.is_empty() { return None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a1895..47b94385fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -814,6 +814,27 @@ fn insert_delete_cells_adjust() { assert_eq!(v(&s, "E3"), Value::Number(8.0)); } +/// One rule quotes sheet names everywhere (Excel 365's): a PivotTable on sheet `A1` names its +/// source `'A1'!$A$1:$B$3`, and a formula into sheet `.x` keeps its quotes, which Excel needs to +/// open the file at all. +#[test] +fn sheet_names_are_quoted_by_excels_rule_everywhere() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "A1"})).unwrap(); + s.execute("range.setValues", json!({"range": "'A1'!A1", "values": [["K", "V"], ["a", 1], ["b", 2]]})).unwrap(); + // From the current region, the engine writes the source text itself. + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + s.execute("insert.pivotTable", json!({"rows": ["K"], "values": ["V"]})).unwrap(); + let list = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(list[0]["source"], "'A1'!$A$1:$B$3", "{list}"); + s.execute("home.insertSheet", json!({"name": ".x"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "sheet": ".x", "input": "5"})).unwrap(); + s.execute("home.insertSheet", json!({"name": "R1C1"})).unwrap(); + s.execute("cell.set", json!({"cell": "'A1'!C1", "input": "='.x'!A1+'R1C1'!A1"})).unwrap(); + let got = s.execute("cell.get", json!({"cell": "'A1'!C1"})).unwrap(); + assert_eq!(got["formula"], "='.x'!A1+'R1C1'!A1"); +} + #[test] fn sort_and_filter() { let mut s = s(); diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 3975a7c615..3e77e20040 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -6,22 +6,7 @@ use gridcraft_core::{CellRef, col_to_letters, number_to_text}; use crate::ast::*; -/// Whether a sheet name needs quotes in a reference. -pub fn needs_quotes(name: &str) -> bool { - if name.is_empty() { - return true; - } - let first = name.chars().next().unwrap_or('_'); - if first.is_ascii_digit() || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { - return true; - } - // Looks like a cell ref (`A1`, `R1C1`) or a boolean. - gridcraft_core::CellRef::parse(name).is_some() || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") -} - -pub fn quote_sheet(name: &str) -> String { - if needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } -} +pub use gridcraft_core::addr::{quote_sheet, sheet_needs_quotes as needs_quotes}; fn sheet_prefix(s: &SheetSel) -> String { match s { diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c9333..7472771eb5 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -5,7 +5,7 @@ use std::cmp::Ordering; -use gridcraft_core::{Array, CellError, Value, col_to_letters, compare}; +use gridcraft_core::{Array, CellError, Value, col_to_letters, compare, sheet_needs_quotes}; use crate::criteria::{has_wildcards, lookup_equal}; use crate::util::{R, arg, array_val, as_array, has, int, num, opt_bool, opt_int, opt_num, text, text_val}; @@ -415,10 +415,11 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R { /// Sheet text for ADDRESS, quoted when it needs to be. A `[Book]Sheet` prefix is judged by its /// parts (Microsoft's ADDRESS examples: `"[Book1]Sheet1"` stays bare, `"EXCEL SHEET"` becomes -/// `'EXCEL SHEET'`). +/// `'EXCEL SHEET'`). The sheet follows the formula rule ([`sheet_needs_quotes`]: `'A1'!$A$1`, +/// `'R1C1'!$A$1`), except that Excel 365's ADDRESS leaves `TRUE` and `FALSE` bare. fn quote_sheet(s: &str) -> String { let word = |p: &str| p.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.'); - let bare_sheet = |p: &str| !p.is_empty() && word(p) && !p.chars().next().is_some_and(|c| c.is_ascii_digit()); + let bare_sheet = |p: &str| !sheet_needs_quotes(p) || p.eq_ignore_ascii_case("TRUE") || p.eq_ignore_ascii_case("FALSE"); let plain = match s.strip_prefix('[').and_then(|r| r.split_once(']')) { Some((book, sheet)) => !book.is_empty() && word(book) && bare_sheet(sheet), None => bare_sheet(s), @@ -791,6 +792,13 @@ mod tests { for (r, c, abs, a1, sheet, want) in cases { is_text(ev("ADDRESS", vec![n(r), n(c), n(abs), b(a1), t(sheet)]), want); } + // Excel 365 (ADDRESS(1,1,1,TRUE,name)): names that read as cells or R1C1 references are + // quoted, booleans are not. + for (sheet, want) in + [("A1", "'A1'!$A$1"), ("R1C1", "'R1C1'!$A$1"), ("RC", "'RC'!$A$1"), (".x", "'.x'!$A$1"), ("XFE1", "XFE1!$A$1"), ("TRUE", "TRUE!$A$1")] + { + is_text(ev("ADDRESS", vec![n(1.0), n(1.0), n(1.0), b(true), t(sheet)]), want); + } is_text(ev("ADDRESS", vec![n(1048576.0), n(16384.0)]), "$XFD$1048576"); is_err(ev("ADDRESS", vec![n(0.0), n(1.0)]), CellError::Value); is_err(ev("ADDRESS", vec![n(1.0), n(16385.0)]), CellError::Value); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c0..bdc9ca2eb6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1219,11 +1219,8 @@ impl SheetApp { if let Some(sh) = sh { for n in &d.wb.names { let target = n.formula.replace('$', ""); - let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(s.trim_matches('\'').to_string()), b.to_string()), - None => (None, target.clone()), - }; - if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { + let (sheet, body) = gridcraft_engine::core::addr::split_sheet(&target); + if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(body) == Some(cur) { return n.name.clone(); } } diff --git a/crates/ui-egui/tests/name_box.rs b/crates/ui-egui/tests/name_box.rs new file mode 100644 index 0000000000..f8e94f6dc1 --- /dev/null +++ b/crates/ui-egui/tests/name_box.rs @@ -0,0 +1,17 @@ +//! The Name Box shows a defined name when the selection is exactly its range, also on a sheet +//! whose name holds an apostrophe (`'Bob''s'!$A$1:$B$2`). + +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; + +#[test] +fn name_box_shows_a_name_on_a_sheet_with_an_apostrophe() { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({"name": "Bob's"})).unwrap(); + session.execute("formulas.defineName", json!({"name": "Totals", "refersTo": "='Bob''s'!$A$1:$B$2"})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let app = SheetApp::new(session, Services::default()); + assert_eq!(app.name_box_text(), "Totals"); +} diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f6..0315ad7433 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -53,7 +53,7 @@ impl Scan<'_> { return *shape; } let (scope, bare) = match n.rsplit_once('!') { - Some((s, name)) => (self.wb.sheet_index(&s.trim_matches('\'').replace("''", "'")).unwrap_or(sheet), name), + Some((s, name)) => (self.wb.sheet_index(&gridcraft_core::addr::unquote_sheet(s)).unwrap_or(sheet), name), None => (sheet, n.as_str()), }; if let Some(def) = self.wb.name(bare, scope) { diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 609ae530e1..b071b39067 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -373,10 +373,7 @@ fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) - let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; return Some((si, r, xml)); } - let (sheet, body) = match t.rsplit_once('!') { - Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), - None => (None, t), - }; + let (sheet, body) = gridcraft_core::addr::split_sheet(t); if let Some(r) = RangeRef::parse(body) { let si = match &sheet { Some(n) => wb.sheet_index(n)?, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9884724146..56ca303187 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-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (sheet names in formulas are quoted by one rule, Excel 365's; a formula into a sheet named .x no longer makes the file unopenable in Excel) · **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** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Sheet names: one quoting rule (`gridcraft_core::addr`: `quote_sheet`, `split_sheet`, `unquote_sheet`), Excel 365's: names that read as A1 cells, R1C1 references (`R`, `RC`, `R1C1`…) or booleans, or start with a digit or `.`, are quoted. A formula into a sheet named `.x` was written `.x!A1`, which Excel 365 couldn't open even with repair; a PivotTable on sheet `A1` named its source `A1!$A$1:$B$3`. | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac3..07a3c4a9af 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (ADDRESS quotes sheet names that read as cells or R1C1 references, as Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | ADDRESS quotes a sheet name that reads as a cell or an R1C1 reference (`'A1'!$A$1`, `'R1C1'!$A$1`, `'.x'!$A$1`) and leaves `TRUE`/`FALSE` bare, as Excel 365 does | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |