diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 287a36e..a17f7f4 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -15,6 +15,7 @@ mod csv; mod drawing; mod fmla; mod package; +mod pivot; mod read; mod sheet_read; mod sheet_write; diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs new file mode 100644 index 0000000..892bfbf --- /dev/null +++ b/crates/xlsx/src/pivot.rs @@ -0,0 +1,1336 @@ +//! PivotTables: pivot cache definitions and records (ECMA-376 Part 1 §18.10.1) and PivotTable +//! definitions (§18.10.1.73), written from the model's [`PivotTable`]s and read back into them. +//! +//! Writing: one cache per PivotTable, with a records snapshot of the source list (omitted, with +//! `saveData="0"`, for very large sources) and `refreshOnLoad="1"` so a reader recomputes the +//! report from the source on open. Fields used on an axis get shared items (or, for date +//! grouping, `fieldGroup` group items) so the table's `` can refer to them. The report +//! cells the engine wrote stay on the sheet as plain cells. +//! +//! Reading: the cache's source and field names plus the table's areas, functions, show-values-as, +//! layout flags, grand totals and style become a model [`PivotTable`]. Anything we don't model +//! (OLAP sources, calculated fields, numeric grouping…) becomes a warning. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use sheetcraft_core::date::datetime_from_serial; +use sheetcraft_core::{CellRef, DateSystem, RangeRef, Value, number_to_text}; +use sheetcraft_formula::quote_sheet; +use sheetcraft_model::{PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Workbook}; + +use crate::IoError; +use crate::read::Ctx; +use crate::write::{NS_MAIN, NS_REL, Out, Rels, XML_DECL, abs_range}; +use crate::xml::{El, esc_attr}; + +pub const CT_PIVOT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"; +pub const CT_PIVOT_CACHE_DEF: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"; +pub const CT_PIVOT_CACHE_REC: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"; + +/// Largest source block scanned for a cache (cells); bigger sources are not written. +const MAX_SOURCE_CELLS: u64 = 10_000_000; +/// Largest records snapshot written (cells); bigger caches are written without records. +const MAX_RECORD_CELLS: u64 = 2_000_000; +/// Most shared items kept per field (Excel's limit is 1,048,576 unique items). +const MAX_ITEMS: usize = 1_048_576; +/// Most cache fields read. +const MAX_FIELDS: usize = 16_384; +/// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank). +const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}"; +const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +// ================================================================ shared helpers + +/// The pivot item label of a value (blank shows as "(blank)"). +fn label(v: &Value) -> String { + match v { + Value::Empty => "(blank)".into(), + Value::Number(n) => number_to_text(*n), + Value::Text(t) => t.to_string(), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(), + Value::Error(e) => e.as_str().into(), + Value::Array(_) => label(&v.scalar()), + } +} + +fn same(a: &str, b: &str) -> bool { + a.to_lowercase() == b.to_lowercase() +} + +fn func_name(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "sum", + PivotFunc::Count => "count", + PivotFunc::Average => "average", + PivotFunc::Max => "max", + PivotFunc::Min => "min", + PivotFunc::Product => "product", + PivotFunc::CountNumbers => "countNums", + PivotFunc::StdDev => "stdDev", + PivotFunc::StdDevP => "stdDevp", + PivotFunc::Var => "var", + PivotFunc::VarP => "varp", + } +} + +fn func_from(s: &str) -> Option { + Some(match s { + "sum" => PivotFunc::Sum, + "count" => PivotFunc::Count, + "average" => PivotFunc::Average, + "max" => PivotFunc::Max, + "min" => PivotFunc::Min, + "product" => PivotFunc::Product, + "countNums" => PivotFunc::CountNumbers, + "stdDev" => PivotFunc::StdDev, + "stdDevp" => PivotFunc::StdDevP, + "var" => PivotFunc::Var, + "varp" => PivotFunc::VarP, + _ => return None, + }) +} + +fn func_caption(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "Sum", + PivotFunc::Count => "Count", + PivotFunc::Average => "Average", + PivotFunc::Max => "Max", + PivotFunc::Min => "Min", + PivotFunc::Product => "Product", + PivotFunc::CountNumbers => "Count", + PivotFunc::StdDev => "StdDev", + PivotFunc::StdDevP => "StdDevp", + PivotFunc::Var => "Var", + PivotFunc::VarP => "Varp", + } +} + +fn group_by_name(g: PivotDateGroup) -> &'static str { + match g { + PivotDateGroup::Years => "years", + PivotDateGroup::Quarters => "quarters", + PivotDateGroup::Months => "months", + PivotDateGroup::Days => "days", + PivotDateGroup::None => "range", + } +} + +fn group_caption(g: PivotDateGroup) -> &'static str { + match g { + PivotDateGroup::Years => "Years", + PivotDateGroup::Quarters => "Quarters", + PivotDateGroup::Months => "Months", + PivotDateGroup::Days => "Days", + PivotDateGroup::None => "Group", + } +} + +/// `2024-03-05T00:00:00` for a date serial. +fn iso(sys: DateSystem, serial: f64) -> Option { + let d = datetime_from_serial(sys, serial)?; + Some(format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", d.year, d.month, d.day, d.hour, d.minute, d.second)) +} + +/// `3/5/2024` for a date serial (group boundary captions). +fn mdy(sys: DateSystem, serial: f64) -> String { + match datetime_from_serial(sys, serial) { + Some(d) => format!("{}/{}/{}", d.month, d.day, d.year), + None => number_to_text(serial), + } +} + +// ================================================================ writing + +/// Identity of a shared item (text items match case-insensitively, as in Excel). +#[derive(Clone, PartialEq, Eq, Hash)] +enum Key { + Blank, + Num(u64), + Text(String), + Bool(bool), + Err(&'static str), +} + +fn key(v: &Value) -> Key { + match v { + Value::Empty => Key::Blank, + Value::Number(n) => Key::Num(if *n == 0.0 { 0 } else { n.to_bits() }), + Value::Text(t) => Key::Text(t.to_lowercase()), + Value::Bool(b) => Key::Bool(*b), + Value::Error(e) => Key::Err(e.as_str()), + Value::Array(_) => key(&v.scalar()), + } +} + +#[derive(Default)] +struct Stats { + blank: bool, + text: bool, + number: bool, + non_int: bool, + boolean: bool, + error: bool, + long: bool, + min: f64, + max: f64, +} + +impl Stats { + fn add(&mut self, v: &Value) { + match v { + Value::Empty => self.blank = true, + Value::Number(n) => { + if !self.number { + self.min = *n; + self.max = *n; + } + self.number = true; + self.min = self.min.min(*n); + self.max = self.max.max(*n); + if n.fract() != 0.0 { + self.non_int = true; + } + } + Value::Text(t) => { + self.text = true; + if t.chars().count() > 255 { + self.long = true; + } + } + Value::Bool(_) => self.boolean = true, + Value::Error(_) => self.error = true, + Value::Array(_) => self.add(&v.scalar()), + } + } + fn kinds(&self) -> usize { + [self.text, self.number, self.boolean, self.error].iter().filter(|b| **b).count() + } +} + +/// Date grouping of a cache field. +struct Group { + by: PivotDateGroup, + /// The database field the grouping applies to. + base: usize, + /// The next (coarser) grouping field of the same base. + par: Option, + start: f64, + end: f64, + /// Group item captions, including the `end` items. + items: Vec, +} + +struct CField { + name: String, + /// Column offset in the source (database fields only). + col: Option, + stats: Stats, + /// Shared items (written when `itemized`). + items: Vec, + index: HashMap, + itemized: bool, + group: Option, +} + +impl CField { + /// Item captions the PivotTable's `` indices refer to. + fn item_labels(&self) -> Vec { + match &self.group { + Some(g) => g.items.clone(), + None => self.items.iter().map(label).collect(), + } + } +} + +/// A cache built from a PivotTable's source. +struct Cache { + fields: Vec, + /// Pivot field → cache field, by the pivot field's position in rows, then columns. + axis_map: Vec>, + source_xml: String, + records: Option<(u64, String)>, +} + +/// PivotTables being written; the tables themselves are finished after the styles part (their +/// value number formats need style number-format ids). +#[derive(Default)] +pub struct PivotWriter { + /// (cache id, workbook-relative part name). + caches: Vec<(u32, String)>, + pending: Vec, + tables: u32, +} + +struct Pending { + part: String, + sheet: usize, + idx: usize, + cache_id: u32, + cache: Cache, +} + +impl PivotWriter { + /// Writes the caches of a sheet's PivotTables and adds the sheet's relationships to its + /// PivotTable parts. Pivots whose source can't be resolved are not written. + pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { + let Some(sheet) = wb.sheet(si) else { return }; + for (idx, pt) in sheet.pivots.iter().enumerate() { + let Some(cache) = build_cache(wb, pt, si) else { continue }; + let n = self.caches.len() as u32 + 1; + let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); + let rec_rid = if let Some((count, xml)) = &cache.records { + let mut r = Rels::default(); + let rid = r.add("pivotCacheRecords", &format!("pivotCacheRecords{n}.xml")); + out.part(&format!("xl/pivotCache/_rels/pivotCacheDefinition{n}.xml.rels"), None, r.xml().into_bytes()); + let s = format!("{XML_DECL}{xml}"); + out.part(&format!("xl/pivotCache/pivotCacheRecords{n}.xml"), Some(CT_PIVOT_CACHE_REC), s.into_bytes()); + Some(rid) + } else { + None + }; + out.part(&cache_part, Some(CT_PIVOT_CACHE_DEF), cache_xml(&cache, rec_rid.as_deref(), wb.date_system).into_bytes()); + self.caches.push((n, format!("pivotCache/pivotCacheDefinition{n}.xml"))); + self.tables += 1; + let t = self.tables; + rels.add("pivotTable", &format!("../pivotTables/pivotTable{t}.xml")); + let mut tr = Rels::default(); + tr.add("pivotCacheDefinition", &format!("../pivotCache/pivotCacheDefinition{n}.xml")); + out.part(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels"), None, tr.xml().into_bytes()); + self.pending.push(Pending { part: format!("xl/pivotTables/pivotTable{t}.xml"), sheet: si, idx, cache_id: n, cache }); + } + } + + /// Adds the workbook relationships to the caches; returns the `` element (empty + /// when there are none). + pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { + if self.caches.is_empty() { + return String::new(); + } + let mut s = String::from(""); + for (id, target) in &self.caches { + let rid = wb_rels.add("pivotCacheDefinition", target); + let _ = write!(s, ""); + } + s.push_str(""); + s + } + + /// Number formats used by value fields (to be registered in the styles part). + pub fn number_formats(&self, wb: &Workbook) -> Vec { + let mut v = Vec::new(); + for p in &self.pending { + if let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) { + for val in &pt.values { + if let Some(f) = &val.number_format + && !v.contains(f) + { + v.push(f.clone()); + } + } + } + } + v + } + + /// Writes the PivotTable parts. `fmt_id` maps a number format code to its styles id. + pub fn finish(self, wb: &Workbook, out: &mut Out, fmt_id: &dyn Fn(&str) -> Option) { + for p in self.pending { + let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) else { continue }; + let xml = table_xml(pt, p.idx, p.cache_id, &p.cache, fmt_id); + out.part(&p.part, Some(CT_PIVOT_TABLE), xml.into_bytes()); + } + } +} + +/// Resolves a pivot source to (sheet, range including the header row, worksheetSource XML). +fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Option<(usize, RangeRef, String)> { + let t = text.trim().trim_start_matches('=').trim(); + if t.is_empty() || depth > 4 { + return None; + } + if let Some((si, ti)) = wb.table(t) + && let Some(sh) = wb.sheet(si) + && let Some(tb) = sh.tables.get(ti) + { + if !tb.header_row { + return None; + } + let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); + let r = RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)); + // Table names are sanitized when written; refer by range when this one would change. + let valid = !tb.name.is_empty() + && tb.name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') + && tb.name.starts_with(|c: char| c.is_alphabetic() || c == '_') + && CellRef::parse(&tb.name).is_none(); + let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; + return Some((si, r, xml)); + } + let (sheet, body) = match t.rsplit_once('!') { + Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body), + None => (None, t), + }; + if let Some(r) = RangeRef::parse(body) { + let si = match &sheet { + Some(n) => wb.sheet_index(n)?, + None => default_sheet, + }; + let sh = wb.sheet(si)?; + let r = if r.is_full_cols() || r.is_full_rows() { r.intersection(&sh.used_range()?)? } else { r }; + return Some((si, r, range_source_xml(&sh.name, r))); + } + if sheet.is_none() + && let Some(n) = wb.name(t, default_sheet) + { + let (si, r, _) = resolve_source(wb, &n.formula.clone(), default_sheet, depth + 1)?; + let xml = if n.scope.is_none() { + format!("", esc_attr(&n.name)) + } else { + wb.sheet(si).map(|s| range_source_xml(&s.name, r))? + }; + return Some((si, r, xml)); + } + None +} + +fn range_source_xml(sheet: &str, r: RangeRef) -> String { + let name: String = sheet.chars().take(31).collect(); + format!("", r.a1(), esc_attr(&name)) +} + +fn header_text(v: &Value) -> String { + match v { + Value::Empty => String::new(), + v => label(v).trim().to_string(), + } +} + +fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { + let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; + if r.count() > MAX_SOURCE_CELLS { + return None; + } + let sh = wb.sheet(si)?; + let mut fields: Vec = Vec::new(); + for (k, c) in (r.start.col..=r.end.col).enumerate() { + let mut h = header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()); + if h.is_empty() { + h = format!("Column{}", k + 1); + } + let mut n = h.clone(); + let mut i = 2; + while fields.iter().any(|f| same(&f.name, &n)) { + n = format!("{h}{i}"); + i += 1; + } + fields.push(CField { + name: n, + col: Some(k as u32), + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: None, + }); + } + let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + + // Records (rows that aren't entirely blank), read once. + let mut rows: Vec = Vec::new(); + if r.end.row > r.start.row { + for row in r.start.row + 1..=r.end.row { + let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); + if !blank { + rows.push(row); + } + } + } + for &row in &rows { + for f in fields.iter_mut() { + let c = r.start.col + f.col.unwrap_or(0); + f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); + } + } + + // Which fields are on an axis, and how dates are grouped: per base column, the base field + // takes the finest grouping and coarser ones become extra (non-database) fields chained + // through `par`, as Excel lays them out. + let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); + let groupable = |f: &CField| { + let s = &f.stats; + s.number + && !s.text + && !s.boolean + && !s.error + && datetime_from_serial(wb.date_system, s.min).is_some() + && datetime_from_serial(wb.date_system, s.max).is_some() + }; + // (base column field, groupings fine → coarse) + let mut groupings: Vec<(usize, Vec)> = Vec::new(); + for pf in &axis { + let Some(b) = find(&fields, &pf.source_col) else { continue }; + if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { + continue; + } + match groupings.iter_mut().find(|(x, _)| *x == b) { + Some((_, v)) => { + if !v.contains(&pf.date_group) { + v.push(pf.date_group); + } + } + None => groupings.push((b, vec![pf.date_group])), + } + } + let fineness = |g: &PivotDateGroup| match g { + PivotDateGroup::Days => 0, + PivotDateGroup::Months => 1, + PivotDateGroup::Quarters => 2, + PivotDateGroup::Years => 3, + PivotDateGroup::None => 4, + }; + for (b, list) in groupings.iter_mut() { + list.sort_by_key(fineness); + let (min, max) = fields.get(*b).map(|f| (f.stats.min, f.stats.max)).unwrap_or((0.0, 0.0)); + let (start, end) = (min.floor(), max.floor() + 1.0); + let make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(wb.date_system, by, start, end) }; + let mut prev = *b; + for (k, by) in list.iter().enumerate() { + if k == 0 { + if let Some(f) = fields.get_mut(*b) { + f.group = Some(make(*by)); + } + continue; + } + let new = fields.len(); + let caption = group_caption(*by); + let mut n = caption.to_string(); + let mut i = 2; + while fields.iter().any(|f| same(&f.name, &n)) { + n = format!("{caption}{i}"); + i += 1; + } + if let Some(g) = fields.get_mut(prev).and_then(|f| f.group.as_mut()) { + g.par = Some(new); + } + fields.push(CField { + name: n, + col: None, + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: Some(make(*by)), + }); + prev = new; + } + } + let mut axis_map: Vec> = Vec::with_capacity(axis.len()); + for pf in &axis { + let Some(b) = find(&fields, &pf.source_col) else { + axis_map.push(None); + continue; + }; + let grouped = fields.iter().position(|f| f.group.as_ref().is_some_and(|g| g.base == b && g.by == pf.date_group)); + match grouped { + Some(i) if pf.date_group != PivotDateGroup::None => axis_map.push(Some(i)), + _ => { + if let Some(f) = fields.get_mut(b) + && f.group.is_none() + { + f.itemized = true; + } + axis_map.push(Some(b)); + } + } + } + // Page fields (filters not on an axis) need items too. + for f in &pt.filters { + let on_axis = axis.iter().any(|a| same(&a.source_col, &f.source_col)); + if !on_axis + && let Some(i) = find(&fields, &f.source_col) + && let Some(cf) = fields.get_mut(i) + { + cf.itemized = true; + } + } + for f in fields.iter_mut() { + if f.group.is_some() { + f.itemized = false; + } else if f.col.is_some() && (f.stats.text || f.stats.boolean || f.stats.error) { + f.itemized = true; + } + } + // Shared items, in order of first appearance. + for &row in &rows { + for f in fields.iter_mut().filter(|f| f.itemized) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + let k = key(&v); + if !f.index.contains_key(&k) && f.items.len() < MAX_ITEMS { + f.index.insert(k, f.items.len() as u32); + f.items.push(v); + } + } + } + // Records snapshot. + let db_fields = fields.iter().filter(|f| f.col.is_some()).count() as u64; + let records = if (rows.len() as u64).saturating_mul(db_fields) <= MAX_RECORD_CELLS { + let mut s = String::new(); + for &row in &rows { + s.push_str(""); + for f in fields.iter().filter(|f| f.col.is_some()) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + if f.itemized { + match f.index.get(&key(&v)) { + Some(i) => { + let _ = write!(s, ""); + } + None => s.push_str(&item_xml(&v, wb.date_system, false)), + } + } else { + s.push_str(&item_xml(&v, wb.date_system, f.group.is_some())); + } + } + s.push_str(""); + } + Some((rows.len() as u64, s)) + } else { + None + }; + Some(Cache { fields, axis_map, source_xml, records }) +} + +fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec { + let mut v = vec![format!("<{}", mdy(sys, start))]; + match by { + PivotDateGroup::Years => { + let y0 = datetime_from_serial(sys, start).map(|d| d.year).unwrap_or(1900); + let y1 = datetime_from_serial(sys, end - 1.0).map(|d| d.year).unwrap_or(y0).max(y0); + for y in y0..=y1.min(y0 + 10_000) { + v.push(y.to_string()); + } + } + PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), + PivotDateGroup::Months => v.extend(MONTHS.iter().map(|m| m.to_string())), + PivotDateGroup::Days | PivotDateGroup::None => { + const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for (m, n) in MONTHS.iter().zip(DAYS) { + for d in 1..=n { + v.push(format!("{d}-{m}")); + } + } + } + } + v.push(format!(">{}", mdy(sys, end))); + v +} + +/// A cache value element (``, ``, ``…); `date` writes numbers as ``. +fn item_xml(v: &Value, sys: DateSystem, date: bool) -> String { + match v { + Value::Empty => "".into(), + Value::Number(n) => { + if date && let Some(d) = iso(sys, *n) { + return format!(""); + } + format!("", crate::xml::num(*n)) + } + Value::Text(t) => format!("", esc_attr(t)), + Value::Bool(b) => format!("", u8::from(*b)), + Value::Error(e) => format!("", esc_attr(e.as_str())), + Value::Array(_) => item_xml(&v.scalar(), sys, date), + } +} + +fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { + let mut s = format!("{XML_DECL} { + let _ = write!(s, " r:id=\"{rid}\" recordCount=\"{count}\""); + } + _ => s.push_str(" saveData=\"0\""), + } + s.push_str(" refreshOnLoad=\"1\" refreshedBy=\"SheetCraft\" createdVersion=\"6\" refreshedVersion=\"6\" minRefreshableVersion=\"3\">"); + let _ = write!(s, "{}", c.source_xml); + let _ = write!(s, "", c.fields.len()); + for f in &c.fields { + let fmt = if f.group.is_some() { 14 } else { 0 }; + let _ = write!(s, "'); + let st = &f.stats; + if f.col.is_some() { + s.push_str(" 1 { + s.push_str(" containsMixedTypes=\"1\""); + } + if st.number { + s.push_str(" containsNumber=\"1\""); + if !st.non_int { + s.push_str(" containsInteger=\"1\""); + } + let _ = write!(s, " minValue=\"{}\" maxValue=\"{}\"", crate::xml::num(st.min), crate::xml::num(st.max)); + } + if st.long { + s.push_str(" longText=\"1\""); + } + } + if f.itemized { + let _ = write!(s, " count=\"{}\">", f.items.len()); + for v in &f.items { + s.push_str(&item_xml(v, sys, false)); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + if let Some(g) = &f.group { + s.push_str("", g.base); + let _ = write!(s, "", g.items.len()); + for it in &g.items { + let _ = write!(s, "", esc_attr(it)); + } + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { + let nf = c.fields.len(); + let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); + let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); + // Filters: on an axis field → hidden items there; otherwise a page field. + let mut hidden: HashMap = HashMap::new(); + let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); + for f in &pt.filters { + let axis_pos = pt.rows.iter().chain(pt.columns.iter()).position(|a| same(&a.source_col, &f.source_col)); + match axis_pos { + Some(p) => { + if let Some(Some(i)) = c.axis_map.get(p) { + hidden.insert(*i, f); + } + } + None => { + if let Some(i) = find_db(&f.source_col) + && !pages.iter().any(|(j, _)| *j == i) + { + pages.push((i, f)); + hidden.insert(i, f); + } + } + } + } + let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| find_db(&v.source_col).map(|i| (i, v))).collect(); + let axis_field = |i: usize| -> Option<&PivotField> { + let p = c.axis_map.iter().position(|x| *x == Some(i))?; + pt.rows.iter().chain(pt.columns.iter()).nth(p) + }; + + let compact = pt.layout == PivotLayout::Compact; + let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; + let mut s = format!( + "{XML_DECL} s.push_str(" outline=\"1\" outlineData=\"1\""), + PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), + } + if !pt.show_headers { + s.push_str(" showHeaders=\"0\""); + } + if !pt.grand_totals_rows { + s.push_str(" rowGrandTotals=\"0\""); + } + if !pt.grand_totals_cols { + s.push_str(" colGrandTotals=\"0\""); + } + s.push_str(" multipleFieldFilters=\"0\">"); + + // Location: the body (report filters sit above it). + let a = pt.anchor; + let end = pt.last_range.map(|r| r.end).unwrap_or(a); + let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); + let k = vals.len(); + let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(k > 1) }; + let first_data_col = if row_idx.is_empty() && col_idx.is_empty() { + 0 + } else if compact { + 1 + } else { + row_idx.len().max(1) + }; + let _ = write!(s, ""); + + // Fields. + let _ = write!(s, ""); + for (i, f) in c.fields.iter().enumerate() { + s.push_str(" {} + PivotLayout::Outline => s.push_str(" compact=\"0\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" outline=\"0\""), + } + if f.group.is_some() { + s.push_str(" numFmtId=\"14\""); + } + let filter = hidden.get(&i).copied(); + let labels = f.item_labels(); + let is_page = axis == Some("axisPage"); + // Hidden items; never hide every item (a field needs one visible item). + let mut hide: Vec = match filter.and_then(|f| f.selected.as_ref()) { + Some(sel) if !(is_page && sel.len() == 1) => labels.iter().map(|l| !sel.iter().any(|x| same(x, l))).collect(), + _ => vec![false; labels.len()], + }; + if !hide.is_empty() && hide.iter().all(|h| *h) { + hide.iter_mut().for_each(|h| *h = false); + } + if is_page && hide.iter().any(|h| *h) { + s.push_str(" multipleItemSelectionAllowed=\"1\""); + } + s.push_str(" showAll=\"0\""); + if let Some(af) = axis_field(i) { + match af.sort { + PivotSort::Asc => s.push_str(" sortType=\"ascending\""), + PivotSort::Desc => s.push_str(" sortType=\"descending\""), + PivotSort::None => {} + } + } + if !pt.subtotals { + s.push_str(" defaultSubtotal=\"0\""); + } + if !pt.subtotals_top { + s.push_str(" subtotalTop=\"0\""); + } + if axis.is_some() { + let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); + let count = labels.len() + usize::from(pt.subtotals); + let _ = write!(s, ">"); + for (x, l) in labels.iter().enumerate() { + let _ = write!(s, ""); + } + if pt.subtotals { + s.push_str(""); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + s.push_str(""); + + if !row_idx.is_empty() { + let _ = write!(s, "", row_idx.len()); + for i in &row_idx { + let _ = write!(s, ""); + } + s.push_str(""); + } + let values_field = k > 1; + if !col_idx.is_empty() || values_field { + let _ = write!(s, "", col_idx.len() + usize::from(values_field)); + for i in &col_idx { + let _ = write!(s, ""); + } + if values_field { + s.push_str(""); + } + s.push_str(""); + } + if !pages.is_empty() { + let _ = write!(s, "", pages.len()); + for (i, f) in &pages { + let _ = write!(s, ""); + } + s.push_str(""); + } + if !vals.is_empty() { + let _ = write!(s, ""); + let mut used: Vec = Vec::new(); + for (i, v) in &vals { + let base = if v.name.trim().is_empty() { + format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) + } else { + v.name.clone() + }; + let mut n = base.clone(); + let mut j = 2; + while used.iter().any(|u| same(u, &n)) { + n = format!("{base}{j}"); + j += 1; + } + used.push(n.clone()); + let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::RunningTotal => { + let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); + } + PivotShowAs::Rank => { + let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); + } + } + if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { + let _ = write!(s, " numFmtId=\"{id}\""); + } + if v.show_as == PivotShowAs::Rank { + let _ = write!( + s, + ">" + ); + } else { + s.push_str("/>"); + } + } + s.push_str(""); + } + s.push_str(""); + s.push_str(""); + s +} + +// ================================================================ reading + +/// A cache field as read. +#[derive(Clone, Debug, Default)] +struct FieldIn { + name: String, + database: bool, + /// Shared item captions. + items: Vec, + /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. + group: Option<(Option, Option, Vec)>, + formula: bool, +} + +impl FieldIn { + fn labels(&self) -> &[String] { + match &self.group { + Some((_, _, items)) if !items.is_empty() => items, + _ => &self.items, + } + } +} + +#[derive(Clone, Debug, Default)] +struct CacheIn { + source: String, + fields: Vec, +} + +/// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. +#[derive(Default)] +pub struct CachesIn { + by_id: HashMap, + parsed: HashMap>, + next_id: u32, +} + +impl CachesIn { + pub fn new(wb_xml: &El, wb_rels: &[crate::package::Rel]) -> CachesIn { + let mut by_id = HashMap::new(); + if let Some(pc) = wb_xml.child("pivotCaches") { + for c in pc.kids("pivotCache") { + if let (Some(id), Some(rid)) = (c.attr_u32("cacheId"), c.attr("id")) + && let Some(r) = wb_rels.iter().find(|r| r.id == rid) + { + by_id.insert(id, r.target.clone()); + } + } + } + CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } + } +} + +fn item_label_el(e: &El, sys: DateSystem) -> String { + let v = e.attr("v").unwrap_or(""); + match e.name.as_str() { + "m" => "(blank)".into(), + "n" => v.trim().parse::().ok().filter(|n| n.is_finite()).map(number_to_text).unwrap_or_else(|| v.to_string()), + "b" => if matches!(v.trim(), "1" | "true") { "TRUE" } else { "FALSE" }.into(), + "d" => crate::sheet_read::parse_iso_datetime(v, sys).map(number_to_text).unwrap_or_else(|| v.to_string()), + _ => v.to_string(), + } +} + +fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option { + let x = cx.optional_xml(part).ok().flatten()?; + if x.name != "pivotCacheDefinition" { + cx.warn(format!("{part} is not a pivot cache definition; its PivotTable was kept as plain cells")); + return None; + } + let src = x.child("cacheSource"); + let ty = src.and_then(|s| s.attr("type")).unwrap_or("worksheet"); + if ty != "worksheet" { + cx.warn(format!("PivotTables with a {ty} data source are not supported; kept as plain cells")); + return None; + } + let ws = src.and_then(|s| s.child("worksheetSource")); + let source = match ws { + Some(w) if w.attr("name").is_some_and(|n| !n.trim().is_empty()) => w.attr("name").unwrap_or("").trim().to_string(), + Some(w) => { + if w.attr("id").is_some() { + cx.warn("a PivotTable's source is in another workbook; its source reference may not resolve"); + } + let r = w.attr("ref").map(|r| r.replace('$', "")).and_then(|r| RangeRef::parse(&r)); + let Some(r) = r else { + cx.warn("a PivotTable's source reference is missing or invalid; kept as plain cells"); + return None; + }; + let sheet = w.attr("sheet").filter(|s| !s.is_empty()).unwrap_or(sheet_name); + format!("{}!{}", quote_sheet(sheet), abs_range(&r)) + } + None => { + cx.warn("a PivotTable's cache has no worksheet source; kept as plain cells"); + return None; + } + }; + let sys = cx.date_system; + let mut fields = Vec::new(); + if let Some(cf) = x.child("cacheFields") { + for f in cf.kids("cacheField").take(MAX_FIELDS) { + let items = f.child("sharedItems").map(|si| si.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + let group = f.child("fieldGroup").map(|g| { + let base = g.attr_u32("base").map(|b| b as usize); + let by = match g.child("rangePr").map(|r| r.attr("groupBy").unwrap_or("range")) { + Some("years") => Some(PivotDateGroup::Years), + Some("quarters") => Some(PivotDateGroup::Quarters), + Some("months") => Some(PivotDateGroup::Months), + Some("days") => Some(PivotDateGroup::Days), + _ => None, + }; + let gi = g.child("groupItems").map(|gi| gi.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + (base, by, gi) + }); + fields.push(FieldIn { + name: f.attr("name").unwrap_or("").to_string(), + database: f.flag("databaseField", true), + items, + group, + formula: f.attr("formula").is_some(), + }); + } + } + if fields.is_empty() { + cx.warn("a PivotTable's cache has no fields; kept as plain cells"); + return None; + } + Some(CacheIn { source, fields }) +} + +/// Reads the PivotTables related to a worksheet part. +pub fn read_pivots(cx: &mut Ctx<'_>, caches: &mut CachesIn, sheet_part: &str, sheet_name: &str) -> Result, IoError> { + let rels = cx.pkg.rels(sheet_part)?; + let mut out = Vec::new(); + for rel in rels.iter().filter(|r| r.kind == "pivotTable" && !r.external) { + let Some(x) = cx.optional_xml(&rel.target)? else { continue }; + if x.name != "pivotTableDefinition" { + cx.warn(format!("{} is not a PivotTable definition; skipped", rel.target)); + continue; + } + // The cache: the table's own relationship, else the workbook's cache id. + let trels = cx.pkg.rels(&rel.target)?; + let cache_part = trels + .iter() + .find(|r| r.kind == "pivotCacheDefinition" && !r.external) + .map(|r| r.target.clone()) + .or_else(|| x.attr_u32("cacheId").and_then(|id| caches.by_id.get(&id).cloned())); + let Some(cache_part) = cache_part else { + cx.warn(format!("PivotTable in {} has no cache; kept as plain cells", rel.target)); + continue; + }; + let cache = match caches.parsed.get(&cache_part) { + Some(c) => c.clone(), + None => { + let c = read_cache(cx, &cache_part, sheet_name); + caches.parsed.insert(cache_part.clone(), c.clone()); + c + } + }; + let Some(cache) = cache else { continue }; + if let Some(pt) = read_table(cx, &x, &cache, caches.next_id) { + caches.next_id = caches.next_id.saturating_add(1); + out.push(pt); + } + } + Ok(out) +} + +/// A pivot field's data items (not subtotal or grand-total items). +fn items_of(e: &El) -> Vec<&El> { + e.child("items").map(|it| it.kids("item").filter(|i| i.attr("t").is_none_or(|t| t == "data")).collect()).unwrap_or_default() +} + +/// (source column name, date grouping) of a cache field used on an axis. +fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { + let f = cache.fields.get(i)?; + if f.formula { + cx.warn("calculated PivotTable fields are not supported"); + return None; + } + match &f.group { + Some((base, by, _)) => { + let by = match by { + Some(b) => *b, + None => { + cx.warn(format!("PivotTable grouping of \"{}\" is not supported; the field is ungrouped", f.name)); + PivotDateGroup::None + } + }; + let base_name = if f.database { f.name.clone() } else { base.and_then(|b| cache.fields.get(b))?.name.clone() }; + Some((base_name, by)) + } + None if !f.database => { + cx.warn(format!("PivotTable field \"{}\" (grouped items) is not supported", f.name)); + None + } + None => Some((f.name.clone(), PivotDateGroup::None)), + } +} + +fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option { + let mut pt = PivotTable { id, ..PivotTable::default() }; + if let Some(n) = x.attr("name").filter(|n| !n.trim().is_empty()) { + pt.name = n.to_string(); + } else { + pt.name = format!("PivotTable{id}"); + } + pt.source = cache.source.clone(); + let Some(loc) = x.child("location").and_then(|l| l.attr("ref")).and_then(|r| RangeRef::parse(&r.replace('$', ""))) else { + cx.warn(format!("PivotTable \"{}\" has no valid location; kept as plain cells", pt.name)); + return None; + }; + let pfs: Vec<&El> = x.child("pivotFields").map(|p| p.kids("pivotField").collect()).unwrap_or_default(); + let pf = |i: usize| pfs.get(i).copied(); + let idx_list = |name: &str, attr: &str| -> Vec { + x.child(name).map(|e| e.children.iter().filter_map(|f| f.attr_i64(attr)).collect()).unwrap_or_default() + }; + let row_x = idx_list("rowFields", "x"); + let col_x = idx_list("colFields", "x"); + let pages: Vec<&El> = x.child("pageFields").map(|p| p.kids("pageField").collect()).unwrap_or_default(); + + // Layout from the first axis field (pivotField defaults: compact and outline on). + let first_axis = row_x.iter().chain(col_x.iter()).find(|i| **i >= 0).and_then(|i| pf(*i as usize)); + let (compact, outline) = match first_axis { + Some(f) => (f.flag("compact", true), f.flag("outline", true)), + None => (x.flag("compact", true), x.flag("outline", false)), + }; + pt.layout = if compact { + PivotLayout::Compact + } else if outline { + PivotLayout::Outline + } else { + PivotLayout::Tabular + }; + if let Some(f) = first_axis { + pt.subtotals = f.flag("defaultSubtotal", true); + pt.subtotals_top = f.flag("subtotalTop", true); + } + pt.grand_totals_rows = x.flag("rowGrandTotals", true); + pt.grand_totals_cols = x.flag("colGrandTotals", true); + pt.show_headers = x.flag("showHeaders", true); + pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string(); + if x.flag("dataOnRows", false) { + cx.warn(format!("PivotTable \"{}\": values on rows are shown on columns", pt.name)); + } + if x.child("calculatedItems").is_some() || x.path(&["filters", "filter"]).is_some() { + cx.warn(format!("PivotTable \"{}\": calculated items and label/value filters are not supported", pt.name)); + } + + let mut filters: Vec = Vec::new(); + + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> Vec { + let mut v = Vec::new(); + for &i in list { + if i < 0 { + continue; + } + let i = i as usize; + let Some((name, group)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let mut f = PivotField { source_col: name.clone(), date_group: group, sort: PivotSort::None, collapsed_items: vec![] }; + if let Some(e) = pf(i) { + f.sort = match e.attr("sortType") { + Some("ascending") => PivotSort::Asc, + Some("descending") => PivotSort::Desc, + _ => PivotSort::None, + }; + let items = items_of(e); + let lab = |it: &El| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned(); + f.collapsed_items = items.iter().filter(|it| !it.flag("sd", true)).filter_map(|it| lab(it)).collect(); + if items.iter().any(|it| it.flag("h", false)) { + let shown: Vec = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); + if !filters.iter().any(|ff| same(&ff.source_col, &name)) { + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + } + } + } + v.push(f); + } + v + }; + let mut page_filters = Vec::new(); + for p in &pages { + let Some(i) = p.attr_i64("fld").filter(|i| *i >= 0).map(|i| i as usize) else { continue }; + let Some((name, _)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let selected = if let Some(k) = p.attr_u32("item") { + labels.get(k as usize).map(|l| vec![l.clone()]) + } else { + pf(i).and_then(|e| { + let items = items_of(e); + items.iter().any(|it| it.flag("h", false)).then(|| { + items + .iter() + .filter(|it| !it.flag("h", false)) + .filter_map(|it| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned()) + .collect() + }) + }) + }; + page_filters.push(PivotFilter { source_col: name, selected }); + } + let mut axis_filters = Vec::new(); + pt.rows = axis(cx, &row_x, &mut axis_filters); + pt.columns = axis(cx, &col_x, &mut axis_filters); + filters.extend(page_filters); + filters.extend(axis_filters); + pt.filters = filters; + + // Values. + if let Some(df) = x.child("dataFields") { + for d in df.kids("dataField") { + let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue }; + let Some(f) = cache.fields.get(i) else { continue }; + if f.formula { + cx.warn("calculated PivotTable fields are not supported"); + continue; + } + let func = match d.attr("subtotal") { + None => PivotFunc::Sum, + Some(s) => func_from(s).unwrap_or_else(|| { + cx.warn(format!("PivotTable function {s} is not supported; using Sum")); + PivotFunc::Sum + }), + }; + let ext = d + .child("extLst") + .into_iter() + .flat_map(|l| l.kids("ext")) + .filter_map(|e| e.child("dataField")) + .find_map(|e| e.attr("pivotShowAs").map(str::to_string)); + let show_as = match (ext.as_deref(), d.attr("showDataAs")) { + (Some("rankDescending"), _) => PivotShowAs::Rank, + (Some(other), _) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + (None, None | Some("normal")) => PivotShowAs::Normal, + (None, Some("percentOfTotal")) => PivotShowAs::PercentOfGrandTotal, + (None, Some("percentOfCol")) => PivotShowAs::PercentOfColumnTotal, + (None, Some("percentOfRow")) => PivotShowAs::PercentOfRowTotal, + (None, Some("runTotal")) => PivotShowAs::RunningTotal, + (None, Some(other)) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + }; + let number_format = d + .attr_u32("numFmtId") + .filter(|id| *id != 0) + .and_then(|id| crate::tables::builtin_format(id).map(str::to_string).or_else(|| cx.num_fmts.get(&id).cloned())); + let name = d.attr("name").map(str::to_string).unwrap_or_else(|| format!("{} of {}", func_caption(func), f.name)); + pt.values.push(PivotValue { source_col: f.name.clone(), func, name, show_as, number_format }); + } + } + + pt.anchor = loc.start; + let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 }; + pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end)); + Some(pt) +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index ac2c44d..5a0ae11 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -24,6 +24,8 @@ pub struct Ctx<'a> { /// Threaded-comment person id → display name. pub persons: std::collections::HashMap, pub next_id: u32, + /// Custom number formats from the styles part, by id. + pub num_fmts: std::collections::HashMap, } 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 to a model sheet index. let mut file_to_model: Vec> = vec![]; + let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels); let sheet_els: Vec = 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"); } diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index d1dc4bc..1dbb55d 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -24,6 +24,8 @@ pub struct StylesIn { pub palette: Vec, /// Named cell styles (`cellStyles` with their `cellStyleXfs`). pub named: Vec<(String, Style)>, + /// Custom number formats (`numFmts`) by id. + pub num_fmts: HashMap, } 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) { let mut nf = NumFmts { custom: vec![], map: HashMap::new() }; let mut fonts: Vec = vec![]; let mut font_map = HashMap::new(); @@ -668,6 +672,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style]) -> String { } dx.push_str(""); } + for f in extra_fmts { + nf.id(f); + } let mut s = String::from( "\n", @@ -696,7 +703,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style]) -> String { let _ = write!(s, "{cell_styles}"); let _ = write!(s, "{dx}", dxfs.len()); s.push_str(""); - s + (s, nf.map) } /// Index of a differential format, adding it when new. diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 007d9ff..46bd997 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -3,6 +3,7 @@ mod csv_tests; mod fixtures; mod malformed; +mod pivot_tests; mod roundtrip; use std::io::Write; diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs new file mode 100644 index 0000000..5d15d98 --- /dev/null +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -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> { + 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 = ct + .kids("Override") + .map(|o| (o.attr("PartName").unwrap().trim_start_matches('/').to_string(), o.attr("ContentType").unwrap().to_string())) + .collect(); + let defaults: HashSet = 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 = 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(""), + "{t1}" + ); + assert!(t1.contains("")); + assert!(t1.contains("subtotal=\"count\"")); + assert!(t1.contains("numFmtId=\"4\"")); + assert!(t1.contains("axis=\"axisPage\"")); + assert!(t1.contains(""), "{c1}"); + assert!(c1.contains("refreshOnLoad=\"1\"")); + let c2 = String::from_utf8(parts.get("xl/pivotCache/pivotCacheDefinition2.xml").unwrap().clone()).unwrap(); + assert!(c2.contains("")); + 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(""), "{}", &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#""#; +const PT_CACHE_RELS: &str = r#""#; + +fn with_pivot(table: &str, cache: Option<&str>) -> Vec { + 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("", &extra, "", "") +} + +const GOOD_CACHE: &str = r#""#; + +#[test] +fn hostile_pivot_parts_warn_not_panic() { + let tables = [ + "garbage <<<", + r#""#, + r#""#, + r#""#, + r#""#, + ]; + let caches = [ + None, + Some("garbage"), + Some(r#""#), + Some(r#""#), + Some(r#""#), + Some(r#""#), + 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#""#; + let table = r#""#; + let bytes = minimal( + "", + &[ + ("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 ; 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("", r#""#); + let mut list: Vec<(String, Vec)> = 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"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index dad68d2..31c63d1 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -157,9 +157,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result, 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, IoError> { if out.needs_calc { w.push_str(" fullCalcOnLoad=\"1\""); } - w.push_str("/>"); + w.push_str("/>"); + w.push_str(&pivots.workbook_xml(&mut wb_rels)); + w.push_str(""); // 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(