Merge PR #1693: Charts: series that refer to defined names ([0]!Name) plot their data, and are saved qualified

crates/xlsx/src/tests/mod.rs: kept both mod lines (cellmetadata, chart_book_names)
docs/file-format-parity.md: kept HEAD header line, both change rows
This commit is contained in:
aiongg
2026-10-22 02:36:34 +00:00
9 changed files with 380 additions and 22 deletions
+12 -2
View File
@@ -156,13 +156,23 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
.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<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
return parts.join(" ");
+134
View File
@@ -0,0 +1,134 @@
//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name`
//! in `<c:f>`; 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<u8> {
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 `<c:f>` blank.
let xml = chart_xml(&bytes);
for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] {
assert!(xml.contains(&format!("<c:f>{f}</c:f>")), "{f} missing from {xml}");
}
assert!(!xml.contains("<c:f>Task"), "a bare name in {xml}");
// Series names that are defined names stay references, qualified or not.
assert!(xml.contains("<c:tx><c:strRef><c:f>Sheet1!DoneTitle</c:f>"), "{xml}");
// A series name that is a defined name stays a reference, not literal text.
assert!(!xml.contains("<c:v>LeftTitle</c:v>"));
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<String> = (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<Option<f64>> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect();
assert_eq!(data.series[0].values, start);
let left: Vec<Option<f64>> = (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");
}
+81 -14
View File
@@ -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<ChartKind> {
})
}
/// Excel's file notation for "this workbook" in front of a workbook-scoped defined name.
const THIS_BOOK: &str = "[0]!";
/// A chart reference (`<c:f>`) 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<char> = 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<char> = 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<String> {
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<String> {
}
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<usize>, 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 `<c:f>`. 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, "</c:numLit></c:{tag}>");
return s;
}
let mut s = format!("<c:{tag}><c:numRef><c:f>{}</c:f>", esc(&crate::fmla::text_to_file(f)));
let mut s = format!("<c:{tag}><c:numRef><c:f>{}</c:f>", esc(&chart_ref_to_file(wb, si, f)));
if let Some(vals) = ref_values(wb, si, f) {
let _ = write!(s, "<c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>", 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, "</c:strLit></c:{tag}>");
return s;
}
let mut s = format!("<c:{tag}><c:strRef><c:f>{}</c:f>", esc(&crate::fmla::text_to_file(f)));
let mut s = format!("<c:{tag}><c:strRef><c:f>{}</c:f>", esc(&chart_ref_to_file(wb, si, f)));
if let Some(vals) = ref_values(wb, si, f) {
let _ = write!(s, "<c:strCache><c:ptCount val=\"{}\"/>", 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!("<c:ser><c:idx val=\"{idx}\"/><c:order val=\"{idx}\"/>");
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);
+2 -4
View File
@@ -48,15 +48,13 @@ fn kind_of(layout: &str) -> Option<ChartKind> {
fn formula(dim: &El) -> Option<String> {
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<String> {
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());
}
+1 -1
View File
@@ -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" {
+139
View File
@@ -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<usize>, formula: &str) -> DefinedName {
DefinedName { name: n.into(), scope, formula: formula.into(), comment: String::new(), hidden: false }
}
/// Excel draws a chart blank when a `<c:f>` 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.
"<c:f>[0]!Items</c:f>",
"<c:f>[0]!Amounts</c:f>",
"<c:tx><c:strRef><c:f>[0]!Title</c:f>",
// Sheet-scoped: qualified with its sheet.
"<c:f>Data!Local</c:f>",
"<c:tx><c:strRef><c:f>'My Data'!Other</c:f>",
"<c:val><c:numRef><c:f>'My Data'!Other</c:f>",
// Undefined: `[0]!`, as a file that referred to it had.
"<c:f>[0]!NoSuchName</c:f>",
// Literal series names stay literal.
"<c:tx><c:v>Plain</c:v></c:tx>",
"<c:tx><c:v>Literal</c:v></c:tx>",
// An unknown sheet is kept as written, never re-qualified.
"<c:f>Gone!Thing</c:f>",
] {
assert!(xml.contains(f), "{f} missing from {xml}");
}
for bare in ["<c:f>Local</c:f>", "<c:f>Items</c:f>", "<c:f>Amounts</c:f>", "<c:f>Other</c:f>", "<c:f>NoSuchName</c:f>"] {
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("<c:f>").skip(1).filter_map(|p| p.split_once("</c:f>").map(|(f, _)| f.to_string())).collect::<Vec<_>>();
assert_eq!(refs(&again), refs(&xml));
}
+1
View File
@@ -2,6 +2,7 @@
mod array_export;
mod cellmetadata;
mod chart_book_names;
mod chart_bool_default;
mod csv_tests;
mod encrypted_tests;