Merge PR #1685: #1447: a PivotChart's link names the workbook

docs/file-format-parity.md: header Change line names both pivot fixes; both change log rows kept
This commit is contained in:
aiongg
2026-10-22 02:36:11 +00:00
8 changed files with 79 additions and 8 deletions
+2 -1
View File
@@ -139,8 +139,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
let fmt = str_param(p, "format").unwrap_or("xlsx");
let d = s.doc()?;
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
// The file name goes into a PivotChart's link to its PivotTable.
let bytes = crate::io::save_bytes(&d.wb, &name)?;
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
}
+4 -1
View File
@@ -207,7 +207,10 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xlsx => {
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
+41
View File
@@ -0,0 +1,41 @@
//! #1447: a PivotChart is bound to its PivotTable behind the workbook's name.
use std::io::Read;
use gridcraft_engine::Session;
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) -> Json {
s.execute(command, params).unwrap()
}
fn source() -> Session {
let mut s = Session::new();
s.new_workbook();
run(
&mut s,
"range.setValues",
json!({"range": "A1", "values": [["Region ", " Sales ", "Qty"], ["East", 10, 1], ["West", 20, 2], ["East", 5, 3]]}),
);
s
}
#[test]
fn pivot_chart_link_names_the_workbook_in_brackets() {
let mut s = source();
run(&mut s, "selection.set", json!({"range": "A1"}));
run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));
run(&mut s, "insert.pivotChart", json!({}));
let bytes = gridcraft_engine::io::save_bytes(&s.doc().unwrap().wb, "Q1 Report.xlsx").unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut chart = String::new();
zip.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut chart).unwrap();
assert!(chart.contains("<c:name>[Q1 Report.xlsx]Sheet2!PivotTable1</c:name>"), "{chart}");
// Without a file name, a placeholder workbook is named; reading drops the brackets again.
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
let mut reopened = Session::new();
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());
assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1"));
}
+11 -4
View File
@@ -236,7 +236,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
source: None,
by_rows: false,
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
pivot: root.path(&["pivotSource", "name"]).map(|n| strip_book(n.text.trim()).to_string()).filter(|n| !n.is_empty()),
})
}
@@ -610,17 +610,24 @@ fn axis_xml(a: &Ax<'_>) -> String {
s
}
/// A PivotChart link without the workbook in brackets: `[Book.xlsx]Sheet2!PT` → `Sheet2!PT`.
fn strip_book(link: &str) -> &str {
link.strip_prefix('[').and_then(|r| r.split_once(']')).map_or(link, |(_, rest)| rest)
}
/// Chart part XML for a model chart on sheet `si`.
pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><c:roundedCorners val=\"0\"/>",
);
if (1..=48).contains(&ch.style) {
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
}
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
// A PivotChart names its PivotTable behind the workbook in brackets (`[Book.xlsx]Sheet2!PivotTable1`);
// without the brackets Excel shows an ordinary chart.
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
let link = strip_book(link);
let _ = write!(s, "<c:pivotSource><c:name>[{}]{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(book_name), esc(link));
}
s.push_str("<c:chart>");
match &ch.title {
+6
View File
@@ -138,6 +138,12 @@ pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError>
write::write_xlsx(wb, kind)
}
/// Writes a workbook as the given member of the `.xlsx` family for the file `book_name` (a bare
/// file name, used for the workbook in a PivotChart's link to its PivotTable).
pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: &str) -> Result<Vec<u8>, IoError> {
write::write_xlsx_named(wb, kind, Some(book_name))
}
/// CSV import options.
#[derive(Clone, Debug, PartialEq)]
pub struct CsvOptions {
+1 -1
View File
@@ -522,7 +522,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
} else {
let name = format!("xl/charts/chart{}.xml", out.charts);
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch).into_bytes());
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes());
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
};
objs.push(Obj::Chart(ch, rid));
+12
View File
@@ -113,6 +113,8 @@ pub struct Out {
pub sst: Sst,
pub images: u32,
pub charts: u32,
/// The workbook's file name, which a PivotChart's link to its PivotTable names in brackets.
pub book_name: String,
/// References of chartex parts, defined as hidden `_xlchart.v1.N` names.
pub chart_names: Vec<String>,
pub drawings: u32,
@@ -185,7 +187,16 @@ fn check_sheet_names(wb: &Workbook) -> Result<(), IoError> {
Ok(())
}
/// Workbook name written in a PivotChart's link when the file name isn't known.
const DEFAULT_BOOK_NAME: &str = "Workbook.xlsx";
pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
write_xlsx_named(wb, kind, None)
}
/// Writes the workbook for the file `book_name` (a bare file name). A PivotChart is bound to its
/// PivotTable in Excel only when its link starts with the workbook in brackets.
pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) -> Result<Vec<u8>, IoError> {
if wb.sheets.is_empty() {
return Err(IoError::Format("a workbook needs at least one sheet".into()));
}
@@ -201,6 +212,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
sst: Sst::default(),
images: 0,
charts: 0,
book_name: book_name.filter(|n| !n.is_empty() && !n.contains(['[', ']'])).unwrap_or(DEFAULT_BOOK_NAME).to_string(),
chart_names: vec![],
drawings: 0,
tables: 0,