mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-11 16:34:47 -06:00
383 lines
17 KiB
Rust
383 lines
17 KiB
Rust
use super::*;
|
|
|
|
// ================================================================ reading
|
|
|
|
/// A cache field as read.
|
|
#[derive(Clone, Debug, Default)]
|
|
struct FieldIn {
|
|
name: String,
|
|
database: bool,
|
|
/// Shared item captions.
|
|
items: Vec<String>,
|
|
/// (base field, grouping, group item captions); grouping `None` = unsupported grouping.
|
|
group: Option<(Option<usize>, Option<PivotDateGroup>, Vec<String>)>,
|
|
/// A calculated field's formula.
|
|
formula: Option<String>,
|
|
}
|
|
|
|
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<FieldIn>,
|
|
}
|
|
|
|
/// Workbook-level pivot cache lookup, filled from `<pivotCaches>`; caches are parsed once.
|
|
#[derive(Default)]
|
|
pub struct CachesIn {
|
|
by_id: HashMap<u32, String>,
|
|
parsed: HashMap<String, Option<CacheIn>>,
|
|
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::<f64>().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<CacheIn> {
|
|
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").map(str::to_string),
|
|
});
|
|
}
|
|
}
|
|
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<Vec<PivotTable>, 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.is_some() {
|
|
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<PivotTable> {
|
|
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<i64> {
|
|
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("colGrandTotals", true);
|
|
pt.grand_totals_cols = x.flag("rowGrandTotals", true);
|
|
pt.show_headers = x.flag("showHeaders", true);
|
|
pt.enable_show_details = x.flag("enableDrill", true);
|
|
pt.style = x.child("pivotTableStyleInfo").and_then(|s| s.attr("name")).unwrap_or("").to_string();
|
|
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<PivotFilter> = Vec::new();
|
|
|
|
// (fields, position of Σ Values (`-2`) among them)
|
|
let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec<PivotFilter>| -> (Vec<PivotField>, Option<usize>) {
|
|
let mut v = Vec::new();
|
|
let mut values_at = None;
|
|
for &i in list {
|
|
if i == -2 {
|
|
values_at = Some(v.len());
|
|
continue;
|
|
}
|
|
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<String> = 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, values_at)
|
|
};
|
|
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();
|
|
let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters);
|
|
let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters);
|
|
pt.rows = rows;
|
|
pt.columns = columns;
|
|
// Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last
|
|
// field by default. In Columns it always comes last, as the column layout puts it there.
|
|
pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false));
|
|
pt.values_position =
|
|
if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None };
|
|
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 };
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// Calculated fields of the cache (each PivotTable on it can use them).
|
|
for f in &cache.fields {
|
|
if let Some(formula) = &f.formula
|
|
&& !f.name.trim().is_empty()
|
|
&& !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name))
|
|
{
|
|
let formula = formula.trim();
|
|
let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") };
|
|
pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula });
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|