M4: PivotTable XLSX round-trip — cache definitions/records, date grouping, pivotTable parts, reader with warnings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db4b00064c
commit
b8ff9648a9
|
|
@ -15,6 +15,7 @@ mod csv;
|
|||
mod drawing;
|
||||
mod fmla;
|
||||
mod package;
|
||||
mod pivot;
|
||||
mod read;
|
||||
mod sheet_read;
|
||||
mod sheet_write;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -24,6 +24,8 @@ pub struct Ctx<'a> {
|
|||
/// Threaded-comment person id → display name.
|
||||
pub persons: std::collections::HashMap<String, String>,
|
||||
pub next_id: u32,
|
||||
/// Custom number formats from the styles part, by id.
|
||||
pub num_fmts: std::collections::HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
|
|
@ -73,6 +75,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
styles: StyleTable::default(),
|
||||
persons: Default::default(),
|
||||
next_id: 1,
|
||||
num_fmts: Default::default(),
|
||||
};
|
||||
let root_rels = cx.pkg.rels("")?;
|
||||
let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into());
|
||||
|
|
@ -98,7 +101,8 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
// Styles and theme.
|
||||
if let Some(r) = find_rel(&wb_rels, "styles").cloned() {
|
||||
if let Some(x) = cx.optional_xml(&r.target)? {
|
||||
let StylesIn { xfs, dxfs, palette, named } = read_styles(&x);
|
||||
let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x);
|
||||
cx.num_fmts = num_fmts;
|
||||
cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect();
|
||||
cx.dxfs = dxfs;
|
||||
cx.palette = palette;
|
||||
|
|
@ -169,6 +173,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
|
||||
// Sheets. `file_to_model[i]` maps the i-th <sheet> to a model sheet index.
|
||||
let mut file_to_model: Vec<Option<usize>> = vec![];
|
||||
let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels);
|
||||
let sheet_els: Vec<El> = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default();
|
||||
for (i, se) in sheet_els.iter().enumerate() {
|
||||
let name = se.attr("name").unwrap_or("").to_string();
|
||||
|
|
@ -192,6 +197,11 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
Sheet::new(name.clone())
|
||||
}
|
||||
};
|
||||
match crate::pivot::read_pivots(&mut cx, &mut pivot_caches, &rel.target, &name) {
|
||||
Ok(p) => sheet.pivots = p,
|
||||
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
|
||||
Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")),
|
||||
}
|
||||
sheet.visibility = match se.attr("state") {
|
||||
Some("hidden") => Visibility::Hidden,
|
||||
Some("veryHidden") => Visibility::VeryHidden,
|
||||
|
|
@ -266,9 +276,6 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
if wb_rels.iter().any(|r| r.kind == "vbaProject") {
|
||||
cx.warn("macros (VBA project) are not supported and were dropped");
|
||||
}
|
||||
if wb_rels.iter().any(|r| r.kind == "pivotCacheDefinition") {
|
||||
cx.warn("pivot tables are not supported; their cached values were kept as plain cells");
|
||||
}
|
||||
if wb_rels.iter().any(|r| r.kind == "externalLink") {
|
||||
cx.warn("links to external workbooks are not supported");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct StylesIn {
|
|||
pub palette: Vec<u32>,
|
||||
/// Named cell styles (`cellStyles` with their `cellStyleXfs`).
|
||||
pub named: Vec<(String, Style)>,
|
||||
/// Custom number formats (`numFmts`) by id.
|
||||
pub num_fmts: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
pub fn read_color(e: &El, palette: &[u32]) -> Color {
|
||||
|
|
@ -368,7 +370,7 @@ pub fn read_styles(root: &El) -> StylesIn {
|
|||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
StylesIn { xfs, dxfs, palette, named }
|
||||
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
|
@ -566,7 +568,9 @@ impl NumFmts {
|
|||
|
||||
/// `styles.xml` for the workbook: one cell format per model style, in `StyleId` order (so a
|
||||
/// cell's `s` attribute is its style id), plus the given differential formats.
|
||||
pub fn write_styles(wb: &Workbook, dxfs: &[Style]) -> String {
|
||||
/// `extra_fmts` are number formats used outside cell styles (PivotTable value fields); the
|
||||
/// returned map gives the id of every custom format written.
|
||||
pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (String, HashMap<String, u32>) {
|
||||
let mut nf = NumFmts { custom: vec![], map: HashMap::new() };
|
||||
let mut fonts: Vec<Font> = vec![];
|
||||
let mut font_map = HashMap::new();
|
||||
|
|
@ -668,6 +672,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style]) -> String {
|
|||
}
|
||||
dx.push_str("</dxf>");
|
||||
}
|
||||
for f in extra_fmts {
|
||||
nf.id(f);
|
||||
}
|
||||
|
||||
let mut s = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
||||
|
|
@ -696,7 +703,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style]) -> String {
|
|||
let _ = write!(s, "<cellStyles count=\"{named}\">{cell_styles}</cellStyles>");
|
||||
let _ = write!(s, "<dxfs count=\"{}\">{dx}</dxfs>", dxfs.len());
|
||||
s.push_str("<tableStyles count=\"0\" defaultTableStyle=\"TableStyleMedium2\" defaultPivotStyle=\"PivotStyleLight16\"/></styleSheet>");
|
||||
s
|
||||
(s, nf.map)
|
||||
}
|
||||
|
||||
/// Index of a differential format, adding it when new.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
mod csv_tests;
|
||||
mod fixtures;
|
||||
mod malformed;
|
||||
mod pivot_tests;
|
||||
mod roundtrip;
|
||||
|
||||
use std::io::Write;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
//! PivotTables: write → read round trips, package structure, hostile pivot parts.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sheetcraft_core::date::serial_from_ymd;
|
||||
use sheetcraft_core::{CellRef, RangeRef, Value};
|
||||
use sheetcraft_model::*;
|
||||
|
||||
use super::minimal;
|
||||
use crate::xml;
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
CellRef::parse(a).unwrap()
|
||||
}
|
||||
fn rr(a: &str) -> RangeRef {
|
||||
RangeRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
fn date(y: i64, m: i64, d: i64) -> f64 {
|
||||
serial_from_ymd(sheetcraft_core::DateSystem::D1900, y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn field(name: &str) -> PivotField {
|
||||
PivotField { source_col: name.into(), ..PivotField::default() }
|
||||
}
|
||||
|
||||
/// Source data on "Data" (A1:F9, also table "SalesTbl" and defined name "SalesData"), three
|
||||
/// PivotTables on "Report" with a few hand-written report cells.
|
||||
fn workbook() -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
let date_style = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("m/d/yyyy"));
|
||||
let data = wb.sheet_mut(0).unwrap();
|
||||
data.name = "Data".into();
|
||||
let headers = ["Region", "Product", "Date", "Units", "Sales", "Channel"];
|
||||
for (c, h) in headers.iter().enumerate() {
|
||||
data.cells.set(CellRef::new(0, c as u32), Cell::value(Value::text(*h)));
|
||||
}
|
||||
let rows: [(&str, &str, f64, f64, f64, &str); 8] = [
|
||||
("East", "A", date(2024, 1, 5), 3.0, 30.5, "Online"),
|
||||
("West", "B", date(2024, 2, 9), 5.0, 50.0, "Store"),
|
||||
("East", "B", date(2024, 3, 1), 2.0, 20.0, "Online"),
|
||||
("North", "C", date(2025, 4, 12), 7.0, 70.25, "Store"),
|
||||
("west", "A", date(2025, 5, 30), 1.0, 10.0, "Online"),
|
||||
("East", "C", date(2025, 6, 2), 4.0, 40.0, "Phone"),
|
||||
("North", "A", date(2024, 7, 7), 6.0, 60.0, "Online"),
|
||||
("West", "C", date(2025, 12, 31), 8.0, 80.0, "Store"),
|
||||
];
|
||||
for (r, (a, b, d, u, s, ch)) in rows.iter().enumerate() {
|
||||
let r = r as u32 + 1;
|
||||
data.cells.set(CellRef::new(r, 0), Cell::value(Value::text(*a)));
|
||||
data.cells.set(CellRef::new(r, 1), Cell::value(Value::text(*b)));
|
||||
data.cells.set(CellRef::new(r, 2), Cell { value: Value::number(*d), formula: None, style: date_style });
|
||||
data.cells.set(CellRef::new(r, 3), Cell::value(Value::number(*u)));
|
||||
data.cells.set(CellRef::new(r, 4), Cell::value(Value::number(*s)));
|
||||
data.cells.set(CellRef::new(r, 5), Cell::value(Value::text(*ch)));
|
||||
}
|
||||
data.tables.push(Table {
|
||||
id: 1,
|
||||
name: "SalesTbl".into(),
|
||||
range: rr("A1:F9"),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: headers.iter().map(|h| TableColumn { name: (*h).into(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
|
||||
style: "TableStyleMedium2".into(),
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
});
|
||||
wb.names.push(DefinedName { name: "SalesData".into(), scope: None, formula: "Data!$A$1:$F$9".into(), comment: String::new(), hidden: false });
|
||||
|
||||
let mut report = Sheet::new("Report");
|
||||
// Pivot 1: tabular, page filter, custom and built-in value formats, collapsed item.
|
||||
let p1 = PivotTable {
|
||||
id: 1,
|
||||
name: "Sales by Region".into(),
|
||||
source: "Data!$A$1:$F$9".into(),
|
||||
anchor: at("A4"),
|
||||
rows: vec![PivotField { source_col: "Region".into(), sort: PivotSort::Asc, collapsed_items: vec!["East".into()], ..PivotField::default() }],
|
||||
columns: vec![PivotField { sort: PivotSort::None, ..field("Product") }],
|
||||
values: vec![
|
||||
PivotValue {
|
||||
source_col: "Sales".into(),
|
||||
func: PivotFunc::Sum,
|
||||
name: "Sum of Sales".into(),
|
||||
number_format: Some("#,##0.00".into()),
|
||||
..PivotValue::default()
|
||||
},
|
||||
PivotValue {
|
||||
source_col: "Units".into(),
|
||||
func: PivotFunc::Count,
|
||||
name: "Count of Units".into(),
|
||||
number_format: Some("0.0\" u\"".into()),
|
||||
..PivotValue::default()
|
||||
},
|
||||
],
|
||||
filters: vec![PivotFilter { source_col: "Channel".into(), selected: Some(vec!["Online".into()]) }],
|
||||
layout: PivotLayout::Tabular,
|
||||
grand_totals_rows: false,
|
||||
grand_totals_cols: true,
|
||||
subtotals: false,
|
||||
subtotals_top: false,
|
||||
style: "PivotStyleMedium9".into(),
|
||||
show_headers: false,
|
||||
last_range: Some(rr("A2:H12")),
|
||||
};
|
||||
// Pivot 2: table source, date grouping on two levels, show-values-as, multi-select page
|
||||
// filter and a hidden-items filter on a column field.
|
||||
let p2 = PivotTable {
|
||||
id: 2,
|
||||
name: "Trend".into(),
|
||||
source: "SalesTbl".into(),
|
||||
anchor: at("K4"),
|
||||
rows: vec![
|
||||
PivotField { source_col: "Date".into(), date_group: PivotDateGroup::Years, sort: PivotSort::None, collapsed_items: vec!["2024".into()] },
|
||||
PivotField { source_col: "Date".into(), date_group: PivotDateGroup::Months, sort: PivotSort::Asc, collapsed_items: vec![] },
|
||||
],
|
||||
columns: vec![PivotField { sort: PivotSort::Desc, ..field("Product") }],
|
||||
values: vec![
|
||||
PivotValue {
|
||||
source_col: "Sales".into(),
|
||||
func: PivotFunc::Sum,
|
||||
name: "Share".into(),
|
||||
show_as: PivotShowAs::PercentOfGrandTotal,
|
||||
number_format: None,
|
||||
},
|
||||
PivotValue { source_col: "Sales".into(), func: PivotFunc::Max, name: "Rank".into(), show_as: PivotShowAs::Rank, number_format: None },
|
||||
PivotValue {
|
||||
source_col: "Sales".into(),
|
||||
func: PivotFunc::Average,
|
||||
name: "Running".into(),
|
||||
show_as: PivotShowAs::RunningTotal,
|
||||
number_format: None,
|
||||
},
|
||||
PivotValue {
|
||||
source_col: "Units".into(),
|
||||
func: PivotFunc::StdDevP,
|
||||
name: "Spread".into(),
|
||||
show_as: PivotShowAs::PercentOfColumnTotal,
|
||||
number_format: None,
|
||||
},
|
||||
PivotValue {
|
||||
source_col: "Units".into(),
|
||||
func: PivotFunc::VarP,
|
||||
name: "RowPct".into(),
|
||||
show_as: PivotShowAs::PercentOfRowTotal,
|
||||
number_format: None,
|
||||
},
|
||||
],
|
||||
filters: vec![
|
||||
PivotFilter { source_col: "Region".into(), selected: Some(vec!["East".into(), "North".into()]) },
|
||||
PivotFilter { source_col: "Product".into(), selected: Some(vec!["A".into(), "B".into()]) },
|
||||
],
|
||||
layout: PivotLayout::Outline,
|
||||
grand_totals_rows: true,
|
||||
grand_totals_cols: false,
|
||||
subtotals: true,
|
||||
subtotals_top: true,
|
||||
style: "PivotStyleDark3".into(),
|
||||
show_headers: true,
|
||||
last_range: Some(rr("K2:Z30")),
|
||||
};
|
||||
// Pivot 3: defaults, defined-name source, compact.
|
||||
let p3 = PivotTable {
|
||||
id: 3,
|
||||
name: "PivotTable3".into(),
|
||||
source: "SalesData".into(),
|
||||
anchor: at("AB1"),
|
||||
rows: vec![field("Product")],
|
||||
values: vec![PivotValue { source_col: "Units".into(), func: PivotFunc::Product, name: "Product of Units".into(), ..PivotValue::default() }],
|
||||
filters: vec![PivotFilter { source_col: "Channel".into(), selected: None }],
|
||||
last_range: Some(rr("AB1:AC6")),
|
||||
..PivotTable::default()
|
||||
};
|
||||
report.pivots = vec![p1, p2, p3];
|
||||
// Hand-written report cells (the engine's output stays plain cells).
|
||||
report.cells.set(at("A2"), Cell::value(Value::text("Channel")));
|
||||
report.cells.set(at("B2"), Cell::value(Value::text("Online")));
|
||||
report.cells.set(at("A5"), Cell::value(Value::text("East")));
|
||||
report.cells.set(at("B5"), Cell::value(Value::number(50.5)));
|
||||
wb.sheets.push(Arc::new(report));
|
||||
wb
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> HashMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut m = HashMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
m.insert(f.name().to_string(), b);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivots_round_trip() {
|
||||
let wb = workbook();
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let (back, report) = read_xlsx(&bytes).unwrap();
|
||||
assert!(report.warnings.iter().all(|w| !w.to_lowercase().contains("pivot")), "{:?}", report.warnings);
|
||||
let orig = &wb.sheet(1).unwrap().pivots;
|
||||
let read = &back.sheet(1).unwrap().pivots;
|
||||
assert_eq!(read.len(), 3);
|
||||
for (a, b) in orig.iter().zip(read.iter()) {
|
||||
assert_eq!(a, b, "pivot {} differs", a.name);
|
||||
}
|
||||
// Report cells stay as written.
|
||||
let s = back.sheet(1).unwrap();
|
||||
assert_eq!(s.value(at("B5")), Value::number(50.5));
|
||||
assert_eq!(s.value(at("A5")), Value::text("East"));
|
||||
// A second trip is stable.
|
||||
let (again, _) = read_xlsx(&write_xlsx(&back).unwrap()).unwrap();
|
||||
assert_eq!(&again.sheet(1).unwrap().pivots, read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_package_structure() {
|
||||
let bytes = write_xlsx(&workbook()).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
let ct = xml::parse(parts.get("[Content_Types].xml").unwrap()).unwrap();
|
||||
let overrides: HashMap<String, String> = ct
|
||||
.kids("Override")
|
||||
.map(|o| (o.attr("PartName").unwrap().trim_start_matches('/').to_string(), o.attr("ContentType").unwrap().to_string()))
|
||||
.collect();
|
||||
let defaults: HashSet<String> = ct.kids("Default").map(|d| d.attr("Extension").unwrap().to_string()).collect();
|
||||
// Every override names an existing part; every part has a content type.
|
||||
for p in overrides.keys() {
|
||||
assert!(parts.contains_key(p), "override for missing part {p}");
|
||||
}
|
||||
for p in parts.keys() {
|
||||
if p == "[Content_Types].xml" {
|
||||
continue;
|
||||
}
|
||||
let ext = p.rsplit('.').next().unwrap();
|
||||
assert!(overrides.contains_key(p) || defaults.contains(ext), "no content type for {p}");
|
||||
}
|
||||
let count = |ct: &str| overrides.values().filter(|v| v.as_str() == ct).count();
|
||||
assert_eq!(count(crate::pivot::CT_PIVOT_TABLE), 3);
|
||||
assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_DEF), 3);
|
||||
assert_eq!(count(crate::pivot::CT_PIVOT_CACHE_REC), 3);
|
||||
// Every relationship target exists.
|
||||
let mut kinds: Vec<String> = vec![];
|
||||
for (name, data) in &parts {
|
||||
if !name.ends_with(".rels") {
|
||||
continue;
|
||||
}
|
||||
let dir = name.trim_end_matches(".rels").rsplit_once("/_rels/").map(|(d, _)| d).unwrap_or("");
|
||||
let rels = xml::parse(data).unwrap();
|
||||
for r in rels.kids("Relationship") {
|
||||
if r.attr("TargetMode") == Some("External") {
|
||||
continue;
|
||||
}
|
||||
let target = crate::package::resolve(dir, r.attr("Target").unwrap());
|
||||
assert!(parts.contains_key(&target), "{name} points at missing {target}");
|
||||
kinds.push(r.attr("Type").unwrap().rsplit('/').next().unwrap().to_string());
|
||||
}
|
||||
}
|
||||
for k in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] {
|
||||
assert!(kinds.iter().any(|x| x == k), "no {k} relationship");
|
||||
}
|
||||
// The workbook lists the caches by id with relationships of the right type.
|
||||
let wbx = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
let wb_rels = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap();
|
||||
let caches: Vec<&xml::El> = wbx.child("pivotCaches").unwrap().kids("pivotCache").collect();
|
||||
assert_eq!(caches.len(), 3);
|
||||
let mut ids = HashSet::new();
|
||||
for c in &caches {
|
||||
assert!(ids.insert(c.attr("cacheId").unwrap().to_string()));
|
||||
let rid = c.attr("id").unwrap();
|
||||
let rel = wb_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap();
|
||||
assert!(rel.attr("Type").unwrap().ends_with("/pivotCacheDefinition"));
|
||||
}
|
||||
// Each table's cacheId is one of the workbook's caches and its field counts agree.
|
||||
for t in 1..=3 {
|
||||
let pt = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{t}.xml")).unwrap()).unwrap();
|
||||
assert!(ids.contains(pt.attr("cacheId").unwrap()));
|
||||
let trels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels")).unwrap()).unwrap();
|
||||
let target = crate::package::resolve("xl/pivotTables", trels.kids("Relationship").next().unwrap().attr("Target").unwrap());
|
||||
let cache = xml::parse(parts.get(&target).unwrap()).unwrap();
|
||||
let nf = cache.child("cacheFields").unwrap().kids("cacheField").count();
|
||||
assert_eq!(pt.child("pivotFields").unwrap().kids("pivotField").count(), nf);
|
||||
assert_eq!(cache.child("cacheFields").unwrap().attr_u32("count"), Some(nf as u32));
|
||||
// Item indices stay inside the shared/group items.
|
||||
for (i, pf) in pt.child("pivotFields").unwrap().kids("pivotField").enumerate() {
|
||||
let cf = cache.child("cacheFields").unwrap().kids("cacheField").nth(i).unwrap();
|
||||
let n = cf
|
||||
.path(&["fieldGroup", "groupItems"])
|
||||
.map(|g| g.children.len())
|
||||
.unwrap_or_else(|| cf.child("sharedItems").map(|s| s.children.len()).unwrap_or(0));
|
||||
if let Some(items) = pf.child("items") {
|
||||
assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32));
|
||||
for it in items.kids("item") {
|
||||
if let Some(x) = it.attr_u32("x") {
|
||||
assert!((x as usize) < n, "item x={x} out of {n} in table {t} field {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Records match the database fields.
|
||||
if let Some(rid) = cache.attr("id") {
|
||||
let crels_name = target.replace("pivotCache/", "pivotCache/_rels/") + ".rels";
|
||||
let crels = xml::parse(parts.get(&crels_name).unwrap()).unwrap();
|
||||
let rel = crels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap();
|
||||
let rec = xml::parse(parts.get(&crate::package::resolve("xl/pivotCache", rel.attr("Target").unwrap())).unwrap()).unwrap();
|
||||
let db = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count();
|
||||
assert_eq!(rec.attr_u32("count"), cache.attr_u32("recordCount"));
|
||||
assert_eq!(rec.kids("r").count(), 8);
|
||||
for r in rec.kids("r") {
|
||||
assert_eq!(r.children.len(), db);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sheet 2 (Report) relates to all three tables.
|
||||
let srels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap();
|
||||
assert_eq!(srels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_xml_details() {
|
||||
let parts = unzip(&write_xlsx(&workbook()).unwrap());
|
||||
let t1 = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap();
|
||||
assert!(
|
||||
t1.contains("<location ref=\"A4:H12\" firstHeaderRow=\"1\" firstDataRow=\"3\" firstDataCol=\"1\" rowPageCount=\"1\" colPageCount=\"1\"/>"),
|
||||
"{t1}"
|
||||
);
|
||||
assert!(t1.contains("<field x=\"-2\"/>"));
|
||||
assert!(t1.contains("subtotal=\"count\""));
|
||||
assert!(t1.contains("numFmtId=\"4\""));
|
||||
assert!(t1.contains("axis=\"axisPage\""));
|
||||
assert!(t1.contains("<pivotTableStyleInfo name=\"PivotStyleMedium9\""));
|
||||
let c1 = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition1.xml").unwrap().clone()).unwrap();
|
||||
assert!(c1.contains("<worksheetSource ref=\"A1:F9\" sheet=\"Data\"/>"), "{c1}");
|
||||
assert!(c1.contains("refreshOnLoad=\"1\""));
|
||||
let c2 = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition2.xml").unwrap().clone()).unwrap();
|
||||
assert!(c2.contains("<worksheetSource name=\"SalesTbl\"/>"));
|
||||
assert!(c2.contains("groupBy=\"years\"") && c2.contains("groupBy=\"months\""));
|
||||
let t2 = String::from_utf8(parts.get("xl/pivotTables/pivotTable2.xml").unwrap().clone()).unwrap();
|
||||
assert!(t2.contains("pivotShowAs=\"rankDescending\""));
|
||||
assert!(t2.contains("showDataAs=\"runTotal\""));
|
||||
// The custom value format is registered in the styles part.
|
||||
let styles = String::from_utf8(parts.get("xl/styles.xml").unwrap().clone()).unwrap();
|
||||
assert!(styles.contains("formatCode=\"0.0" u"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_pivot_source_is_skipped() {
|
||||
let mut wb = workbook();
|
||||
let s = wb.sheet_mut(1).unwrap();
|
||||
s.pivots[0].source = "Nowhere!A1:B2".into();
|
||||
s.pivots.truncate(1);
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert!(back.sheet(1).unwrap().pivots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_cache_is_written_without_records() {
|
||||
let mut wb = Workbook::new();
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.cells.set(at("A1"), Cell::value(Value::text("K")));
|
||||
s.cells.set(at("B1"), Cell::value(Value::text("V")));
|
||||
s.cells.set(at("C1"), Cell::value(Value::text("W")));
|
||||
// 700,000 records x 3 fields is over the records limit.
|
||||
for r in 1..=700_000u32 {
|
||||
s.cells.set(CellRef::new(r, 1), Cell::value(Value::number(r as f64)));
|
||||
s.cells.set(CellRef::new(r, 2), Cell::value(Value::number(0.5)));
|
||||
s.cells.set(CellRef::new(r, 0), Cell::value(Value::number((r % 3) as f64)));
|
||||
}
|
||||
s.pivots.push(PivotTable {
|
||||
id: 1,
|
||||
source: "Sheet1!$A$1:$C$700001".into(),
|
||||
anchor: at("E1"),
|
||||
rows: vec![field("K")],
|
||||
values: vec![PivotValue { source_col: "V".into(), name: "Sum of V".into(), ..PivotValue::default() }],
|
||||
last_range: Some(rr("E1:F5")),
|
||||
..PivotTable::default()
|
||||
});
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
assert!(!parts.keys().any(|k| k.contains("pivotCacheRecords")));
|
||||
let c = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition1.xml").unwrap().clone()).unwrap();
|
||||
assert!(c.contains("saveData=\"0\""));
|
||||
assert!(c.contains("<sharedItems containsSemiMixedTypes=\"0\" containsString=\"0\" containsNumber=\"1\" containsInteger=\"1\" minValue=\"0\" maxValue=\"2\" count=\"3\">"), "{}", &c[..c.len().min(2000)]);
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(back.sheet(0).unwrap().pivots.len(), 1);
|
||||
}
|
||||
|
||||
const PT_RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable" Target="../pivotTables/pivotTable1.xml"/></Relationships>"#;
|
||||
const PT_CACHE_RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition" Target="../pivotCache/pivotCacheDefinition1.xml"/></Relationships>"#;
|
||||
|
||||
fn with_pivot(table: &str, cache: Option<&str>) -> Vec<u8> {
|
||||
let mut extra: Vec<(&str, &str)> = vec![
|
||||
("xl/worksheets/_rels/sheet1.xml.rels", PT_RELS),
|
||||
("xl/pivotTables/pivotTable1.xml", table),
|
||||
("xl/pivotTables/_rels/pivotTable1.xml.rels", PT_CACHE_RELS),
|
||||
];
|
||||
if let Some(c) = cache {
|
||||
extra.push(("xl/pivotCache/pivotCacheDefinition1.xml", c));
|
||||
}
|
||||
minimal("<sheetData/>", &extra, "", "")
|
||||
}
|
||||
|
||||
const GOOD_CACHE: &str = r#"<pivotCacheDefinition xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cacheSource type="worksheet"><worksheetSource ref="A1:B3"/></cacheSource><cacheFields count="2"><cacheField name="K"><sharedItems><s v="x"/><n v="2"/><m/><b v="1"/><d v="2024-01-01T00:00:00"/></sharedItems></cacheField><cacheField name="V"><sharedItems/><fieldGroup base="0"><rangePr startNum="0" endNum="10" groupInterval="5"/><groupItems><s v="0-4"/></groupItems></fieldGroup></cacheField></cacheFields></pivotCacheDefinition>"#;
|
||||
|
||||
#[test]
|
||||
fn hostile_pivot_parts_warn_not_panic() {
|
||||
let tables = [
|
||||
"garbage <<<",
|
||||
r#"<notAPivot/>"#,
|
||||
r#"<pivotTableDefinition name="P" cacheId="1"/>"#,
|
||||
r#"<pivotTableDefinition name="P" cacheId="1"><location ref="nonsense"/></pivotTableDefinition>"#,
|
||||
r#"<pivotTableDefinition name="" cacheId="9"><location ref="$D$3:$F$9"/><pivotFields count="1"><pivotField axis="axisRow" sortType="descending"><items><item x="99" h="1" sd="0"/><item x="-4"/><item t="default"/></items></pivotField></pivotFields><rowFields><field x="0"/><field x="4294967295"/><field x="-2"/><field x="77"/></rowFields><colFields><field x="1"/></colFields><pageFields><pageField fld="0" item="9999"/><pageField fld="-1"/><pageField fld="55"/></pageFields><dataFields><dataField fld="1" subtotal="bogus" showDataAs="index" numFmtId="99999"/><dataField fld="12"/><dataField name="E" fld="0"><extLst><ext><dataField pivotShowAs="percentOfParent"/></ext></extLst></dataField></dataFields></pivotTableDefinition>"#,
|
||||
];
|
||||
let caches = [
|
||||
None,
|
||||
Some("garbage"),
|
||||
Some(r#"<pivotCacheDefinition><cacheSource type="external"/></pivotCacheDefinition>"#),
|
||||
Some(r#"<pivotCacheDefinition><cacheSource type="worksheet"><worksheetSource ref="zz"/></cacheSource></pivotCacheDefinition>"#),
|
||||
Some(r#"<pivotCacheDefinition><cacheSource type="worksheet"><worksheetSource ref="A1:B2"/></cacheSource></pivotCacheDefinition>"#),
|
||||
Some(r#"<somethingElse/>"#),
|
||||
Some(GOOD_CACHE),
|
||||
];
|
||||
for t in &tables {
|
||||
for c in &caches {
|
||||
let bytes = with_pivot(t, *c);
|
||||
let (wb, report) = read_xlsx(&bytes).unwrap();
|
||||
let pivots = &wb.sheet(0).unwrap().pivots;
|
||||
if pivots.is_empty() {
|
||||
assert!(!report.warnings.is_empty(), "no warning for table {t} / cache {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fully hostile table against a good cache still yields a pivot, with warnings.
|
||||
let (wb, report) = read_xlsx(&with_pivot(tables[4], Some(GOOD_CACHE))).unwrap();
|
||||
let pt = &wb.sheet(0).unwrap().pivots[0];
|
||||
assert_eq!(pt.anchor, at("D3"));
|
||||
assert_eq!(pt.source, "Data!$A$1:$B$3");
|
||||
assert_eq!(pt.rows.len(), 1);
|
||||
assert_eq!(pt.rows[0].sort, PivotSort::Desc);
|
||||
assert!(pt.values.iter().any(|v| v.func == PivotFunc::Sum && v.source_col == "V"));
|
||||
assert!(report.warnings.iter().any(|w| w.contains("bogus")), "{:?}", report.warnings);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("grouping")), "{:?}", report.warnings);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("index")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_found_by_workbook_cache_id() {
|
||||
let rels_extra = r#"<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition" Target="pivotCache/pivotCacheDefinition1.xml"/>"#;
|
||||
let table = r#"<pivotTableDefinition name="ById" cacheId="7"><location ref="C1:D4"/><pivotFields><pivotField axis="axisRow"><items><item x="0"/><item x="1"/><item t="default"/></items></pivotField><pivotField dataField="1"/></pivotFields><rowFields><field x="0"/></rowFields><dataFields><dataField name="Total" fld="1" subtotal="average"/></dataFields></pivotTableDefinition>"#;
|
||||
let bytes = minimal(
|
||||
"<sheetData/>",
|
||||
&[
|
||||
("xl/worksheets/_rels/sheet1.xml.rels", PT_RELS),
|
||||
("xl/pivotTables/pivotTable1.xml", table),
|
||||
("xl/pivotCache/pivotCacheDefinition1.xml", GOOD_CACHE),
|
||||
],
|
||||
rels_extra,
|
||||
"",
|
||||
);
|
||||
// `minimal` puts wb_extra before <sheets>; pivotCaches belongs after, but readers locate it by name.
|
||||
let bytes2 = {
|
||||
let mut parts = unzip(&bytes);
|
||||
let wbx = String::from_utf8(parts.remove("xl/workbook.xml").unwrap()).unwrap();
|
||||
let wbx = wbx.replace("</workbook>", r#"<pivotCaches><pivotCache cacheId="7" r:id="rId9"/></pivotCaches></workbook>"#);
|
||||
let mut list: Vec<(String, Vec<u8>)> = parts.into_iter().collect();
|
||||
list.push(("xl/workbook.xml".into(), wbx.into_bytes()));
|
||||
let refs: Vec<(&str, &[u8])> = list.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
|
||||
super::make_zip(&refs)
|
||||
};
|
||||
let (wb, report) = read_xlsx(&bytes2).unwrap();
|
||||
let pts = &wb.sheet(0).unwrap().pivots;
|
||||
assert_eq!(pts.len(), 1, "{:?}", report.warnings);
|
||||
assert_eq!(pts[0].name, "ById");
|
||||
assert_eq!(pts[0].values[0].func, PivotFunc::Average);
|
||||
assert_eq!(pts[0].rows[0].source_col, "K");
|
||||
}
|
||||
|
|
@ -157,9 +157,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
|||
};
|
||||
|
||||
let mut wb_rels = Rels::default();
|
||||
let mut pivots = crate::pivot::PivotWriter::default();
|
||||
let mut sheets_xml = String::new();
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let (xml, rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out);
|
||||
let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out);
|
||||
pivots.add_sheet(wb, i, &mut out, &mut rels);
|
||||
let name = format!("xl/worksheets/sheet{}.xml", i + 1);
|
||||
out.part(&name, Some(CT_WORKSHEET), xml.into_bytes());
|
||||
if !rels.is_empty() {
|
||||
|
|
@ -241,10 +243,13 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
|||
if out.needs_calc {
|
||||
w.push_str(" fullCalcOnLoad=\"1\"");
|
||||
}
|
||||
w.push_str("/></workbook>");
|
||||
w.push_str("/>");
|
||||
w.push_str(&pivots.workbook_xml(&mut wb_rels));
|
||||
w.push_str("</workbook>");
|
||||
|
||||
// Workbook-level parts.
|
||||
let styles = crate::styles::write_styles(wb, &out.dxfs);
|
||||
let (styles, fmt_ids) = crate::styles::write_styles(wb, &out.dxfs, &pivots.number_formats(wb));
|
||||
pivots.finish(wb, &mut out, &|code: &str| crate::tables::builtin_id(code).or_else(|| fmt_ids.get(code).copied()));
|
||||
out.part("xl/styles.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"), styles.into_bytes());
|
||||
wb_rels.add("styles", "styles.xml");
|
||||
out.part(
|
||||
|
|
|
|||
Loading…
Reference in New Issue