diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 13b7aa5c1..69f1e30f2 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -156,13 +156,23 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec { .collect() } -/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is. +/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on +/// that sheet), the way the evaluator looks names up. +fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool { + match t.split_once('!') { + Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()), + None => wb.name(t, sheet).is_some(), + } +} + +/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated, +/// a quoted or other bare literal is used as is. fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { let t = name.trim(); if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) { return q.replace("\"\"", "\""); } - if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') { + if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) { let vals = eval_list(wb, sheet, t); let parts: Vec = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect(); return parts.join(" "); diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/chart_workbook_names.rs new file mode 100644 index 000000000..0ac156b62 --- /dev/null +++ b/crates/engine/tests/chart_workbook_names.rs @@ -0,0 +1,134 @@ +//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name` +//! in ``; they used to read back verbatim and evaluate to `#NAME?`, so a chart +//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category. + +use std::io::{Read, Write}; + +use gridcraft_engine::{Session, io}; +use gridcraft_model::Workbook; +use serde_json::json; + +const TASKS: usize = 6; + +/// A plan table, OFFSET names over its structured references (and names built on names; one +/// scoped to the sheet), and a stacked bar chart whose series name those names. +fn plan() -> Workbook { + let mut s = Session::new(); + s.new_workbook(); + let mut rows = vec![json!(["Task", "Start", "Done", "Left"])]; + for i in 0..TASKS { + rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()])); + } + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let range = format!("A1:D{}", TASKS + 1); + s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap(); + for (name, refers, scope) in [ + ("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"), + ("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"), + ("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"), + ("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"), + ("LeftTitle", "=Sheet1!$D$1", "Workbook"), + ("DoneTitle", "=Sheet1!$C$1", "Sheet1"), + ] { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + } + s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap(); + let mut wb = Workbook::clone(&s.doc().unwrap().wb); + let chart = &mut wb.sheet_mut(0).unwrap().charts[0]; + assert_eq!(chart.series.len(), 3); + for (ser, (name, values)) in + chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")]) + { + ser.name = Some(name.into()); + ser.categories = Some("TaskNames".into()); + ser.values = values.into(); + } + wb +} + +fn chart_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut f = z.by_name("xl/charts/chart1.xml").unwrap(); + let mut xml = String::new(); + f.read_to_string(&mut xml).unwrap(); + xml +} + +/// The package with `edit` applied to its chart part. +fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + if name == "xl/charts/chart1.xml" { + data = edit(String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +fn reopen(bytes: &[u8]) -> Workbook { + io::open_bytes("plan.xlsx", bytes).unwrap().0 +} + +#[test] +fn workbook_names_in_chart_series_resolve_after_save_and_reopen() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + // Qualified, as Excel needs them: it draws a chart with a bare name in `` blank. + let xml = chart_xml(&bytes); + for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] { + assert!(xml.contains(&format!("{f}")), "{f} missing from {xml}"); + } + assert!(!xml.contains("Task"), "a bare name in {xml}"); + // Series names that are defined names stay references, qualified or not. + assert!(xml.contains("Sheet1!DoneTitle"), "{xml}"); + // A series name that is a defined name stays a reference, not literal text. + assert!(!xml.contains("LeftTitle")); + + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames")); + assert_eq!(chart.series[0].values, "TaskStart"); + assert_eq!(chart.series[2].values, "Sheet1!TaskLeft"); + assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle")); + + let data = gridcraft_chart::resolve(&wb, 0, chart); + let tasks: Vec = (1..=TASKS).map(|i| format!("Task {i}")).collect(); + assert_eq!(data.categories, tasks); + let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Start", "Done", "Left"]); + let start: Vec> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect(); + assert_eq!(data.series[0].values, start); + let left: Vec> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect(); + assert_eq!(data.series[2].values, left); + + // A second save and reopen gives the same chart data. + let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap()); + assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data); +} + +#[test] +fn a_missing_workbook_name_resolves_to_no_values_without_panicking() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName")); + let wb = reopen(&bytes); + let chart = &wb.sheet(0).unwrap().charts[0]; + assert_eq!(chart.series[2].values, "NoSuchName"); + let data = gridcraft_chart::resolve(&wb, 0, chart); + assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values); + // The other series still plot. + assert_eq!(data.series[0].values.len(), TASKS); +} + +#[test] +fn external_workbook_refs_in_chart_series_are_kept_as_they_were() { + let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap(); + let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7")); + let wb = reopen(&bytes); + assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7"); +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..68af6cfc2 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::Value; use gridcraft_formula::{Expr, SheetSel}; -use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; +use gridcraft_model::{Chart, ChartKind, Color, DefinedName, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; use crate::drawing::{dml_color, srgb}; use crate::xml::{El, esc, num}; @@ -61,12 +61,49 @@ fn group_kind(g: &El) -> Option { }) } +/// Excel's file notation for "this workbook" in front of a workbook-scoped defined name. +const THIS_BOOK: &str = "[0]!"; + +/// A chart reference (``) read from a file → model text: drops a leading `=` and the +/// `[0]!` in front of workbook-scoped names (`[0]!Sales` → `Sales`), which the formula parser +/// would take for a structured reference. External books (`[1]Sheet1!$A$1`), quoted sheet +/// names and string literals are kept as they are. +pub(crate) fn chart_ref_from_file(text: &str) -> String { + let t = text.trim(); + let t = t.strip_prefix('=').unwrap_or(t); + let mut out = String::with_capacity(t.len()); + let mut quote: Option = None; + // The last character kept: `[0]!` only counts at the start or after an operator, + // never inside a structured reference (`T[[0]]`) or a longer name. + let mut prev: Option = None; + let mut i = 0; + while let Some(c) = t.get(i..).and_then(|r| r.chars().next()) { + let rest = t.get(i..).unwrap_or(""); + if quote.is_none() + && rest.starts_with(THIS_BOOK) + && prev.is_none_or(|p| !(p.is_alphanumeric() || "_.\\[]'\"!$#".contains(p))) + && rest.get(THIS_BOOK.len()..).and_then(|n| n.chars().next()).is_some_and(|n| n.is_alphabetic() || n == '_' || n == '\\') + { + i += THIS_BOOK.len(); + continue; + } + match (c, quote) { + ('"' | '\'', None) => quote = Some(c), + (q, Some(open)) if q == open => quote = None, + _ => {} + } + out.push(c); + prev = Some(c); + i += c.len_utf8(); + } + out +} + fn data_ref(e: Option<&El>) -> Option { let e = e?; for k in ["numRef", "strRef", "multiLvlStrRef"] { if let Some(f) = e.child(k).and_then(|r| r.child("f")) { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(chart_ref_from_file(&f.text)); } } // Literal values become an array constant. @@ -84,14 +121,9 @@ fn data_ref(e: Option<&El>) -> Option { } fn read_series(s: &El) -> Series { - let name = s.child("tx").and_then(|tx| { - if let Some(f) = tx.path(&["strRef", "f"]) { - let t = f.text.trim(); - Some(t.strip_prefix('=').unwrap_or(t).to_string()) - } else { - tx.child("v").map(|v| v.text.clone()) - } - }); + let name = s + .child("tx") + .and_then(|tx| tx.path(&["strRef", "f"]).map(|f| chart_ref_from_file(&f.text)).or_else(|| tx.child("v").map(|v| v.text.clone()))); let sp = s.child("spPr"); let color = sp .and_then(|p| p.child("solidFill")) @@ -306,6 +338,41 @@ fn is_ref(f: &str) -> bool { matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_))) } +/// A chart reference that is a name: its sheet when qualified (`Sheet1!Sales`), the bare +/// name, and the definition formulas would find from sheet `si` (none when undefined). `None` +/// when it isn't a name, or names a sheet the workbook doesn't have. +fn chart_name<'a>(wb: &'a Workbook, si: usize, f: &str) -> Option<(Option, String, Option<&'a DefinedName>)> { + let Ok(Expr::Name(n)) = gridcraft_formula::parse(f.trim()) else { return None }; + match n.split_once('!') { + Some((s, bare)) => { + let i = wb.sheet_index(s.trim_matches('\''))?; + Some((Some(i), bare.to_string(), wb.name(bare, i))) + } + None => { + let def = wb.name(&n, si); + Some((None, n, def)) + } + } +} + +/// A chart reference for ``. Excel needs a name qualified there: with a bare `Name` it +/// draws the whole chart blank, with no repair prompt (observed in Excel for Windows, +/// Microsoft 365). A workbook-scoped name is written `[0]!Name`, as Excel writes it, a +/// sheet-scoped one `Sheet!Name`; an undefined name gets `[0]!`, as the file it came from had. +fn chart_ref_to_file(wb: &Workbook, si: usize, f: &str) -> String { + let on_sheet = |i: usize, n: &str| wb.sheet(i).map(|sh| format!("{}!{n}", gridcraft_formula::quote_sheet(&sh.name))); + match chart_name(wb, si, f) { + Some((Some(i), n, _)) => on_sheet(i, &n).unwrap_or_else(|| crate::fmla::text_to_file(f)), + Some((None, n, def)) => def.and_then(|d| d.scope).and_then(|i| on_sheet(i, &n)).unwrap_or_else(|| format!("{THIS_BOOK}{n}")), + None => crate::fmla::text_to_file(f), + } +} + +/// A series name that refers to a defined name (as opposed to literal text that parses as one). +fn is_defined_name(wb: &Workbook, si: usize, f: &str) -> bool { + chart_name(wb, si, f).is_some_and(|(_, _, d)| d.is_some()) +} + fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let f = f.unwrap_or("").trim(); if f.is_empty() { @@ -322,7 +389,7 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "General", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -383,7 +450,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let _ = write!(s, ""); return s; } - let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); + let mut s = format!("{}", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "", vals.len()); for (i, v) in vals.iter().enumerate() { @@ -400,7 +467,7 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); if let Some(n) = &s.name { - if is_ref(n) { + if is_ref(n) || is_defined_name(wb, si, n) { x.push_str(&str_data("tx", n, wb, si)); } else { let lit = n.trim().strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(n); diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index 2ab8c221b..e9f8729a4 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -48,15 +48,13 @@ fn kind_of(layout: &str) -> Option { fn formula(dim: &El) -> Option { let f = dim.child("f")?; - let t = f.text.trim(); - Some(crate::fmla::from_file(t.strip_prefix('=').unwrap_or(t))) + Some(crate::fmla::from_file(&crate::chart::chart_ref_from_file(&f.text))) } fn text(tx: &El) -> Option { if let Some(d) = tx.child("txData") { if let Some(f) = d.child("f") { - let t = f.text.trim(); - return Some(t.strip_prefix('=').unwrap_or(t).to_string()); + return Some(crate::chart::chart_ref_from_file(&f.text)); } return d.child("v").map(|v| v.text.clone()); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 1a479081c..ee93c1b77 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -265,7 +265,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { continue; } if lname.starts_with("_xlchart.") { - chart_names.push((lname, crate::fmla::from_file(text))); + chart_names.push((lname, crate::fmla::from_file(&crate::chart::chart_ref_from_file(text)))); continue; } if lname == "_xlnm.print_area" || lname == "_xlnm.print_titles" { diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs new file mode 100644 index 000000000..3182cbae1 --- /dev/null +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -0,0 +1,139 @@ +//! `[0]!Name` ("this workbook") in chart references: read as the bare name, written back for +//! workbook-scoped names only. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, Chart, ChartKind, DefinedName, LegendPos, Series, Sheet, Workbook}; + +use crate::chart::chart_ref_from_file; +use crate::{read_xlsx, write_xlsx}; + +#[test] +fn this_book_prefix_is_dropped_only_in_front_of_names() { + for (file, model) in [ + ("[0]!TaskNames", "TaskNames"), + ("=[0]!TaskNames", "TaskNames"), + (" [0]!_Hidden ", "_Hidden"), + ("([0]!a,[0]!b)", "(a,b)"), + // External books keep their index: they go through externalLink parts. + ("[1]Sheet1!$A$1:$A$5", "[1]Sheet1!$A$1:$A$5"), + ("[2]!Rates", "[2]!Rates"), + // Not a name after the prefix, or not at a name's start. + ("[0]!$A$1", "[0]!$A$1"), + ("Plan[[0]]", "Plan[[0]]"), + // Inside a quoted sheet name or a string. + ("'[0]!x'!$A$1", "'[0]!x'!$A$1"), + ("\"[0]!x\"", "\"[0]!x\""), + ("'It''s [0]!'!$A$1", "'It''s [0]!'!$A$1"), + ("Sheet1!$B$2:$B$9", "Sheet1!$B$2:$B$9"), + ("", ""), + ("[0]!", "[0]!"), + ("[0]!é", "é"), + ] { + assert_eq!(chart_ref_from_file(file), model, "{file}"); + } +} + +fn series(name: &str, values: &str) -> Series { + Series { + name: Some(name.into()), + categories: Some("Items".into()), + values: values.into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + } +} + +fn name(n: &str, scope: Option, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope, formula: formula.into(), comment: String::new(), hidden: false } +} + +/// Excel draws a chart blank when a `` holds a bare name (observed in Excel for Windows, +/// Microsoft 365, with no repair prompt); `[0]!Name` and `Sheet!Name` both plot. +#[test] +fn chart_names_are_written_qualified() { + let mut wb = Workbook::default(); + let mut s = Sheet::new("Data"); + s.charts.push(Chart { + id: 1, + kind: ChartKind::BarStacked, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![ + series("Title", "Amounts"), + series("Data!$B$1", "Local"), + series("\"Plain\"", "Data!$B$2:$B$4"), + series("'My Data'!Other", "'My Data'!Other"), + series("Literal", "NoSuchName"), + series("Gone!Thing", "Gone!Thing"), + ], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))]; + wb.names = vec![ + name("Items", None, "Data!$A$2:$A$4"), + name("Amounts", None, "OFFSET(Items,0,1)"), + name("Title", None, "Data!$B$1"), + name("Local", Some(0), "Data!$C$2:$C$4"), + name("Other", Some(1), "'My Data'!$A$1:$A$3"), + ]; + let bytes = write_xlsx(&wb).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap(); + for f in [ + // Workbook-scoped: `[0]!`, as Excel writes it. + "[0]!Items", + "[0]!Amounts", + "[0]!Title", + // Sheet-scoped: qualified with its sheet. + "Data!Local", + "'My Data'!Other", + "'My Data'!Other", + // Undefined: `[0]!`, as a file that referred to it had. + "[0]!NoSuchName", + // Literal series names stay literal. + "Plain", + "Literal", + // An unknown sheet is kept as written, never re-qualified. + "Gone!Thing", + ] { + assert!(xml.contains(f), "{f} missing from {xml}"); + } + for bare in ["Local", "Items", "Amounts", "Other", "NoSuchName"] { + assert!(!xml.contains(bare), "bare {bare} in {xml}"); + } + + let (back, _) = read_xlsx(&bytes).unwrap(); + let got: Vec<(Option<&str>, Option<&str>, &str)> = + back.sheets[0].charts[0].series.iter().map(|s| (s.name.as_deref(), s.categories.as_deref(), s.values.as_str())).collect(); + assert_eq!( + got, + [ + (Some("Title"), Some("Items"), "Amounts"), + (Some("Data!$B$1"), Some("Items"), "Data!Local"), + (Some("Plain"), Some("Items"), "Data!$B$2:$B$4"), + (Some("'My Data'!Other"), Some("Items"), "'My Data'!Other"), + (Some("Literal"), Some("Items"), "NoSuchName"), + (Some("Gone!Thing"), Some("Items"), "Gone!Thing"), + ] + ); + // A second save writes the same references. + let mut again = String::new(); + let bytes2 = write_xlsx(&back).unwrap(); + zip::ZipArchive::new(std::io::Cursor::new(&bytes2)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut again).unwrap(); + let refs = |x: &str| x.split("").skip(1).filter_map(|p| p.split_once("").map(|(f, _)| f.to_string())).collect::>(); + assert_eq!(refs(&again), refs(&xml)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index d5e18041c..d8c5ea876 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,6 +2,7 @@ mod array_export; mod cellmetadata; +mod chart_book_names; mod chart_bool_default; mod csv_tests; mod encrypted_tests; diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 9058b0935..6fcc2880c 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (series that refer to workbook-scoped defined names (`[0]!Name` in the file) plot their data instead of `#NAME?` (#1686); previously trivial: chart sheets are kept on save, still not editable (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -66,6 +66,13 @@ extensions) is lost when GridCraft saves a workbook it opened. That is the main **~30% ready**: a user who opens an Excel workbook with charts and saves it gets simpler charts back. +Series data, categories and names may refer to defined names, as dynamic (OFFSET) ranges do: +Excel writes a workbook-scoped name as `[0]!Name` in ``. GridCraft reads it as the bare +name and writes it back with `[0]!`; a sheet-scoped name is written `Sheet!Name`. Excel needs the +qualifier: with a bare name in `` it draws the whole chart blank, without a repair prompt +(observed in Excel for Windows, Microsoft 365). External books (`[1]Sheet1!…`) are kept as they +are (#1686). Such series get no `numCache`/`strCache` on save (#1690). + ## Work (40–60 h) 1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h. @@ -80,6 +87,7 @@ back. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9185dd4dd..bfadedc39 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -101,6 +101,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-18 | trivial | `file.open {csvAsText: true}` keeps every CSV/TSV/TXT field as the text the file holds, so identifiers (GTINs, SKUs, ZIP codes) keep their digits and leading zeros (#1808; a preference and a CLI flag are separate work) | | 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | +| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`. Names in chart references are written qualified (`[0]!Name`, or `Sheet!Name` for a sheet-scoped name): Excel draws a chart with a bare name blank (#1686) | | 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection |