mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-14 06:12:18 +00:00
Refactor xlsx for strict Clippy checks
This commit is contained in:
1 parent
7fbf3f60c8
commit
84e684e586
15 files changed
+1041
-829
No files matched your search
@@ -50,7 +50,7 @@ pub use read::KeptReader;
|
||||
pub use rebase::{SheetKept, rebase_links};
|
||||
#[cfg(test)]
|
||||
pub(crate) use write::sheet_names_in;
|
||||
pub use write::{KeptOut, SheetIds, workbook_elements, write_package_rels, write_workbook};
|
||||
pub use write::{KeptOut, SheetIds, WorkbookKept, workbook_elements, write_package_rels, write_workbook};
|
||||
|
||||
/// Most bytes kept for one workbook.
|
||||
pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, Ctx, DateSystem, El, HashMap, IoError, MAX_FIELDS, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout,
|
||||
PivotShowAs, PivotSort, PivotTable, PivotValue, RangeRef, abs_range, func_caption, func_from, number_to_text, quote_sheet, same,
|
||||
};
|
||||
|
||||
// ================================================================ reading
|
||||
|
||||
|
||||
+162
-131
@@ -246,12 +246,7 @@ fn header_text(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cache> {
|
||||
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)?;
|
||||
fn cache_fields(sh: &gridcraft_model::Sheet, r: RangeRef) -> Vec<CField> {
|
||||
let mut fields: Vec<CField> = Vec::new();
|
||||
// Lower-case names taken, and the next suffix for each header (linear for repeated headers).
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
@@ -280,42 +275,24 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
||||
formula: None,
|
||||
});
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn group_cache_fields(fields: &mut Vec<CField>, axis: &[&PivotField], date_system: DateSystem) {
|
||||
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<u32> = 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()
|
||||
&& datetime_from_serial(date_system, s.min).is_some()
|
||||
&& datetime_from_serial(date_system, s.max).is_some()
|
||||
};
|
||||
// (base column field, groupings fine → coarse)
|
||||
let mut groupings: Vec<(usize, Vec<PivotDateGroup>)> = Vec::new();
|
||||
for pf in &axis {
|
||||
let Some(b) = find(&fields, &pf.source_col) else { continue };
|
||||
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;
|
||||
}
|
||||
@@ -339,7 +316,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
||||
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 make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(date_system, by, start, end) };
|
||||
let mut prev = *b;
|
||||
for (k, by) in list.iter().enumerate() {
|
||||
if k == 0 {
|
||||
@@ -372,6 +349,39 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cac
|
||||
prev = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option<Cache> {
|
||||
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 = cache_fields(sh, r);
|
||||
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<u32> = 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();
|
||||
group_cache_fields(&mut fields, &axis, wb.date_system);
|
||||
let mut axis_map: Vec<Option<usize>> = Vec::with_capacity(axis.len());
|
||||
for pf in &axis {
|
||||
let Some(b) = find(&fields, &pf.source_col) else {
|
||||
@@ -598,11 +608,18 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
struct PivotTableLayout<'a> {
|
||||
row_fields: Vec<usize>,
|
||||
column_fields: Vec<usize>,
|
||||
hidden_fields: HashMap<usize, &'a PivotFilter>,
|
||||
page_fields: Vec<(usize, &'a PivotFilter)>,
|
||||
values: Vec<(usize, &'a PivotValue)>,
|
||||
}
|
||||
|
||||
fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option<u32>) -> 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<usize> = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect();
|
||||
let col_idx: Vec<usize> = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect();
|
||||
let row_fields: Vec<usize> = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect();
|
||||
let column_fields: Vec<usize> = 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<usize, &PivotFilter> = HashMap::new();
|
||||
let mut pages: Vec<(usize, &PivotFilter)> = Vec::new();
|
||||
@@ -625,12 +642,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
}
|
||||
}
|
||||
let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name));
|
||||
let vals: Vec<(usize, &PivotValue)> =
|
||||
let values: Vec<(usize, &PivotValue)> =
|
||||
pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&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 layout = PivotTableLayout { row_fields, column_fields, hidden_fields: hidden, page_fields: pages, values };
|
||||
|
||||
let compact = pt.layout == PivotLayout::Compact;
|
||||
let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() };
|
||||
@@ -666,16 +680,16 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
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 k = layout.values.len();
|
||||
// Σ Values (two or more value fields): its index among the written fields of its axis.
|
||||
let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len()));
|
||||
let values_at = |map: &[Option<usize>], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count());
|
||||
let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len()));
|
||||
let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, layout.row_fields.len()));
|
||||
// In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere.
|
||||
let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len());
|
||||
let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) };
|
||||
let row_levels = row_idx.len() + usize::from(row_values.is_some());
|
||||
let first_data_col = if row_levels == 0 && col_idx.is_empty() {
|
||||
let col_values = (k > 1 && !pt.values_on_rows).then_some(layout.column_fields.len());
|
||||
let first_data_row = if layout.column_fields.is_empty() { 1 } else { 1 + layout.column_fields.len() + usize::from(col_values.is_some()) };
|
||||
let row_levels = layout.row_fields.len() + usize::from(row_values.is_some());
|
||||
let first_data_col = if row_levels == 0 && layout.column_fields.is_empty() {
|
||||
0
|
||||
} else if compact {
|
||||
1
|
||||
@@ -683,27 +697,52 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
row_levels.max(1)
|
||||
};
|
||||
// Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row.
|
||||
let hide_values_row = col_values.is_some() && col_idx.is_empty();
|
||||
let hide_values_row = col_values.is_some() && layout.column_fields.is_empty();
|
||||
let first_header_row = usize::from(!hide_values_row);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<location ref=\"{}\" firstHeaderRow=\"{first_header_row}\" firstDataRow=\"{first_data_row}\" firstDataCol=\"{first_data_col}\"",
|
||||
loc.a1()
|
||||
);
|
||||
if !pages.is_empty() {
|
||||
let _ = write!(s, " rowPageCount=\"{}\" colPageCount=\"1\"", pages.len());
|
||||
if !layout.page_fields.is_empty() {
|
||||
let _ = write!(s, " rowPageCount=\"{}\" colPageCount=\"1\"", layout.page_fields.len());
|
||||
}
|
||||
s.push_str("/>");
|
||||
|
||||
// Fields.
|
||||
let _ = write!(s, "<pivotFields count=\"{nf}\">");
|
||||
write_pivot_fields(&mut s, pt, c, &layout);
|
||||
axis_fields_xml(&mut s, "rowFields", &layout.row_fields, row_values);
|
||||
axis_fields_xml(&mut s, "colFields", &layout.column_fields, col_values);
|
||||
write_page_fields(&mut s, c, &layout);
|
||||
write_data_fields(&mut s, c, &layout, fmt_id);
|
||||
s.push_str("<pivotTableStyleInfo");
|
||||
if !pt.style.is_empty() {
|
||||
let _ = write!(s, " name=\"{}\"", esc_attr(&pt.style));
|
||||
}
|
||||
s.push_str(" showRowHeaders=\"1\" showColHeaders=\"1\" showRowStripes=\"0\" showColStripes=\"0\" showLastColumn=\"1\"/>");
|
||||
if hide_values_row {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<extLst><ext uri=\"{EXT_X14_PIVOT_TABLE}\" xmlns:x14=\"{NS_X14}\"><x14:pivotTableDefinition hideValuesRow=\"1\"/></ext></extLst>"
|
||||
);
|
||||
}
|
||||
s.push_str("</pivotTableDefinition>");
|
||||
s
|
||||
}
|
||||
|
||||
fn axis_field<'a>(pt: &'a PivotTable, c: &Cache, i: usize) -> Option<&'a PivotField> {
|
||||
let p = c.axis_map.iter().position(|x| *x == Some(i))?;
|
||||
pt.rows.iter().chain(pt.columns.iter()).nth(p)
|
||||
}
|
||||
|
||||
fn write_pivot_fields(s: &mut String, pt: &PivotTable, c: &Cache, layout: &PivotTableLayout<'_>) {
|
||||
let _ = write!(s, "<pivotFields count=\"{}\">", c.fields.len());
|
||||
for (i, f) in c.fields.iter().enumerate() {
|
||||
s.push_str("<pivotField");
|
||||
let axis = if row_idx.contains(&i) {
|
||||
let axis = if layout.row_fields.contains(&i) {
|
||||
Some("axisRow")
|
||||
} else if col_idx.contains(&i) {
|
||||
} else if layout.column_fields.contains(&i) {
|
||||
Some("axisCol")
|
||||
} else if pages.iter().any(|(j, _)| *j == i) {
|
||||
} else if layout.page_fields.iter().any(|(j, _)| *j == i) {
|
||||
Some("axisPage")
|
||||
} else {
|
||||
None
|
||||
@@ -711,7 +750,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
if let Some(ax) = axis {
|
||||
let _ = write!(s, " axis=\"{ax}\"");
|
||||
}
|
||||
if vals.iter().any(|(j, _)| *j == i) {
|
||||
if layout.values.iter().any(|(j, _)| *j == i) {
|
||||
s.push_str(" dataField=\"1\"");
|
||||
}
|
||||
if f.formula.is_some() {
|
||||
@@ -725,7 +764,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
if f.group.is_some() {
|
||||
s.push_str(" numFmtId=\"14\"");
|
||||
}
|
||||
let filter = hidden.get(&i).copied();
|
||||
let filter = layout.hidden_fields.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).
|
||||
@@ -740,7 +779,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
s.push_str(" multipleItemSelectionAllowed=\"1\"");
|
||||
}
|
||||
s.push_str(" showAll=\"0\"");
|
||||
if let Some(af) = axis_field(i) {
|
||||
if let Some(af) = axis_field(pt, c, i) {
|
||||
match af.sort {
|
||||
PivotSort::Asc => s.push_str(" sortType=\"ascending\""),
|
||||
PivotSort::Desc => s.push_str(" sortType=\"descending\""),
|
||||
@@ -754,7 +793,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
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 collapsed: &[String] = axis_field(pt, c, i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]);
|
||||
let count = labels.len() + usize::from(pt.subtotals);
|
||||
let _ = write!(s, "><items count=\"{count}\">");
|
||||
for (x, l) in labels.iter().enumerate() {
|
||||
@@ -776,84 +815,76 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn
|
||||
}
|
||||
}
|
||||
s.push_str("</pivotFields>");
|
||||
}
|
||||
|
||||
axis_fields_xml(&mut s, "rowFields", &row_idx, row_values);
|
||||
axis_fields_xml(&mut s, "colFields", &col_idx, col_values);
|
||||
if !pages.is_empty() {
|
||||
let _ = write!(s, "<pageFields count=\"{}\">", pages.len());
|
||||
for (i, f) in &pages {
|
||||
let _ = write!(s, "<pageField fld=\"{i}\"");
|
||||
if let Some(sel) = &f.selected
|
||||
&& sel.len() == 1
|
||||
&& let Some(one) = sel.first()
|
||||
&& let Some(x) = c.fields.get(*i).and_then(|cf| cf.item_labels().iter().position(|l| same(l, one)))
|
||||
{
|
||||
let _ = write!(s, " item=\"{x}\"");
|
||||
}
|
||||
s.push_str(" hier=\"-1\"/>");
|
||||
}
|
||||
s.push_str("</pageFields>");
|
||||
fn write_page_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>) {
|
||||
if layout.page_fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !vals.is_empty() {
|
||||
let _ = write!(s, "<dataFields count=\"{k}\">");
|
||||
let mut used: Vec<String> = 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;
|
||||
let _ = write!(s, "<pageFields count=\"{}\">", layout.page_fields.len());
|
||||
for (i, filter) in &layout.page_fields {
|
||||
let _ = write!(s, "<pageField fld=\"{i}\"");
|
||||
if let Some(selected) = &filter.selected
|
||||
&& selected.len() == 1
|
||||
&& let Some(one) = selected.first()
|
||||
&& let Some(x) = c.fields.get(*i).and_then(|field| field.item_labels().iter().position(|label| same(label, one)))
|
||||
{
|
||||
let _ = write!(s, " item=\"{x}\"");
|
||||
}
|
||||
s.push_str(" hier=\"-1\"/>");
|
||||
}
|
||||
s.push_str("</pageFields>");
|
||||
}
|
||||
|
||||
fn write_data_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>, fmt_id: &dyn Fn(&str) -> Option<u32>) {
|
||||
if layout.values.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = write!(s, "<dataFields count=\"{}\">", layout.values.len());
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
for (i, value) in &layout.values {
|
||||
let base = if value.name.trim().is_empty() {
|
||||
format!("{} of {}", func_caption(value.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or(""))
|
||||
} else {
|
||||
value.name.clone()
|
||||
};
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2;
|
||||
while used.iter().any(|existing| same(existing, &name)) {
|
||||
name = format!("{base}{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
used.push(name.clone());
|
||||
let _ = write!(s, "<dataField name=\"{}\" fld=\"{i}\"", esc_attr(&name));
|
||||
if value.func != PivotFunc::Sum {
|
||||
let _ = write!(s, " subtotal=\"{}\"", func_name(value.func));
|
||||
}
|
||||
let base_field = layout.row_fields.first().or(layout.column_fields.first()).copied().unwrap_or(0);
|
||||
match value.show_as {
|
||||
PivotShowAs::Normal => 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\"");
|
||||
}
|
||||
used.push(n.clone());
|
||||
let _ = write!(s, "<dataField name=\"{}\" fld=\"{i}\"", esc_attr(&n));
|
||||
if v.func != PivotFunc::Sum {
|
||||
let _ = write!(s, " subtotal=\"{}\"", func_name(v.func));
|
||||
}
|
||||
let base_field = row_idx.first().or(col_idx.first()).copied().unwrap_or(0);
|
||||
match v.show_as {
|
||||
PivotShowAs::Normal => 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,
|
||||
"><extLst><ext uri=\"{EXT_X14_DATA_FIELD}\" xmlns:x14=\"{NS_X14}\"><x14:dataField pivotShowAs=\"rankDescending\"/></ext></extLst></dataField>"
|
||||
);
|
||||
} else {
|
||||
s.push_str("/>");
|
||||
PivotShowAs::Rank => {
|
||||
let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\"");
|
||||
}
|
||||
}
|
||||
s.push_str("</dataFields>");
|
||||
if let Some(id) = value.number_format.as_deref().and_then(fmt_id) {
|
||||
let _ = write!(s, " numFmtId=\"{id}\"");
|
||||
}
|
||||
if value.show_as == PivotShowAs::Rank {
|
||||
let _ = write!(
|
||||
s,
|
||||
"><extLst><ext uri=\"{EXT_X14_DATA_FIELD}\" xmlns:x14=\"{NS_X14}\"><x14:dataField pivotShowAs=\"rankDescending\"/></ext></extLst></dataField>"
|
||||
);
|
||||
} else {
|
||||
s.push_str("/>");
|
||||
}
|
||||
}
|
||||
s.push_str("<pivotTableStyleInfo");
|
||||
if !pt.style.is_empty() {
|
||||
let _ = write!(s, " name=\"{}\"", esc_attr(&pt.style));
|
||||
}
|
||||
s.push_str(" showRowHeaders=\"1\" showColHeaders=\"1\" showRowStripes=\"0\" showColStripes=\"0\" showLastColumn=\"1\"/>");
|
||||
if hide_values_row {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<extLst><ext uri=\"{EXT_X14_PIVOT_TABLE}\" xmlns:x14=\"{NS_X14}\"><x14:pivotTableDefinition hideValuesRow=\"1\"/></ext></extLst>"
|
||||
);
|
||||
}
|
||||
s.push_str("</pivotTableDefinition>");
|
||||
s
|
||||
s.push_str("</dataFields>");
|
||||
}
|
||||
|
||||
/// `<rowFields>`/`<colFields>`: the axis's cache field indices, with Σ Values (`-2`) at `values_at`.
|
||||
|
||||
+113
-89
@@ -96,87 +96,10 @@ pub fn find_rel<'r>(rels: &'r [Rel], kind: &str) -> Option<&'r Rel> {
|
||||
rels.iter().find(|r| r.kind == kind)
|
||||
}
|
||||
|
||||
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
let pkg = Package::open(bytes)?;
|
||||
let mut cx = Ctx {
|
||||
pkg,
|
||||
report: ReadReport::default(),
|
||||
sst: vec![],
|
||||
phonetics: vec![],
|
||||
xf_map: vec![],
|
||||
dxfs: vec![],
|
||||
palette: crate::tables::INDEXED.to_vec(),
|
||||
date_system: DateSystem::D1900,
|
||||
styles: StyleTable::default(),
|
||||
persons: Default::default(),
|
||||
next_id: 1,
|
||||
num_fmts: Default::default(),
|
||||
cell_pictures: vec![],
|
||||
cf_ext_ids: vec![],
|
||||
};
|
||||
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());
|
||||
let wb_xml = match cx.pkg.read_xml(&wb_part)? {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
if cx.pkg.has("xl/workbook.xml") && wb_part != "xl/workbook.xml" {
|
||||
cx.pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))?
|
||||
} else if cx.pkg.has("content.xml") {
|
||||
return Err(IoError::Format("this is an OpenDocument file, not XLSX".into()));
|
||||
} else {
|
||||
return Err(IoError::Format("no workbook part".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
if wb_xml.name != "workbook" {
|
||||
return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name)));
|
||||
}
|
||||
let wb_rels = cx.pkg.rels(&wb_part)?;
|
||||
let mut kept = crate::passthrough::KeptReader::new(&mut cx);
|
||||
cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) {
|
||||
Ok(pictures) => pictures,
|
||||
Err(error) => {
|
||||
cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained"));
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheets.clear();
|
||||
|
||||
// Theme, then styles: a font's link to the theme's body font is read against it.
|
||||
if let Some(r) = find_rel(&wb_rels, "theme").cloned()
|
||||
&& let Some(x) = cx.optional_xml(&r.target)?
|
||||
{
|
||||
wb.theme = crate::theme::read_theme(&x);
|
||||
}
|
||||
// Cell checkboxes live in the feature property bags, which styles refer to.
|
||||
let mut checkboxes = Vec::new();
|
||||
if let Some(r) = find_rel(&wb_rels, "FeaturePropertyBag").cloned()
|
||||
&& let Some(x) = cx.optional_xml(&r.target)?
|
||||
{
|
||||
checkboxes = crate::feature_bags::checkbox_complements(&x);
|
||||
}
|
||||
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, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font);
|
||||
cx.num_fmts = num_fmts;
|
||||
// Cell format 0 is the Normal cell format: everything on it (font, number format,
|
||||
// fill, borders, alignment, protection) is what a cell without its own format shows
|
||||
// (#1013).
|
||||
if let Some(normal) = xfs.first() {
|
||||
cx.styles = StyleTable::with_default(normal.clone());
|
||||
}
|
||||
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;
|
||||
wb.cell_styles = named;
|
||||
}
|
||||
} else {
|
||||
cx.warn("no styles part");
|
||||
}
|
||||
fn read_shared_strings(cx: &mut Ctx<'_>, wb_rels: &[Rel]) -> Result<(), IoError> {
|
||||
// Shared strings, streamed item by item. Phonetic (furigana) runs are kept next to the text
|
||||
// they annotate, so an open + save does not delete them.
|
||||
if let Some(r) = find_rel(&wb_rels, "sharedStrings").cloned() {
|
||||
if let Some(r) = find_rel(wb_rels, "sharedStrings").cloned() {
|
||||
match cx.pkg.read(&r.target)? {
|
||||
Some(b) => {
|
||||
let mut sst = Vec::new();
|
||||
@@ -206,18 +129,67 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = find_rel(&wb_rels, "person").cloned()
|
||||
&& let Ok(Some(x)) = cx.pkg.read_xml(&r.target)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_workbook_styles(cx: &mut Ctx<'_>, wb: &mut Workbook, wb_rels: &[Rel]) -> Result<(), IoError> {
|
||||
// Theme, then styles: a font's link to the theme's body font is read against it.
|
||||
if let Some(r) = find_rel(wb_rels, "theme").cloned()
|
||||
&& let Some(x) = cx.optional_xml(&r.target)?
|
||||
{
|
||||
for p in x.kids("person") {
|
||||
if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) {
|
||||
cx.persons.insert(id.to_string(), n.to_string());
|
||||
wb.theme = crate::theme::read_theme(&x);
|
||||
}
|
||||
// Cell checkboxes live in the feature property bags, which styles refer to.
|
||||
let mut checkboxes = Vec::new();
|
||||
if let Some(r) = find_rel(wb_rels, "FeaturePropertyBag").cloned()
|
||||
&& let Some(x) = cx.optional_xml(&r.target)?
|
||||
{
|
||||
checkboxes = crate::feature_bags::checkbox_complements(&x);
|
||||
}
|
||||
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, num_fmts } = read_styles(&x, &checkboxes, &wb.theme.minor_font);
|
||||
cx.num_fmts = num_fmts;
|
||||
// Cell format 0 is the Normal cell format: everything on it (font, number format,
|
||||
// fill, borders, alignment, protection) is what a cell without its own format shows
|
||||
// (#1013).
|
||||
if let Some(normal) = xfs.first() {
|
||||
cx.styles = StyleTable::with_default(normal.clone());
|
||||
}
|
||||
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;
|
||||
wb.cell_styles = named;
|
||||
}
|
||||
} else {
|
||||
cx.warn("no styles part");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the workbook XML selected by the package's root relationships.
|
||||
fn workbook_xml(pkg: &mut Package<'_>, part: &str) -> Result<El, IoError> {
|
||||
let xml = match pkg.read_xml(part)? {
|
||||
Some(xml) => xml,
|
||||
None => {
|
||||
if pkg.has("xl/workbook.xml") && part != "xl/workbook.xml" {
|
||||
pkg.read_xml("xl/workbook.xml")?.ok_or_else(|| IoError::Format("no workbook part".into()))?
|
||||
} else if pkg.has("content.xml") {
|
||||
return Err(IoError::Format("this is an OpenDocument file, not XLSX".into()));
|
||||
} else {
|
||||
return Err(IoError::Format("no workbook part".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
if xml.name != "workbook" {
|
||||
return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", xml.name)));
|
||||
}
|
||||
Ok(xml)
|
||||
}
|
||||
|
||||
// Workbook properties.
|
||||
if let Some(p) = wb_xml.child("workbookPr") {
|
||||
/// Read workbook-level settings before processing the individual sheets.
|
||||
fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
|
||||
if let Some(p) = xml.child("workbookPr") {
|
||||
if p.flag("date1904", false) {
|
||||
cx.date_system = DateSystem::D1904;
|
||||
}
|
||||
@@ -228,7 +200,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
};
|
||||
}
|
||||
wb.date_system = cx.date_system;
|
||||
if let Some(c) = wb_xml.child("calcPr") {
|
||||
if let Some(c) = xml.child("calcPr") {
|
||||
wb.calc.mode = match c.attr("calcMode") {
|
||||
Some("manual") => CalcMode::Manual,
|
||||
Some("autoNoTable") => CalcMode::AutomaticExceptTables,
|
||||
@@ -243,10 +215,59 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
}
|
||||
wb.calc.precision_as_displayed = !c.flag("fullPrecision", true);
|
||||
}
|
||||
if let Some(p) = wb_xml.child("workbookProtection") {
|
||||
if let Some(p) = xml.child("workbookProtection") {
|
||||
wb.protected_structure = p.flag("lockStructure", false);
|
||||
wb.structure_password = if wb.protected_structure { crate::sheet_read::read_password(p, "workbook") } else { None };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
let pkg = Package::open(bytes)?;
|
||||
let mut cx = Ctx {
|
||||
pkg,
|
||||
report: ReadReport::default(),
|
||||
sst: vec![],
|
||||
phonetics: vec![],
|
||||
xf_map: vec![],
|
||||
dxfs: vec![],
|
||||
palette: crate::tables::INDEXED.to_vec(),
|
||||
date_system: DateSystem::D1900,
|
||||
styles: StyleTable::default(),
|
||||
persons: Default::default(),
|
||||
next_id: 1,
|
||||
num_fmts: Default::default(),
|
||||
cell_pictures: vec![],
|
||||
cf_ext_ids: vec![],
|
||||
};
|
||||
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());
|
||||
let wb_xml = workbook_xml(&mut cx.pkg, &wb_part)?;
|
||||
let wb_rels = cx.pkg.rels(&wb_part)?;
|
||||
let mut kept = crate::passthrough::KeptReader::new(&mut cx);
|
||||
cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) {
|
||||
Ok(pictures) => pictures,
|
||||
Err(error) => {
|
||||
cx.warn(format!("cell-picture data could not be read ({error}); scalar fallbacks were retained"));
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheets.clear();
|
||||
|
||||
read_workbook_styles(&mut cx, &mut wb, &wb_rels)?;
|
||||
read_shared_strings(&mut cx, &wb_rels)?;
|
||||
|
||||
if let Some(r) = find_rel(&wb_rels, "person").cloned()
|
||||
&& let Ok(Some(x)) = cx.pkg.read_xml(&r.target)
|
||||
{
|
||||
for p in x.kids("person") {
|
||||
if let (Some(id), Some(n)) = (p.attr("id"), p.attr("displayName")) {
|
||||
cx.persons.insert(id.to_string(), n.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
read_workbook_properties(&mut cx, &mut wb, &wb_xml);
|
||||
let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;
|
||||
|
||||
// Sheets. `file_to_model[i]` maps the i-th <sheet> to a model sheet index.
|
||||
@@ -426,7 +447,10 @@ pub fn parse_print_area(text: &str) -> Option<RangeRef> {
|
||||
RangeRef::parse(&r)
|
||||
}
|
||||
|
||||
pub fn parse_print_titles(text: &str) -> (Option<(u32, u32)>, Option<(u32, u32)>) {
|
||||
/// A print-titles span: the first and last row (or column) repeated on each page.
|
||||
type TitleSpan = Option<(u32, u32)>;
|
||||
|
||||
pub fn parse_print_titles(text: &str) -> (TitleSpan, TitleSpan) {
|
||||
let mut rows = None;
|
||||
let mut cols = None;
|
||||
for part in text.split(',') {
|
||||
|
||||
@@ -97,6 +97,24 @@ const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
|
||||
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
|
||||
const MAX_COND_CELLS: u64 = 4_000_000;
|
||||
|
||||
fn read_sheet_format(e: &El, sheet: &mut Sheet) {
|
||||
if let Some(h) = e.attr_f64("defaultRowHeight") {
|
||||
let px = pt_to_px(h);
|
||||
if px > 0.0 {
|
||||
sheet.default_row_height = px;
|
||||
}
|
||||
}
|
||||
if let Some(w) = e.attr_f64("defaultColWidth") {
|
||||
let px = col_width_to_px(w);
|
||||
if px > 0.0 {
|
||||
sheet.default_col_width = px;
|
||||
}
|
||||
} else if let Some(b) = e.attr_f64("baseColWidth") {
|
||||
let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0;
|
||||
sheet.default_col_width = px as f32;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
|
||||
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
|
||||
let rels = cx.pkg.rels(part)?;
|
||||
@@ -134,23 +152,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
fit_to_page = e.child("pageSetUpPr").is_some_and(|p| p.flag("fitToPage", false));
|
||||
}
|
||||
"sheetViews" => read_views(e, &mut sheet),
|
||||
"sheetFormatPr" => {
|
||||
if let Some(h) = e.attr_f64("defaultRowHeight") {
|
||||
let px = pt_to_px(h);
|
||||
if px > 0.0 {
|
||||
sheet.default_row_height = px;
|
||||
}
|
||||
}
|
||||
if let Some(w) = e.attr_f64("defaultColWidth") {
|
||||
let px = col_width_to_px(w);
|
||||
if px > 0.0 {
|
||||
sheet.default_col_width = px;
|
||||
}
|
||||
} else if let Some(b) = e.attr_f64("baseColWidth") {
|
||||
let px = ((b.clamp(0.0, 255.0) * 7.0 + 5.0) / 8.0).ceil() * 8.0;
|
||||
sheet.default_col_width = px as f32;
|
||||
}
|
||||
}
|
||||
"sheetFormatPr" => read_sheet_format(e, &mut sheet),
|
||||
"cols" => read_cols(cx, e, &mut sheet, &mut column_assignments_left)?,
|
||||
"mergeCells" => {
|
||||
for m in e.kids("mergeCell") {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS, MAX_ROWS,
|
||||
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
|
||||
};
|
||||
|
||||
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
|
||||
let mut cells = 0u64;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CfOperator, CfRule, CfValueKind, CondFormat, Ctx, El, ErrorStyle, MAX_COND_CELLS, RangeRef, Sheet, Validation, ValidationKind, from_file,
|
||||
parse_sqref, read_color,
|
||||
};
|
||||
|
||||
fn cf_op(s: Option<&str>) -> CfOperator {
|
||||
match s.unwrap_or("between") {
|
||||
|
||||
+409
-364
@@ -16,7 +16,7 @@ use crate::tables::{paper_id, px_to_col_width, px_to_pt};
|
||||
use crate::write::{
|
||||
CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL,
|
||||
};
|
||||
use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_esc, push_num, push_u64};
|
||||
use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_u64};
|
||||
|
||||
mod parts;
|
||||
mod rules;
|
||||
@@ -124,70 +124,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
}
|
||||
let _ = write!(s, "<dimension ref=\"{}\"/>", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into()));
|
||||
|
||||
// sheetViews
|
||||
s.push_str("<sheetViews><sheetView");
|
||||
if selected {
|
||||
s.push_str(" tabSelected=\"1\"");
|
||||
}
|
||||
if !sheet.show_gridlines {
|
||||
s.push_str(" showGridLines=\"0\"");
|
||||
}
|
||||
if !sheet.show_headings {
|
||||
s.push_str(" showRowColHeaders=\"0\"");
|
||||
}
|
||||
if sheet.show_formulas {
|
||||
s.push_str(" showFormulas=\"1\"");
|
||||
}
|
||||
if !sheet.show_zeros {
|
||||
s.push_str(" showZeros=\"0\"");
|
||||
}
|
||||
if !sheet.show_outline_symbols {
|
||||
s.push_str(" showOutlineSymbols=\"0\"");
|
||||
}
|
||||
if sheet.right_to_left {
|
||||
s.push_str(" rightToLeft=\"1\"");
|
||||
}
|
||||
if sheet.view != gridcraft_model::SheetViewMode::Normal {
|
||||
let _ = write!(s, " view=\"{}\"", sheet.view.as_str());
|
||||
}
|
||||
if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) {
|
||||
let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom);
|
||||
}
|
||||
if sheet.view_top_left != CellRef::default() && sheet.freeze.is_none() {
|
||||
let _ = write!(s, " topLeftCell=\"{}\"", sheet.view_top_left.a1());
|
||||
}
|
||||
s.push_str(" workbookViewId=\"0\">");
|
||||
let active = sheet.view_active.a1();
|
||||
match sheet.freeze {
|
||||
Some((rows, cols)) if rows > 0 || cols > 0 => {
|
||||
let pane = match (rows > 0, cols > 0) {
|
||||
(true, true) => "bottomRight",
|
||||
(true, false) => "bottomLeft",
|
||||
_ => "topRight",
|
||||
};
|
||||
s.push_str("<pane");
|
||||
if cols > 0 {
|
||||
let _ = write!(s, " xSplit=\"{cols}\"");
|
||||
}
|
||||
if rows > 0 {
|
||||
let _ = write!(s, " ySplit=\"{rows}\"");
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
" topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>",
|
||||
CellRef::new(
|
||||
rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1),
|
||||
cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1)
|
||||
)
|
||||
.a1()
|
||||
);
|
||||
let _ = write!(s, "<selection pane=\"{pane}\" activeCell=\"{active}\" sqref=\"{active}\"/>");
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(s, "<selection activeCell=\"{active}\" sqref=\"{active}\"/>");
|
||||
}
|
||||
}
|
||||
s.push_str("</sheetView></sheetViews>");
|
||||
write_sheet_views(&mut s, sheet, selected);
|
||||
|
||||
// sheetFormatPr
|
||||
let max_row_outline = sheet.rows.values().map(|r| r.outline).max().unwrap_or(0);
|
||||
@@ -252,80 +189,248 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
s.push_str("</cols>");
|
||||
}
|
||||
|
||||
// sheetData
|
||||
// Every written cell in row-major order: the store already iterates that way, so only cells
|
||||
// that exist outside it (pictures, cached spill values, table headers) need sorting in.
|
||||
let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len());
|
||||
keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell))));
|
||||
if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) {
|
||||
let mut all: BTreeSet<CellRef> = keys.iter().map(|(c, _)| *c).collect();
|
||||
all.extend(sheet.cell_pictures.keys().copied());
|
||||
all.extend(extra.keys().copied());
|
||||
all.extend(overrides.keys().copied());
|
||||
keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect();
|
||||
write_sheet_data(&mut s, SheetCellData { workbook: wb, index: si, sheet, overrides: &overrides, extra: &extra, dynamic: &dynamic }, &sid, out);
|
||||
|
||||
let prio = write_sheet_rules(&mut s, sheet, &mut rels, out);
|
||||
let kept = write_sheet_printing(&mut s, sheet, &mut rels, out);
|
||||
|
||||
write_sheet_drawing(&mut s, wb, si, sheet, &mut rels, out);
|
||||
write_sheet_notes(&mut s, sheet, si, &kept, &mut rels, out);
|
||||
// Tables.
|
||||
if !tables.is_empty() {
|
||||
let mut parts = String::new();
|
||||
let mut used_names: HashSet<String> = HashSet::new();
|
||||
for (t, cols) in &tables {
|
||||
out.tables += 1;
|
||||
out.table_id += 1;
|
||||
let n = out.tables;
|
||||
let name = table_name(&t.name, &mut used_names, out.table_id);
|
||||
let xml = table_xml(t, cols, &name, out.table_id, out);
|
||||
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes());
|
||||
let rid = rels.add("table", &format!("../tables/table{n}.xml"));
|
||||
let _ = write!(parts, "<tablePart r:id=\"{rid}\"/>");
|
||||
}
|
||||
let _ = write!(s, "<tableParts count=\"{}\">{parts}</tableParts>", tables.len());
|
||||
}
|
||||
s.reserve(keys.len().saturating_mul(32));
|
||||
s.push_str("<sheetData>");
|
||||
let mut row_infos = sheet.rows.iter().peekable();
|
||||
let mut next = 0;
|
||||
loop {
|
||||
// The next row with cells or row settings, and its cells.
|
||||
let cell_row = keys.get(next).map(|(c, _)| c.row);
|
||||
let info_row = row_infos.peek().map(|(r, _)| **r);
|
||||
let r = match (cell_row, info_row) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => break,
|
||||
};
|
||||
let info = match row_infos.next_if(|(row, _)| **row == r) {
|
||||
Some((_, info)) => *info,
|
||||
None => LineInfo::default(),
|
||||
};
|
||||
let first = next;
|
||||
while keys.get(next).is_some_and(|(c, _)| c.row == r) {
|
||||
next += 1;
|
||||
write_sheet_extensions(&mut s, sheet, &prio);
|
||||
s.push_str("</worksheet>");
|
||||
(s, rels)
|
||||
}
|
||||
|
||||
struct CellWriteInput<'a> {
|
||||
reference: CellRef,
|
||||
cell: Option<&'a Cell>,
|
||||
picture: Option<&'a std::sync::Arc<gridcraft_model::CellPicture>>,
|
||||
header_override: Option<&'a String>,
|
||||
spill_value: Option<&'a Value>,
|
||||
dynamic_range: Option<&'a RangeRef>,
|
||||
}
|
||||
|
||||
fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) {
|
||||
// Excel 2010 extensions: solid data bars, then sparklines.
|
||||
let solid_bars: Vec<(&CondFormat, u32)> = sheet
|
||||
.cond_formats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. }))
|
||||
.map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1)))
|
||||
.collect();
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("<extLst>");
|
||||
}
|
||||
if !solid_bars.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<ext uri=\"{{78C0D931-6437-407d-A8A1-0F90B4350D7D}}\" xmlns:x14=\"{X14_NS}\" xmlns:xm=\"{XM_NS}\"><x14:conditionalFormattings>"
|
||||
);
|
||||
for (cf, p) in &solid_bars {
|
||||
if let CfRule::DataBar { min, max, .. } = &cf.rule {
|
||||
let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max));
|
||||
let _ = write!(s, "<x14:conditionalFormatting><x14:cfRule type=\"dataBar\" id=\"{id}\"><x14:dataBar gradient=\"0\">{lo}{hi}");
|
||||
let _ = write!(s, "</x14:dataBar></x14:cfRule><xm:sqref>{}</xm:sqref></x14:conditionalFormatting>", sqref(&cf.ranges));
|
||||
}
|
||||
}
|
||||
let cells = keys.get(first..next).unwrap_or_default();
|
||||
if cells.is_empty() && info == LineInfo::default() {
|
||||
continue;
|
||||
s.push_str("</x14:conditionalFormattings></ext>");
|
||||
}
|
||||
if !sheet.sparklines.is_empty() {
|
||||
let _ = write!(s, "<ext uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\" xmlns:x14=\"{X14_NS}\"><x14:sparklineGroups xmlns:xm=\"{XM_NS}\">");
|
||||
for sp in &sheet.sparklines {
|
||||
let ty = match sp.kind {
|
||||
SparklineKind::Line => "",
|
||||
SparklineKind::Column => " type=\"column\"",
|
||||
SparklineKind::WinLoss => " type=\"stacked\"",
|
||||
};
|
||||
let src =
|
||||
if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) };
|
||||
let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) };
|
||||
let _ = write!(
|
||||
s,
|
||||
"<x14:sparklineGroup displayEmptyCellsAs=\"gap\"{ty}{}><x14:colorSeries {color}/><x14:sparklines><x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup>",
|
||||
if sp.markers { " markers=\"1\"" } else { "" },
|
||||
esc(&src),
|
||||
sp.cell.a1()
|
||||
);
|
||||
}
|
||||
s.push_str("<row r=\"");
|
||||
push_u64(&mut s, u64::from(r) + 1);
|
||||
s.push('"');
|
||||
if let Some(st) = info.style {
|
||||
let _ = write!(s, " s=\"{}\" customFormat=\"1\"", sid(st));
|
||||
s.push_str("</x14:sparklineGroups></ext>");
|
||||
}
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("</extLst>");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) {
|
||||
// Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note
|
||||
// (its text with the replies as lines, for readers without threaded comments) linked by id.
|
||||
if !sheet.comments.is_empty() {
|
||||
out.comments += 1;
|
||||
let n = out.comments;
|
||||
let ids = thread_ids(sheet, si);
|
||||
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
|
||||
if !ids.is_empty() {
|
||||
let x = threaded_xml(sheet, &ids, &mut out.persons);
|
||||
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
|
||||
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
|
||||
}
|
||||
if let Some(h) = info.size {
|
||||
let _ = write!(s, " ht=\"{}\"{}", num(px_to_pt(h)), if info.custom { " customHeight=\"1\"" } else { "" });
|
||||
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
|
||||
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
|
||||
let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out);
|
||||
out.part(&vml_name, None, vml.into_bytes());
|
||||
rels.add("comments", &format!("../comments{n}.xml"));
|
||||
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
} else if let Some(rid) = kept.lone_vml(rels, out) {
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
}
|
||||
s.push_str(&kept.elements());
|
||||
}
|
||||
|
||||
fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) {
|
||||
// Drawing: pictures, charts, shapes.
|
||||
if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() {
|
||||
out.drawings += 1;
|
||||
let dn = out.drawings;
|
||||
let mut drels = Rels::default();
|
||||
let mut objs: Vec<Obj<'_>> = vec![];
|
||||
for img in &sheet.images {
|
||||
if img.data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ext = image_ext(&img.mime, &img.data);
|
||||
out.default_type(ext, image_content_type(ext));
|
||||
let file = out.image_part(ext, &img.data);
|
||||
let rid = drels.add("image", &format!("../media/{file}"));
|
||||
objs.push(Obj::Image(img, rid));
|
||||
}
|
||||
if info.hidden {
|
||||
s.push_str(" hidden=\"1\"");
|
||||
for ch in &sheet.charts {
|
||||
out.charts += 1;
|
||||
let rid = if crate::chartex::is_chartex(ch.kind) {
|
||||
let n = out.charts;
|
||||
let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names);
|
||||
out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes());
|
||||
// Excel expects a chartex part's style and colour parts.
|
||||
out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes());
|
||||
out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes());
|
||||
let mut crels = Rels::default();
|
||||
crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml"));
|
||||
crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml"));
|
||||
out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes());
|
||||
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
|
||||
} else {
|
||||
let name = format!("xl/charts/chart{}.xml", out.charts);
|
||||
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes());
|
||||
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
|
||||
};
|
||||
objs.push(Obj::Chart(ch, rid));
|
||||
}
|
||||
if info.outline > 0 {
|
||||
let _ = write!(s, " outlineLevel=\"{}\"", info.outline.min(7));
|
||||
for sh in &sheet.shapes {
|
||||
objs.push(Obj::Shape(sh));
|
||||
}
|
||||
if info.collapsed {
|
||||
s.push_str(" collapsed=\"1\"");
|
||||
out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes());
|
||||
if !drels.is_empty() {
|
||||
out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes());
|
||||
}
|
||||
if cells.is_empty() {
|
||||
s.push_str("/>");
|
||||
continue;
|
||||
let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml"));
|
||||
let _ = write!(s, "<drawing r:id=\"{rid}\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> crate::passthrough::SheetKept<'a> {
|
||||
// Printing.
|
||||
let p = &sheet.print;
|
||||
if p.gridlines || p.headings || p.center_h || p.center_v {
|
||||
s.push_str("<printOptions");
|
||||
for (a, v) in [("horizontalCentered", p.center_h), ("verticalCentered", p.center_v), ("headings", p.headings), ("gridLines", p.gridlines)] {
|
||||
if v {
|
||||
let _ = write!(s, " {a}=\"1\"");
|
||||
}
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
|
||||
let kept = crate::passthrough::SheetKept::write(sheet, rels, out);
|
||||
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
|
||||
let _ =
|
||||
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
|
||||
s.push_str("<pageSetup");
|
||||
if let Some(id) = paper_id(&p.paper)
|
||||
&& id != 1
|
||||
{
|
||||
let _ = write!(s, " paperSize=\"{id}\"");
|
||||
}
|
||||
if p.scale != 100 && (10..=400).contains(&p.scale) {
|
||||
let _ = write!(s, " scale=\"{}\"", p.scale);
|
||||
}
|
||||
if let Some((w, h)) = p.fit_to {
|
||||
let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\"");
|
||||
}
|
||||
let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
|
||||
if let Some(id) = kept.page_setup_id() {
|
||||
let _ = write!(s, " r:id=\"{id}\"");
|
||||
}
|
||||
s.push_str("/>");
|
||||
if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins {
|
||||
s.push_str("<headerFooter");
|
||||
for (attr, on, default) in [
|
||||
("differentOddEven", p.different_odd_even, false),
|
||||
("differentFirst", p.different_first, false),
|
||||
("scaleWithDoc", p.hf_scale_with_doc, true),
|
||||
("alignWithMargins", p.hf_align_with_margins, true),
|
||||
] {
|
||||
if on != default {
|
||||
let _ = write!(s, " {attr}=\"{}\"", u8::from(on));
|
||||
}
|
||||
}
|
||||
s.push('>');
|
||||
for &(c, cell) in cells {
|
||||
let ov = overrides.get(&c);
|
||||
let ex = extra.get(&c);
|
||||
let array = dynamic.get(&c).copied().or_else(|| {
|
||||
let f = cell?.formula.as_ref()?;
|
||||
(f.array.is_none() && crate::array_formula::needs_array(wb, si, c, f)).then_some(RangeRef::cell(c))
|
||||
});
|
||||
write_cell(&mut s, c, cell, sheet.cell_pictures.get(&c), ov, ex, array.as_ref(), &sid, out);
|
||||
// CT_HeaderFooter's element order.
|
||||
for (tag, text) in [
|
||||
("oddHeader", &p.header),
|
||||
("oddFooter", &p.footer),
|
||||
("evenHeader", &p.even_header),
|
||||
("evenFooter", &p.even_footer),
|
||||
("firstHeader", &p.first_header),
|
||||
("firstFooter", &p.first_footer),
|
||||
] {
|
||||
if !text.is_empty() {
|
||||
let _ = write!(s, "<{tag}>{}</{tag}>", esc(text));
|
||||
}
|
||||
}
|
||||
s.push_str("</headerFooter>");
|
||||
}
|
||||
for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] {
|
||||
let list: Vec<u32> = list.iter().copied().filter(|&b| b > 0).collect();
|
||||
if !list.is_empty() {
|
||||
let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len());
|
||||
for b in list {
|
||||
let _ = write!(s, "<brk id=\"{b}\" max=\"{max}\" man=\"1\"/>");
|
||||
}
|
||||
let _ = write!(s, "</{tag}>");
|
||||
}
|
||||
s.push_str("</row>");
|
||||
}
|
||||
s.push_str("</sheetData>");
|
||||
|
||||
kept
|
||||
}
|
||||
|
||||
fn write_sheet_rules(s: &mut String, sheet: &Sheet, rels: &mut Rels, out: &mut Out) -> Vec<u32> {
|
||||
// sheetProtection
|
||||
if let Some(p) = &sheet.protection {
|
||||
s.push_str("<sheetProtection sheet=\"1\" objects=\"1\" scenarios=\"1\"");
|
||||
@@ -433,236 +538,178 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
}
|
||||
s.push_str("</hyperlinks>");
|
||||
}
|
||||
// Printing.
|
||||
let p = &sheet.print;
|
||||
if p.gridlines || p.headings || p.center_h || p.center_v {
|
||||
s.push_str("<printOptions");
|
||||
for (a, v) in [("horizontalCentered", p.center_h), ("verticalCentered", p.center_v), ("headings", p.headings), ("gridLines", p.gridlines)] {
|
||||
if v {
|
||||
let _ = write!(s, " {a}=\"1\"");
|
||||
}
|
||||
}
|
||||
s.push_str("/>");
|
||||
}
|
||||
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
|
||||
let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out);
|
||||
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
|
||||
let _ =
|
||||
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
|
||||
s.push_str("<pageSetup");
|
||||
if let Some(id) = paper_id(&p.paper)
|
||||
&& id != 1
|
||||
{
|
||||
let _ = write!(s, " paperSize=\"{id}\"");
|
||||
}
|
||||
if p.scale != 100 && (10..=400).contains(&p.scale) {
|
||||
let _ = write!(s, " scale=\"{}\"", p.scale);
|
||||
}
|
||||
if let Some((w, h)) = p.fit_to {
|
||||
let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\"");
|
||||
}
|
||||
let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
|
||||
if let Some(id) = kept.page_setup_id() {
|
||||
let _ = write!(s, " r:id=\"{id}\"");
|
||||
}
|
||||
s.push_str("/>");
|
||||
if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins {
|
||||
s.push_str("<headerFooter");
|
||||
for (attr, on, default) in [
|
||||
("differentOddEven", p.different_odd_even, false),
|
||||
("differentFirst", p.different_first, false),
|
||||
("scaleWithDoc", p.hf_scale_with_doc, true),
|
||||
("alignWithMargins", p.hf_align_with_margins, true),
|
||||
] {
|
||||
if on != default {
|
||||
let _ = write!(s, " {attr}=\"{}\"", u8::from(on));
|
||||
}
|
||||
}
|
||||
s.push('>');
|
||||
// CT_HeaderFooter's element order.
|
||||
for (tag, text) in [
|
||||
("oddHeader", &p.header),
|
||||
("oddFooter", &p.footer),
|
||||
("evenHeader", &p.even_header),
|
||||
("evenFooter", &p.even_footer),
|
||||
("firstHeader", &p.first_header),
|
||||
("firstFooter", &p.first_footer),
|
||||
] {
|
||||
if !text.is_empty() {
|
||||
let _ = write!(s, "<{tag}>{}</{tag}>", esc(text));
|
||||
}
|
||||
}
|
||||
s.push_str("</headerFooter>");
|
||||
}
|
||||
for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] {
|
||||
let list: Vec<u32> = list.iter().copied().filter(|&b| b > 0).collect();
|
||||
if !list.is_empty() {
|
||||
let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len());
|
||||
for b in list {
|
||||
let _ = write!(s, "<brk id=\"{b}\" max=\"{max}\" man=\"1\"/>");
|
||||
}
|
||||
let _ = write!(s, "</{tag}>");
|
||||
}
|
||||
}
|
||||
|
||||
// Drawing: pictures, charts, shapes.
|
||||
if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() {
|
||||
out.drawings += 1;
|
||||
let dn = out.drawings;
|
||||
let mut drels = Rels::default();
|
||||
let mut objs: Vec<Obj<'_>> = vec![];
|
||||
for img in &sheet.images {
|
||||
if img.data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ext = image_ext(&img.mime, &img.data);
|
||||
out.default_type(ext, image_content_type(ext));
|
||||
let file = out.image_part(ext, &img.data);
|
||||
let rid = drels.add("image", &format!("../media/{file}"));
|
||||
objs.push(Obj::Image(img, rid));
|
||||
}
|
||||
for ch in &sheet.charts {
|
||||
out.charts += 1;
|
||||
let rid = if crate::chartex::is_chartex(ch.kind) {
|
||||
let n = out.charts;
|
||||
let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names);
|
||||
out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes());
|
||||
// Excel expects a chartex part's style and colour parts.
|
||||
out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes());
|
||||
out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes());
|
||||
let mut crels = Rels::default();
|
||||
crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml"));
|
||||
crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml"));
|
||||
out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes());
|
||||
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
|
||||
} else {
|
||||
let name = format!("xl/charts/chart{}.xml", out.charts);
|
||||
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes());
|
||||
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
|
||||
};
|
||||
objs.push(Obj::Chart(ch, rid));
|
||||
}
|
||||
for sh in &sheet.shapes {
|
||||
objs.push(Obj::Shape(sh));
|
||||
}
|
||||
out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes());
|
||||
if !drels.is_empty() {
|
||||
out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes());
|
||||
}
|
||||
let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml"));
|
||||
let _ = write!(s, "<drawing r:id=\"{rid}\"/>");
|
||||
}
|
||||
// Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note
|
||||
// (its text with the replies as lines, for readers without threaded comments) linked by id.
|
||||
if !sheet.comments.is_empty() {
|
||||
out.comments += 1;
|
||||
let n = out.comments;
|
||||
let ids = thread_ids(sheet, si);
|
||||
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
|
||||
if !ids.is_empty() {
|
||||
let x = threaded_xml(sheet, &ids, &mut out.persons);
|
||||
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
|
||||
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
|
||||
}
|
||||
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
|
||||
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
|
||||
let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out);
|
||||
out.part(&vml_name, None, vml.into_bytes());
|
||||
rels.add("comments", &format!("../comments{n}.xml"));
|
||||
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
} else if let Some(rid) = kept.lone_vml(&mut rels, out) {
|
||||
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
|
||||
}
|
||||
s.push_str(&kept.elements());
|
||||
// Tables.
|
||||
if !tables.is_empty() {
|
||||
let mut parts = String::new();
|
||||
let mut used_names: HashSet<String> = HashSet::new();
|
||||
for (t, cols) in &tables {
|
||||
out.tables += 1;
|
||||
out.table_id += 1;
|
||||
let n = out.tables;
|
||||
let name = table_name(&t.name, &mut used_names, out.table_id);
|
||||
let xml = table_xml(t, cols, &name, out.table_id, out);
|
||||
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes());
|
||||
let rid = rels.add("table", &format!("../tables/table{n}.xml"));
|
||||
let _ = write!(parts, "<tablePart r:id=\"{rid}\"/>");
|
||||
}
|
||||
let _ = write!(s, "<tableParts count=\"{}\">{parts}</tableParts>", tables.len());
|
||||
}
|
||||
// Excel 2010 extensions: solid data bars, then sparklines.
|
||||
let solid_bars: Vec<(&CondFormat, u32)> = sheet
|
||||
.cond_formats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. }))
|
||||
.map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1)))
|
||||
.collect();
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("<extLst>");
|
||||
}
|
||||
if !solid_bars.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<ext uri=\"{{78C0D931-6437-407d-A8A1-0F90B4350D7D}}\" xmlns:x14=\"{X14_NS}\" xmlns:xm=\"{XM_NS}\"><x14:conditionalFormattings>"
|
||||
);
|
||||
for (cf, p) in &solid_bars {
|
||||
if let CfRule::DataBar { min, max, .. } = &cf.rule {
|
||||
let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max));
|
||||
let _ = write!(s, "<x14:conditionalFormatting><x14:cfRule type=\"dataBar\" id=\"{id}\"><x14:dataBar gradient=\"0\">{lo}{hi}");
|
||||
let _ = write!(s, "</x14:dataBar></x14:cfRule><xm:sqref>{}</xm:sqref></x14:conditionalFormatting>", sqref(&cf.ranges));
|
||||
}
|
||||
}
|
||||
s.push_str("</x14:conditionalFormattings></ext>");
|
||||
}
|
||||
if !sheet.sparklines.is_empty() {
|
||||
let _ = write!(s, "<ext uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\" xmlns:x14=\"{X14_NS}\"><x14:sparklineGroups xmlns:xm=\"{XM_NS}\">");
|
||||
for sp in &sheet.sparklines {
|
||||
let ty = match sp.kind {
|
||||
SparklineKind::Line => "",
|
||||
SparklineKind::Column => " type=\"column\"",
|
||||
SparklineKind::WinLoss => " type=\"stacked\"",
|
||||
};
|
||||
let src =
|
||||
if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) };
|
||||
let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) };
|
||||
let _ = write!(
|
||||
s,
|
||||
"<x14:sparklineGroup displayEmptyCellsAs=\"gap\"{ty}{}><x14:colorSeries {color}/><x14:sparklines><x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup>",
|
||||
if sp.markers { " markers=\"1\"" } else { "" },
|
||||
esc(&src),
|
||||
sp.cell.a1()
|
||||
);
|
||||
}
|
||||
s.push_str("</x14:sparklineGroups></ext>");
|
||||
}
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("</extLst>");
|
||||
}
|
||||
s.push_str("</worksheet>");
|
||||
(s, rels)
|
||||
prio
|
||||
}
|
||||
|
||||
fn write_cell(
|
||||
s: &mut String,
|
||||
c: CellRef,
|
||||
cell: Option<&Cell>,
|
||||
picture: Option<&std::sync::Arc<gridcraft_model::CellPicture>>,
|
||||
ov: Option<&String>,
|
||||
extra: Option<&Value>,
|
||||
dynamic: Option<&RangeRef>,
|
||||
sid: &dyn Fn(gridcraft_model::StyleId) -> u32,
|
||||
out: &mut Out,
|
||||
) {
|
||||
struct SheetCellData<'a> {
|
||||
workbook: &'a Workbook,
|
||||
index: usize,
|
||||
sheet: &'a Sheet,
|
||||
overrides: &'a BTreeMap<CellRef, String>,
|
||||
extra: &'a BTreeMap<CellRef, Value>,
|
||||
dynamic: &'a BTreeMap<CellRef, RangeRef>,
|
||||
}
|
||||
|
||||
fn write_sheet_data(s: &mut String, data: SheetCellData<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) {
|
||||
let SheetCellData { workbook, index, sheet, overrides, extra, dynamic } = data;
|
||||
// Cell storage is already row-major; sort only when extra cells need merging.
|
||||
let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len());
|
||||
keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell))));
|
||||
if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) {
|
||||
let mut all: BTreeSet<CellRef> = keys.iter().map(|(c, _)| *c).collect();
|
||||
all.extend(sheet.cell_pictures.keys().copied());
|
||||
all.extend(extra.keys().copied());
|
||||
all.extend(overrides.keys().copied());
|
||||
keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect();
|
||||
}
|
||||
s.reserve(keys.len().saturating_mul(32));
|
||||
s.push_str("<sheetData>");
|
||||
let mut row_infos = sheet.rows.iter().peekable();
|
||||
let mut next = 0;
|
||||
loop {
|
||||
let cell_row = keys.get(next).map(|(cell, _)| cell.row);
|
||||
let info_row = row_infos.peek().map(|(row, _)| **row);
|
||||
let row = match (cell_row, info_row) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => break,
|
||||
};
|
||||
let info = match row_infos.next_if(|(entry_row, _)| **entry_row == row) {
|
||||
Some((_, info)) => *info,
|
||||
None => LineInfo::default(),
|
||||
};
|
||||
let first = next;
|
||||
while keys.get(next).is_some_and(|(cell, _)| cell.row == row) {
|
||||
next += 1;
|
||||
}
|
||||
let cells = keys.get(first..next).unwrap_or_default();
|
||||
if cells.is_empty() && info == LineInfo::default() {
|
||||
continue;
|
||||
}
|
||||
s.push_str("<row r=\"");
|
||||
push_u64(s, u64::from(row) + 1);
|
||||
s.push('"');
|
||||
if let Some(st) = info.style {
|
||||
let _ = write!(s, " s=\"{}\" customFormat=\"1\"", sid(st));
|
||||
}
|
||||
if let Some(h) = info.size {
|
||||
let _ = write!(s, " ht=\"{}\"{}", num(px_to_pt(h)), if info.custom { " customHeight=\"1\"" } else { "" });
|
||||
}
|
||||
if info.hidden {
|
||||
s.push_str(" hidden=\"1\"");
|
||||
}
|
||||
if info.outline > 0 {
|
||||
let _ = write!(s, " outlineLevel=\"{}\"", info.outline.min(7));
|
||||
}
|
||||
if info.collapsed {
|
||||
s.push_str(" collapsed=\"1\"");
|
||||
}
|
||||
if cells.is_empty() {
|
||||
s.push_str("/>");
|
||||
continue;
|
||||
}
|
||||
s.push('>');
|
||||
for &(cell_ref, cell) in cells {
|
||||
let ov = overrides.get(&cell_ref);
|
||||
let ex = extra.get(&cell_ref);
|
||||
let array = dynamic.get(&cell_ref).copied().or_else(|| {
|
||||
let f = cell?.formula.as_ref()?;
|
||||
(f.array.is_none() && crate::array_formula::needs_array(workbook, index, cell_ref, f)).then_some(RangeRef::cell(cell_ref))
|
||||
});
|
||||
write_cell(
|
||||
s,
|
||||
CellWriteInput {
|
||||
reference: cell_ref,
|
||||
cell,
|
||||
picture: sheet.cell_pictures.get(&cell_ref),
|
||||
header_override: ov,
|
||||
spill_value: ex,
|
||||
dynamic_range: array.as_ref(),
|
||||
},
|
||||
sid,
|
||||
out,
|
||||
);
|
||||
}
|
||||
s.push_str("</row>");
|
||||
}
|
||||
s.push_str("</sheetData>");
|
||||
}
|
||||
|
||||
fn write_sheet_views(s: &mut String, sheet: &Sheet, selected: bool) {
|
||||
s.push_str("<sheetViews><sheetView");
|
||||
if selected {
|
||||
s.push_str(" tabSelected=\"1\"");
|
||||
}
|
||||
if !sheet.show_gridlines {
|
||||
s.push_str(" showGridLines=\"0\"");
|
||||
}
|
||||
if !sheet.show_headings {
|
||||
s.push_str(" showRowColHeaders=\"0\"");
|
||||
}
|
||||
if sheet.show_formulas {
|
||||
s.push_str(" showFormulas=\"1\"");
|
||||
}
|
||||
if !sheet.show_zeros {
|
||||
s.push_str(" showZeros=\"0\"");
|
||||
}
|
||||
if !sheet.show_outline_symbols {
|
||||
s.push_str(" showOutlineSymbols=\"0\"");
|
||||
}
|
||||
if sheet.right_to_left {
|
||||
s.push_str(" rightToLeft=\"1\"");
|
||||
}
|
||||
if sheet.view != gridcraft_model::SheetViewMode::Normal {
|
||||
let _ = write!(s, " view=\"{}\"", sheet.view.as_str());
|
||||
}
|
||||
if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) {
|
||||
let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom);
|
||||
}
|
||||
if sheet.view_top_left != CellRef::default() && sheet.freeze.is_none() {
|
||||
let _ = write!(s, " topLeftCell=\"{}\"", sheet.view_top_left.a1());
|
||||
}
|
||||
s.push_str(" workbookViewId=\"0\">");
|
||||
let active = sheet.view_active.a1();
|
||||
match sheet.freeze {
|
||||
Some((rows, cols)) if rows > 0 || cols > 0 => {
|
||||
let pane = match (rows > 0, cols > 0) {
|
||||
(true, true) => "bottomRight",
|
||||
(true, false) => "bottomLeft",
|
||||
_ => "topRight",
|
||||
};
|
||||
s.push_str("<pane");
|
||||
if cols > 0 {
|
||||
let _ = write!(s, " xSplit=\"{cols}\"");
|
||||
}
|
||||
if rows > 0 {
|
||||
let _ = write!(s, " ySplit=\"{rows}\"");
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
" topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>",
|
||||
CellRef::new(
|
||||
rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1),
|
||||
cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1)
|
||||
)
|
||||
.a1()
|
||||
);
|
||||
let _ = write!(s, "<selection pane=\"{pane}\" activeCell=\"{active}\" sqref=\"{active}\"/>");
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(s, "<selection activeCell=\"{active}\" sqref=\"{active}\"/>");
|
||||
}
|
||||
}
|
||||
s.push_str("</sheetView></sheetViews>");
|
||||
}
|
||||
|
||||
fn write_cell(s: &mut String, input: CellWriteInput<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) {
|
||||
let CellWriteInput { reference: c, cell, picture, header_override: ov, spill_value: extra, dynamic_range: dynamic } = input;
|
||||
let style = cell.map(|x| sid(x.style)).unwrap_or(0);
|
||||
s.push_str("<c r=\"");
|
||||
push_a1(s, c);
|
||||
s.push('"');
|
||||
if style != 0 {
|
||||
s.push_str(" s=\"");
|
||||
push_u64(s, u64::from(style));
|
||||
s.push('"');
|
||||
let _ = write!(s, " s=\"{style}\"");
|
||||
}
|
||||
if let Some(picture) = picture {
|
||||
let vm = out.cell_pictures.index(picture);
|
||||
@@ -704,19 +751,21 @@ fn write_cell(
|
||||
};
|
||||
s.push_str(t);
|
||||
s.push('>');
|
||||
match f.array.or(dynamic.copied()) {
|
||||
Some(r) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">", r.a1());
|
||||
let text = esc(&crate::fmla::to_file(f));
|
||||
match (f.array, dynamic) {
|
||||
(Some(r), _) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
|
||||
}
|
||||
(None, Some(r)) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(s, "<f>{text}</f>");
|
||||
}
|
||||
None => s.push_str("<f>"),
|
||||
}
|
||||
push_esc(s, &crate::fmla::to_file(f));
|
||||
s.push_str("</f>");
|
||||
match &value {
|
||||
Value::Number(n) => {
|
||||
s.push_str("<v>");
|
||||
push_num(s, *n);
|
||||
s.push_str("</v>");
|
||||
let _ = write!(s, "<v>{}</v>", num(*n));
|
||||
}
|
||||
Value::Text(t) => {
|
||||
let _ = write!(s, "<v>{}</v>", esc(&encode_xstring(t)));
|
||||
@@ -735,15 +784,11 @@ fn write_cell(
|
||||
match &value {
|
||||
Value::Empty | Value::Array(_) => s.push_str("/>"),
|
||||
Value::Number(n) => {
|
||||
s.push_str("><v>");
|
||||
push_num(s, *n);
|
||||
s.push_str("</v></c>");
|
||||
let _ = write!(s, "><v>{}</v></c>", num(*n));
|
||||
}
|
||||
Value::Text(t) => {
|
||||
let i = out.sst.index(t);
|
||||
s.push_str(" t=\"s\"><v>");
|
||||
push_u64(s, u64::from(i));
|
||||
s.push_str("</v></c>");
|
||||
let _ = write!(s, " t=\"s\"><v>{i}</v></c>");
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
let _ = write!(s, " t=\"b\"><v>{}</v></c>", *b as u8);
|
||||
|
||||
@@ -127,6 +127,12 @@ fn styles_theme_and_sizes() {
|
||||
let bytes = minimal(body, &[("xl/styles.xml", styles), ("xl/theme/theme1.xml", theme)], rels, "");
|
||||
let (wb, _) = read_xlsx(&bytes).unwrap();
|
||||
let s = wb.sheet(0).unwrap();
|
||||
assert_style_definitions(&wb, s);
|
||||
assert_theme(&wb);
|
||||
assert_sheet_settings(s);
|
||||
}
|
||||
|
||||
fn assert_style_definitions(wb: &gridcraft_model::Workbook, s: &gridcraft_model::Sheet) {
|
||||
let st1 = wb.styles.get(s.cell(at("A1")).unwrap().style);
|
||||
assert_eq!(st1.num_fmt.as_str(), "0.000\"kg\"");
|
||||
assert!(st1.font.bold && st1.font.italic && st1.font.strike);
|
||||
@@ -152,6 +158,9 @@ fn styles_theme_and_sizes() {
|
||||
assert_eq!(st3.fill.pattern, PatternType::DarkGrid);
|
||||
assert_eq!(st3.fill.fg, Color::Theme(5, 0));
|
||||
assert_eq!(wb.styles.get(s.cell(at("D1")).unwrap().style).num_fmt.as_str(), "General");
|
||||
}
|
||||
|
||||
fn assert_theme(wb: &gridcraft_model::Workbook) {
|
||||
// Theme: file order dk1, lt1… maps to lt1, dk1… in the model.
|
||||
assert_eq!(wb.theme.colors[0], 0xFFFFFF);
|
||||
assert_eq!(wb.theme.colors[1], 0x000000);
|
||||
@@ -159,6 +168,9 @@ fn styles_theme_and_sizes() {
|
||||
assert_eq!(wb.theme.colors[3], 0x44546A);
|
||||
assert_eq!(wb.theme.colors[4], 0x4472C4);
|
||||
assert_eq!(wb.theme.major_font, "Calibri Light");
|
||||
}
|
||||
|
||||
fn assert_sheet_settings(s: &gridcraft_model::Sheet) {
|
||||
// Sheet settings.
|
||||
assert_eq!(s.tab_color, Some(Color::Rgb(0x00B050)));
|
||||
assert_eq!(s.freeze, Some((2, 1)));
|
||||
|
||||
@@ -46,6 +46,20 @@ fn missing_parts() {
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("no worksheets")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_part_selection_and_validation() {
|
||||
let root_rels = super::ROOT_RELS.replace("xl/workbook.xml", "xl/missing.xml");
|
||||
let workbook = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"/>"#;
|
||||
let fallback = make_zip(&[("_rels/.rels", root_rels.as_bytes()), ("xl/workbook.xml", workbook.as_bytes())]);
|
||||
assert!(read_xlsx(&fallback).is_ok());
|
||||
|
||||
let wrong_root = make_zip(&[("xl/workbook.xml", b"<worksheet/>")]);
|
||||
assert!(matches!(read_xlsx(&wrong_root), Err(IoError::Format(message)) if message.contains("unexpected root element <worksheet>")));
|
||||
|
||||
let ods = make_zip(&[("content.xml", b"<document/>")]);
|
||||
assert!(matches!(read_xlsx(&ods), Err(IoError::Format(message)) if message.contains("OpenDocument")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_coordinates_and_dimensions() {
|
||||
let body = r#"<dimension ref="A1:XFD1048576"/><cols><col min="1" max="4000000000" width="1e308"/><col min="0" max="0"/></cols>
|
||||
|
||||
@@ -213,6 +213,14 @@ fn unmodelled_parts_survive_a_round_trip() {
|
||||
let out = write_xlsx(&wb).unwrap();
|
||||
let u = Unpacked::new(&out);
|
||||
u.assert_valid();
|
||||
assert_kept_workbook_parts(&u);
|
||||
let sheet = sheet_part(&u, "Data");
|
||||
let sx = u.text(&sheet);
|
||||
assert_kept_sheet_parts(&u, &sheet, &sx);
|
||||
assert_round_trip_kept_content(&wb, &out);
|
||||
}
|
||||
|
||||
fn assert_kept_workbook_parts(u: &Unpacked) {
|
||||
// Every kept part is in the output byte for byte.
|
||||
for d in [
|
||||
PRINTER,
|
||||
@@ -244,34 +252,37 @@ fn unmodelled_parts_survive_a_round_trip() {
|
||||
let item = u.find(CUSTOM_XML.as_bytes())[0].clone();
|
||||
assert!(u.rels(&item).iter().any(|r| r.1.ends_with("/customXmlProps")));
|
||||
// Formulas using the link are written as they were.
|
||||
let sheet = sheet_part(&u, "Data");
|
||||
let sheet = sheet_part(u, "Data");
|
||||
let sx = u.text(&sheet);
|
||||
assert!(sx.contains("<f>[1]Sheet1!$A$1</f>"), "{sx}");
|
||||
assert!(sx.contains("<f>SUM([1]Sheet1!A1:A3)*2</f>"), "{sx}");
|
||||
}
|
||||
|
||||
fn assert_kept_sheet_parts(u: &Unpacked, sheet: &str, sx: &str) {
|
||||
// Printer settings (#324).
|
||||
let ps = attr_of(&sx, "pageSetup", "r:id");
|
||||
let (ty, target) = u.target(&sheet, &ps);
|
||||
let ps = attr_of(sx, "pageSetup", "r:id");
|
||||
let (ty, target) = u.target(sheet, &ps);
|
||||
assert!(ty.ends_with("/printerSettings"));
|
||||
assert_eq!(u.parts.get(&target).unwrap(), PRINTER);
|
||||
// The OLE object and its preview image, re-linked.
|
||||
let ole = attr_of(&sx, "oleObject", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE);
|
||||
let preview = attr_of(&sx, "objectPr", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF);
|
||||
let ole = attr_of(sx, "oleObject", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(sheet, &ole).1).unwrap(), OLE);
|
||||
let preview = attr_of(sx, "objectPr", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(sheet, &preview).1).unwrap(), EMF);
|
||||
// Namespace declarations travel with the fragment.
|
||||
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006");
|
||||
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main");
|
||||
assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006");
|
||||
assert_eq!(attr_of(sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main");
|
||||
// The form control.
|
||||
let ctrl = attr_of(&sx, "control", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes());
|
||||
let ctrl = attr_of(sx, "control", "r:id");
|
||||
assert_eq!(u.parts.get(&u.target(sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes());
|
||||
// Schema order: pageSetup < legacyDrawing < oleObjects < controls.
|
||||
let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}"));
|
||||
assert!(pos("<pageSetup") < pos("<legacyDrawing") && pos("<legacyDrawing") < pos("<oleObjects") && pos("<oleObjects") < pos("<controls"));
|
||||
// Query table: a sheet relationship only.
|
||||
assert!(u.rels(&sheet).iter().any(|r| r.1.ends_with("/queryTable") && u.parts.get(&r.2).is_some_and(|b| b == QUERY_TABLE.as_bytes())));
|
||||
assert!(u.rels(sheet).iter().any(|r| r.1.ends_with("/queryTable") && u.parts.get(&r.2).is_some_and(|b| b == QUERY_TABLE.as_bytes())));
|
||||
// One VML drawing: the kept control shapes plus GridCraft's note, in a fresh shape block.
|
||||
let vml_id = attr_of(&sx, "legacyDrawing", "r:id");
|
||||
let vml_part = u.target(&sheet, &vml_id).1;
|
||||
let vml_id = attr_of(sx, "legacyDrawing", "r:id");
|
||||
let vml_part = u.target(sheet, &vml_id).1;
|
||||
let vml = u.text(&vml_part);
|
||||
assert!(vml.contains("_x0000_s1025") && vml.contains("_x0000_s1026"));
|
||||
assert!(!vml.contains("old note") && !vml.contains("_x0000_s1027"), "the source's note shape is replaced");
|
||||
@@ -280,9 +291,11 @@ fn unmodelled_parts_survive_a_round_trip() {
|
||||
let img = u.rels(&vml_part);
|
||||
assert_eq!(img.len(), 1);
|
||||
assert_eq!(u.parts.get(&img[0].2).unwrap(), EMF);
|
||||
}
|
||||
|
||||
fn assert_round_trip_kept_content(wb: &gridcraft_model::Workbook, out: &[u8]) {
|
||||
// Reading the output keeps the same content again.
|
||||
let (wb2, rep2) = read_xlsx(&out).unwrap();
|
||||
let (wb2, rep2) = read_xlsx(out).unwrap();
|
||||
assert!(!rep2.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep2.warnings);
|
||||
let a = wb.sheet(0).unwrap().preserved.as_deref().unwrap();
|
||||
let b = wb2.sheet(0).unwrap().preserved.as_deref().unwrap();
|
||||
@@ -293,7 +306,6 @@ fn unmodelled_parts_survive_a_round_trip() {
|
||||
assert_eq!(da, db);
|
||||
assert_eq!(a.elements.len(), b.elements.len());
|
||||
assert_eq!(wb.preserved.as_deref().unwrap().parts.len(), wb2.preserved.as_deref().unwrap().parts.len());
|
||||
u.assert_valid();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -236,104 +236,119 @@ fn pivots_round_trip() {
|
||||
|
||||
#[test]
|
||||
fn pivot_package_structure() {
|
||||
let bytes = write_xlsx(&workbook()).unwrap();
|
||||
let parts = unzip(&bytes);
|
||||
let parts = unzip(&write_xlsx(&workbook()).unwrap());
|
||||
assert_pivot_content_types(&parts);
|
||||
assert_relationship_targets_exist(&parts);
|
||||
let cache_ids = assert_workbook_cache_relationships(&parts);
|
||||
assert_pivot_tables_reference_caches(&parts, &cache_ids);
|
||||
assert_sheet_pivot_relationships(&parts);
|
||||
}
|
||||
|
||||
fn assert_pivot_content_types(parts: &HashMap<String, Vec<u8>>) {
|
||||
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 part in overrides.keys() {
|
||||
assert!(parts.contains_key(part), "override for missing part {part}");
|
||||
}
|
||||
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}");
|
||||
for part in parts.keys().filter(|p| p.as_str() != "[Content_Types].xml") {
|
||||
let ext = part.rsplit('.').next().unwrap();
|
||||
assert!(overrides.contains_key(part) || defaults.contains(ext), "no content type for {part}");
|
||||
}
|
||||
let count = |ct: &str| overrides.values().filter(|v| v.as_str() == ct).count();
|
||||
let count = |content_type: &str| overrides.values().filter(|value| value.as_str() == content_type).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;
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_relationship_targets_exist(parts: &HashMap<String, Vec<u8>>) {
|
||||
let mut kinds: Vec<String> = Vec::new();
|
||||
for (name, data) in parts.iter().filter(|(name, _)| name.ends_with(".rels")) {
|
||||
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") {
|
||||
for relation in xml::parse(data).unwrap().kids("Relationship") {
|
||||
if relation.attr("TargetMode") == Some("External") {
|
||||
continue;
|
||||
}
|
||||
let target = crate::package::resolve(dir, r.attr("Target").unwrap());
|
||||
let target = crate::package::resolve(dir, relation.attr("Target").unwrap());
|
||||
assert!(parts.contains_key(&target), "{name} points at missing {target}");
|
||||
kinds.push(r.attr("Type").unwrap().rsplit('/').next().unwrap().to_string());
|
||||
kinds.push(relation.attr("Type").unwrap().rsplit('/').next().unwrap().to_string());
|
||||
}
|
||||
}
|
||||
for k in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] {
|
||||
assert!(kinds.iter().any(|x| x == k), "no {k} relationship");
|
||||
for kind in ["pivotTable", "pivotCacheDefinition", "pivotCacheRecords"] {
|
||||
assert!(kinds.iter().any(|found| found == kind), "no {kind} 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();
|
||||
}
|
||||
|
||||
fn assert_workbook_cache_relationships(parts: &HashMap<String, Vec<u8>>) -> HashSet<String> {
|
||||
let workbook = xml::parse(parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
let relationships = xml::parse(parts.get("xl/_rels/workbook.xml.rels").unwrap()).unwrap();
|
||||
let caches: Vec<&xml::El> = workbook.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"));
|
||||
for cache in &caches {
|
||||
assert!(ids.insert(cache.attr("cacheId").unwrap().to_string()));
|
||||
let rid = cache.attr("id").unwrap();
|
||||
let relation = relationships.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap();
|
||||
assert!(relation.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}");
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn assert_pivot_tables_reference_caches(parts: &HashMap<String, Vec<u8>>, ids: &HashSet<String>) {
|
||||
for table_number in 1..=3 {
|
||||
let table = xml::parse(parts.get(&format!("xl/pivotTables/pivotTable{table_number}.xml")).unwrap()).unwrap();
|
||||
assert!(ids.contains(table.attr("cacheId").unwrap()));
|
||||
let table_rels = xml::parse(parts.get(&format!("xl/pivotTables/_rels/pivotTable{table_number}.xml.rels")).unwrap()).unwrap();
|
||||
let cache_path = crate::package::resolve("xl/pivotTables", table_rels.kids("Relationship").next().unwrap().attr("Target").unwrap());
|
||||
let cache = xml::parse(parts.get(&cache_path).unwrap()).unwrap();
|
||||
assert_pivot_table_fields_match_cache(&table, &cache);
|
||||
assert_pivot_cache_records_match_fields(parts, &cache, &cache_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_pivot_table_fields_match_cache(table: &xml::El, cache: &xml::El) {
|
||||
let cache_fields = cache.child("cacheFields").unwrap();
|
||||
let field_count = cache_fields.kids("cacheField").count();
|
||||
assert_eq!(table.child("pivotFields").unwrap().kids("pivotField").count(), field_count);
|
||||
assert_eq!(cache_fields.attr_u32("count"), Some(field_count as u32));
|
||||
for (field_index, pivot_field) in table.child("pivotFields").unwrap().kids("pivotField").enumerate() {
|
||||
let cache_field = cache_fields.kids("cacheField").nth(field_index).unwrap();
|
||||
let item_count = cache_field
|
||||
.path(&["fieldGroup", "groupItems"])
|
||||
.map(|items| items.children.len())
|
||||
.unwrap_or_else(|| cache_field.child("sharedItems").map(|items| items.children.len()).unwrap_or(0));
|
||||
if let Some(items) = pivot_field.child("items") {
|
||||
assert_eq!(items.attr_u32("count"), Some(items.children.len() as u32));
|
||||
for item in items.kids("item") {
|
||||
if let Some(item_index) = item.attr_u32("x") {
|
||||
assert!((item_index as usize) < item_count, "item x={item_index} out of {item_count} in field {field_index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
fn assert_pivot_cache_records_match_fields(parts: &HashMap<String, Vec<u8>>, cache: &xml::El, cache_path: &str) {
|
||||
let Some(rid) = cache.attr("id") else { return };
|
||||
let rels_path = cache_path.replace("pivotCache/", "pivotCache/_rels/") + ".rels";
|
||||
let cache_rels = xml::parse(parts.get(&rels_path).unwrap()).unwrap();
|
||||
let relation = cache_rels.kids("Relationship").find(|r| r.attr("Id") == Some(rid)).unwrap();
|
||||
let records_path = crate::package::resolve("xl/pivotCache", relation.attr("Target").unwrap());
|
||||
let records = xml::parse(parts.get(&records_path).unwrap()).unwrap();
|
||||
let database_fields = cache.child("cacheFields").unwrap().kids("cacheField").filter(|f| f.attr("databaseField") != Some("0")).count();
|
||||
assert_eq!(records.attr_u32("count"), cache.attr_u32("recordCount"));
|
||||
assert_eq!(records.kids("r").count(), 8);
|
||||
for record in records.kids("r") {
|
||||
assert_eq!(record.children.len(), database_fields);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_sheet_pivot_relationships(parts: &HashMap<String, Vec<u8>>) {
|
||||
let sheet_rels = xml::parse(parts.get("xl/worksheets/_rels/sheet2.xml.rels").unwrap()).unwrap();
|
||||
assert_eq!(sheet_rels.kids("Relationship").filter(|r| r.attr("Type").unwrap().ends_with("/pivotTable")).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -490,7 +490,16 @@ fn full_roundtrip() {
|
||||
assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic");
|
||||
let (back, rep) = read_xlsx(&bytes).unwrap();
|
||||
assert!(rep.warnings.iter().all(|w| !w.contains("skipped") || w.contains("chart")), "{:?}", rep.warnings);
|
||||
assert_workbook_metadata_roundtrip(&wb, &back);
|
||||
|
||||
let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap());
|
||||
assert_sheet_cells_and_settings(&wb, &back, a, b);
|
||||
assert_table_roundtrip(a, b);
|
||||
assert_drawing_and_chart_roundtrip(a, b);
|
||||
assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0));
|
||||
}
|
||||
|
||||
fn assert_workbook_metadata_roundtrip(wb: &Workbook, back: &Workbook) {
|
||||
assert_eq!(back.date_system, wb.date_system);
|
||||
assert_eq!(back.calc, wb.calc);
|
||||
assert!(back.protected_structure);
|
||||
@@ -505,8 +514,9 @@ fn full_roundtrip() {
|
||||
assert_eq!(back.sheets[2].visibility, Visibility::VeryHidden);
|
||||
assert_eq!(back.sheets[2].name, "2024");
|
||||
assert_eq!(back.sheets[2].comments, wb.sheets[2].comments);
|
||||
}
|
||||
|
||||
let (a, b) = (wb.sheet(0).unwrap(), back.sheet(0).unwrap());
|
||||
fn assert_sheet_cells_and_settings(wb: &Workbook, back: &Workbook, a: &Sheet, b: &Sheet) {
|
||||
assert_eq!(b.name, a.name);
|
||||
// Cells: values, formulas and resolved styles.
|
||||
let skip = [at("H2"), at("H3")];
|
||||
@@ -542,6 +552,9 @@ fn full_roundtrip() {
|
||||
let mut sp = a.sparklines.clone();
|
||||
sp[0].source = "'Data & Stuff'!B2:B3".into();
|
||||
assert_eq!(b.sparklines, sp);
|
||||
}
|
||||
|
||||
fn assert_table_roundtrip(a: &Sheet, b: &Sheet) {
|
||||
// Tables: name sanitized, ids reassigned.
|
||||
let (ta, tb) = (&a.tables[0], &b.tables[0]);
|
||||
assert_eq!(tb.name, "Sales_Table");
|
||||
@@ -549,6 +562,9 @@ fn full_roundtrip() {
|
||||
assert_eq!((tb.banded_rows, tb.banded_cols, tb.first_col, tb.last_col, tb.filter_button), (true, true, true, false, true));
|
||||
assert_eq!(tb.filter, ta.filter, "the table's filter criteria survive (SUBTOTAL tells filtered rows apart)");
|
||||
assert_eq!(b.value(at("P1")), Value::text("Region"), "header cells carry the column names");
|
||||
}
|
||||
|
||||
fn assert_drawing_and_chart_roundtrip(a: &Sheet, b: &Sheet) {
|
||||
// Drawings.
|
||||
assert_eq!(b.images.len(), 1);
|
||||
assert_eq!(b.images[0].data, a.images[0].data);
|
||||
@@ -587,7 +603,6 @@ fn full_roundtrip() {
|
||||
}
|
||||
assert!(close(y.anchor.width, x.anchor.width) && close(y.anchor.height, x.anchor.height));
|
||||
}
|
||||
assert_eq!(back.sheet(1).unwrap().value(at("A1")), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -307,58 +307,74 @@ fn reads_values_formulas_formats_and_layout() {
|
||||
assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("Chart sheets")));
|
||||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1", "Data"]);
|
||||
assert_eq!(v(&wb, 0, "A1"), Value::from("Hello"));
|
||||
assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5));
|
||||
assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0));
|
||||
assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld"));
|
||||
assert_eq!(v(&wb, 0, "A2"), Value::Bool(true));
|
||||
assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0));
|
||||
assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0));
|
||||
assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5));
|
||||
// Formulas: text plus the cached result.
|
||||
assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2"));
|
||||
assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0));
|
||||
assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));
|
||||
assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\""));
|
||||
assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!"));
|
||||
assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1"));
|
||||
assert_eq!(v(&wb, 0, "D3"), Value::Bool(true));
|
||||
assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2"));
|
||||
assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)"));
|
||||
assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1"));
|
||||
assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA));
|
||||
// Shared formula: relative offsets resolve per cell.
|
||||
assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10"));
|
||||
assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10"));
|
||||
// Array formula: the anchor holds it; the other cell's cached result is dropped so that
|
||||
// the formula fills the range on recalculation (as when an .xlsx is read, #156).
|
||||
let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();
|
||||
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));
|
||||
assert_eq!(ftext(&wb, "C6"), None);
|
||||
assert_eq!(v(&wb, 0, "C6"), Value::Empty);
|
||||
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
|
||||
let sh = &wb.sheets[0];
|
||||
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd");
|
||||
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%");
|
||||
assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]);
|
||||
assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width);
|
||||
assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0));
|
||||
assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom);
|
||||
assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2"));
|
||||
assert_eq!(v(&wb, 0, "J10"), Value::Empty);
|
||||
assert_eq!(wb.names.len(), 1);
|
||||
assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1"));
|
||||
assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0));
|
||||
assert_eq!(wb.date_system, DateSystem::D1900);
|
||||
// The import saves as .xlsx and reads back.
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2");
|
||||
assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!"));
|
||||
assert_values_roundtrip(&wb);
|
||||
assert_formula_roundtrip(&wb);
|
||||
assert_layout_roundtrip(&wb);
|
||||
assert_xlsx_conversion(&wb);
|
||||
}
|
||||
let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap();
|
||||
assert_eq!(wb.date_system, DateSystem::D1904);
|
||||
}
|
||||
|
||||
fn assert_values_roundtrip(wb: &gridcraft_model::Workbook) {
|
||||
assert_eq!(v(wb, 0, "A1"), Value::from("Hello"));
|
||||
assert_eq!(v(wb, 0, "B1"), Value::Number(1.5));
|
||||
assert_eq!(v(wb, 0, "C1"), Value::Number(42.0));
|
||||
assert_eq!(v(wb, 0, "D1"), Value::from("Wörld"));
|
||||
assert_eq!(v(wb, 0, "A2"), Value::Bool(true));
|
||||
assert_eq!(v(wb, 0, "B2"), Value::Error(CellError::Div0));
|
||||
assert_eq!(v(wb, 0, "C2"), Value::Number(7.0));
|
||||
assert_eq!(v(wb, 0, "D2"), Value::Number(1.5));
|
||||
}
|
||||
|
||||
fn assert_formula_roundtrip(wb: &gridcraft_model::Workbook) {
|
||||
// Formulas: text plus the cached result.
|
||||
assert_eq!(ftext(wb, "A3").as_deref(), Some("B1*2"));
|
||||
assert_eq!(v(wb, 0, "A3"), Value::Number(3.0));
|
||||
assert_eq!(ftext(wb, "B3").as_deref(), Some("SUM(B1:C1)"));
|
||||
assert_eq!(ftext(wb, "C3").as_deref(), Some("A1&\"!\""));
|
||||
assert_eq!(v(wb, 0, "C3"), Value::from("Hello!"));
|
||||
assert_eq!(ftext(wb, "D3").as_deref(), Some("B1>1"));
|
||||
assert_eq!(v(wb, 0, "D3"), Value::Bool(true));
|
||||
assert_eq!(ftext(wb, "E3").as_deref(), Some("Rate*2"));
|
||||
assert_eq!(ftext(wb, "F3").as_deref(), Some("ROUND(B1,0)"));
|
||||
assert_eq!(ftext(wb, "G3").as_deref(), Some("Data!A1"));
|
||||
assert_eq!(v(wb, 0, "H3"), Value::Error(CellError::NA));
|
||||
// Shared formula: relative offsets resolve per cell.
|
||||
assert_eq!(ftext(wb, "B5").as_deref(), Some("A5*10"));
|
||||
assert_eq!(ftext(wb, "B6").as_deref(), Some("A6*10"));
|
||||
// Array formula: the anchor holds it; the other cell's cached result is dropped so that
|
||||
// the formula fills the range on recalculation (as when an .xlsx is read, #156).
|
||||
let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();
|
||||
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));
|
||||
assert_eq!(ftext(wb, "C6"), None);
|
||||
assert_eq!(v(wb, 0, "C6"), Value::Empty);
|
||||
}
|
||||
|
||||
fn assert_layout_roundtrip(wb: &gridcraft_model::Workbook) {
|
||||
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
|
||||
let sh = &wb.sheets[0];
|
||||
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd");
|
||||
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%");
|
||||
assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]);
|
||||
assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width);
|
||||
assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0));
|
||||
assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom);
|
||||
assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2"));
|
||||
assert_eq!(v(wb, 0, "J10"), Value::Empty);
|
||||
assert_eq!(wb.names.len(), 1);
|
||||
assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1"));
|
||||
assert_eq!(v(wb, 1, "A1"), Value::Number(7.0));
|
||||
assert_eq!(wb.date_system, DateSystem::D1900);
|
||||
}
|
||||
|
||||
fn assert_xlsx_conversion(wb: &gridcraft_model::Workbook) {
|
||||
// The import saves as .xlsx and reads back.
|
||||
let (back, _) = read_xlsx(&write_xlsx(wb).unwrap()).unwrap();
|
||||
assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2");
|
||||
assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_result_of_a_formula_kept_as_its_value() {
|
||||
// A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result
|
||||
|
||||
+103
-86
@@ -243,7 +243,6 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
out.default_type("xml", "application/xml");
|
||||
|
||||
// Which sheet is active (must be visible), and at least one visible sheet.
|
||||
let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible);
|
||||
let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) {
|
||||
wb.active_sheet()
|
||||
} else {
|
||||
@@ -251,92 +250,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
};
|
||||
|
||||
let mut wb_rels = Rels::default();
|
||||
// Kept content first: the chart sheets among the worksheets need their relationship ids.
|
||||
let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out);
|
||||
let mut pivots = crate::pivot::PivotWriter::default();
|
||||
let mut sheets_xml = String::new();
|
||||
let mut ids = crate::passthrough::SheetIds::new(wb, &kept);
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids));
|
||||
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() {
|
||||
out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes());
|
||||
}
|
||||
let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1));
|
||||
let state = match sheet.visibility {
|
||||
Visibility::Visible => "",
|
||||
_ if !any_visible && i == active => "",
|
||||
Visibility::Hidden => " state=\"hidden\"",
|
||||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = sheet.name.chars().take(31).collect();
|
||||
let sheet_id = ids.take(sheet.file_id);
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{sheet_id}\"{state} r:id=\"{rid}\"/>", esc_attr(&name));
|
||||
}
|
||||
sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids));
|
||||
let (sheets_xml, kept, pivots) = write_sheets(wb, &mut out, active, &mut wb_rels);
|
||||
|
||||
// Defined names, then print areas and titles.
|
||||
let mut names = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for n in &wb.names {
|
||||
if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sc) = n.scope
|
||||
&& sc >= wb.sheets.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let _ = write!(names, "<definedName name=\"{}\"", esc_attr(&n.name));
|
||||
if let Some(sc) = n.scope {
|
||||
let _ = write!(names, " localSheetId=\"{}\"", kept.file_index(sc));
|
||||
}
|
||||
if n.hidden {
|
||||
names.push_str(" hidden=\"1\"");
|
||||
}
|
||||
if !n.comment.is_empty() {
|
||||
let _ = write!(names, " comment=\"{}\"", esc_attr(&n.comment));
|
||||
}
|
||||
let _ = write!(names, ">{}</definedName>", esc(&crate::fmla::text_to_file(&n.formula)));
|
||||
}
|
||||
for (i, f) in out.chart_names.iter().enumerate() {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"{}{i}\" hidden=\"1\">{}</definedName>",
|
||||
crate::chartex::NAME_PREFIX,
|
||||
esc(&crate::fmla::text_to_file(f))
|
||||
);
|
||||
}
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let q = quote_sheet(&sheet.name);
|
||||
if let Some(pa) = &sheet.print.print_area {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{}\">{}!{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&q),
|
||||
abs_range(pa)
|
||||
);
|
||||
}
|
||||
let mut titles = vec![];
|
||||
if let Some((c0, c1)) = sheet.print.title_cols {
|
||||
titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1)))));
|
||||
}
|
||||
if let Some((r0, r1)) = sheet.print.title_rows {
|
||||
titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1)))));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{}\">{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&titles.join(","))
|
||||
);
|
||||
}
|
||||
}
|
||||
let names = write_defined_names(wb, &kept, &out.chart_names);
|
||||
|
||||
let mut w = format!("{XML_DECL}<workbook xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
|
||||
// A workbook read from a file keeps its <fileVersion> as it was: Excel trusts the saved values
|
||||
@@ -510,5 +426,106 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
zip.finish()
|
||||
}
|
||||
|
||||
fn write_sheets(
|
||||
wb: &Workbook,
|
||||
out: &mut Out,
|
||||
active: usize,
|
||||
wb_rels: &mut Rels,
|
||||
) -> (String, crate::passthrough::WorkbookKept, crate::pivot::PivotWriter) {
|
||||
// Kept content first: the chart sheets among the worksheets need their relationship ids.
|
||||
let kept = crate::passthrough::write_workbook(wb, wb_rels, out);
|
||||
let mut pivots = crate::pivot::PivotWriter::default();
|
||||
let mut sheets_xml = String::new();
|
||||
let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible);
|
||||
let mut ids = crate::passthrough::SheetIds::new(wb, &kept);
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
sheets_xml.push_str(&kept.sheet_entries(i, false, &mut ids));
|
||||
let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, out);
|
||||
pivots.add_sheet(wb, i, 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() {
|
||||
out.part(&format!("xl/worksheets/_rels/sheet{}.xml.rels", i + 1), None, rels.xml().into_bytes());
|
||||
}
|
||||
let rid = wb_rels.add("worksheet", &format!("worksheets/sheet{}.xml", i + 1));
|
||||
let state = match sheet.visibility {
|
||||
Visibility::Visible => "",
|
||||
_ if !any_visible && i == active => "",
|
||||
Visibility::Hidden => " state=\"hidden\"",
|
||||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = sheet.name.chars().take(31).collect();
|
||||
let sheet_id = ids.take(sheet.file_id);
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{sheet_id}\"{state} r:id=\"{rid}\"/>", esc_attr(&name));
|
||||
}
|
||||
sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, &mut ids));
|
||||
|
||||
(sheets_xml, kept, pivots)
|
||||
}
|
||||
|
||||
fn write_defined_names(wb: &Workbook, kept: &crate::passthrough::WorkbookKept, chart_names: &[String]) -> String {
|
||||
// Defined names, then print areas and titles.
|
||||
let mut names = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for n in &wb.names {
|
||||
if n.name.is_empty() || !seen.insert((n.name.to_ascii_lowercase(), n.scope)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sc) = n.scope
|
||||
&& sc >= wb.sheets.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let _ = write!(names, "<definedName name=\"{}\"", esc_attr(&n.name));
|
||||
if let Some(sc) = n.scope {
|
||||
let _ = write!(names, " localSheetId=\"{}\"", kept.file_index(sc));
|
||||
}
|
||||
if n.hidden {
|
||||
names.push_str(" hidden=\"1\"");
|
||||
}
|
||||
if !n.comment.is_empty() {
|
||||
let _ = write!(names, " comment=\"{}\"", esc_attr(&n.comment));
|
||||
}
|
||||
let _ = write!(names, ">{}</definedName>", esc(&crate::fmla::text_to_file(&n.formula)));
|
||||
}
|
||||
for (i, f) in chart_names.iter().enumerate() {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"{}{i}\" hidden=\"1\">{}</definedName>",
|
||||
crate::chartex::NAME_PREFIX,
|
||||
esc(&crate::fmla::text_to_file(f))
|
||||
);
|
||||
}
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let q = quote_sheet(&sheet.name);
|
||||
if let Some(pa) = &sheet.print.print_area {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{}\">{}!{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&q),
|
||||
abs_range(pa)
|
||||
);
|
||||
}
|
||||
let mut titles = vec![];
|
||||
if let Some((c0, c1)) = sheet.print.title_cols {
|
||||
titles.push(format!("{q}!{}", abs_range(&RangeRef::cols(c0.min(c1), c0.max(c1)))));
|
||||
}
|
||||
if let Some((r0, r1)) = sheet.print.title_rows {
|
||||
titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1)))));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{}\">{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&titles.join(","))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
/// Cell metadata declaring dynamic-array formulas (MS-XLSX `XLDAPR`).
|
||||
const METADATA: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<metadata xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:xda=\"http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray\"><metadataTypes count=\"1\"><metadataType name=\"XLDAPR\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" coerce=\"1\" cellMeta=\"1\"/></metadataTypes><futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata><cellMetadata count=\"1\"><bk><rc t=\"1\" v=\"0\"/></bk></cellMetadata></metadata>";
|
||||
Reference in new issue
Block a user