Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/chart/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
108 changes: 108 additions & 0 deletions crates/core/src/addr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, &str) {
match reference.rsplit_once('!') {
Some((sheet, body)) => (Some(unquote_sheet(sheet)), body),
None => (None, reference),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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"));
}
}
2 changes: 1 addition & 1 deletion crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
2 changes: 1 addition & 1 deletion crates/engine/src/cmd/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
} 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()));
}
Expand Down
8 changes: 1 addition & 7 deletions crates/engine/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
Ok(d.wb.active_sheet)
}

/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
pub(crate) fn split_sheet(r: &str) -> (Option<String>, &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
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/cmd/pivot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/cmd/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ fn parse_model(s: &Session, p: &Json) -> Result<Model> {
// ---------------------------------------------------------------- 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)
Expand Down Expand Up @@ -491,7 +491,7 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
/// 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),
Expand Down
22 changes: 2 additions & 20 deletions crates/engine/src/pivot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,7 @@ impl Source {
}
}

/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
fn split_sheet(r: &str) -> (Option<String>, &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 {
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 1 addition & 16 deletions crates/formula/src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions crates/functions/src/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -415,10 +415,11 @@ fn transpose(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {

/// 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),
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 2 additions & 5 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down
17 changes: 17 additions & 0 deletions crates/ui-egui/tests/name_box.rs
Original file line number Diff line number Diff line change
@@ -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");
}
2 changes: 1 addition & 1 deletion crates/xlsx/src/array_formula.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 1 addition & 4 deletions crates/xlsx/src/pivot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,7 @@ fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -
let xml = if valid { format!("<worksheetSource name=\"{}\"/>", 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)?,
Expand Down
3 changes: 2 additions & 1 deletion docs/file-format-parity.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down Expand Up @@ -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) |
Expand Down
Loading