Refactor xlsx for strict Clippy checks

This commit is contained in:
Dylan M. Taylor committed 2026-10-28 10:52:53 +00:00
1 parent 7fbf3f60c8
commit 84e684e586
15 files changed
+1041 -829

No files matched your search

+1 -1
View File
@@ -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;
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(',') {
+19 -17
View File
@@ -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") {
+4 -1
View File
@@ -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;
+4 -1
View File
@@ -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
View File
@@ -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);
+12
View File
@@ -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)));
+14
View File
@@ -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>
+28 -16
View File
@@ -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]
+88 -73
View File
@@ -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]
+17 -2
View File
@@ -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]
+63 -47
View File
@@ -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
View File
@@ -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>";