mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
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:
@@ -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(" ");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod array_export;
|
||||
mod cellmetadata;
|
||||
mod chart_book_names;
|
||||
mod chart_bool_default;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
|
||||
Reference in New Issue
Block a user