diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 04f757d3a..3e2fea105 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -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; diff --git a/crates/xlsx/src/pivot/read.rs b/crates/xlsx/src/pivot/read.rs index f811533ab..134a8ab22 100644 --- a/crates/xlsx/src/pivot/read.rs +++ b/crates/xlsx/src/pivot/read.rs @@ -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 diff --git a/crates/xlsx/src/pivot/write.rs b/crates/xlsx/src/pivot/write.rs index 44e0c386a..a56bbf707 100644 --- a/crates/xlsx/src/pivot/write.rs +++ b/crates/xlsx/src/pivot/write.rs @@ -246,12 +246,7 @@ fn header_text(v: &Value) -> String { } } -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { - let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; - if r.count() > MAX_SOURCE_CELLS { - return None; - } - let sh = wb.sheet(si)?; +fn cache_fields(sh: &gridcraft_model::Sheet, r: RangeRef) -> Vec { let mut fields: Vec = 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, 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 = 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)> = 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 Option Option { + let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; + if r.count() > MAX_SOURCE_CELLS { + return None; + } + let sh = wb.sheet(si)?; + let mut fields = 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 = 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> = 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, + column_fields: Vec, + hidden_fields: HashMap, + 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) -> String { - let nf = c.fields.len(); let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); - let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); + let row_fields: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); + let column_fields: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); // Filters: on an axis field → hidden items there; otherwise a page field. let mut hidden: HashMap = HashMap::new(); let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); @@ -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], 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, ""); - // Fields. - let _ = write!(s, ""); + 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(""); + if hide_values_row { + let _ = write!( + s, + "" + ); + } + s.push_str(""); + 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, "", c.fields.len()); for (i, f) in c.fields.iter().enumerate() { s.push_str(""); +} - 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, "", pages.len()); - for (i, f) in &pages { - let _ = write!(s, ""); - } - s.push_str(""); +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, ""); - let mut used: Vec = Vec::new(); - for (i, v) in &vals { - let base = if v.name.trim().is_empty() { - format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) - } else { - v.name.clone() - }; - let mut n = base.clone(); - let mut j = 2; - while used.iter().any(|u| same(u, &n)) { - n = format!("{base}{j}"); - j += 1; + let _ = write!(s, "", layout.page_fields.len()); + for (i, filter) in &layout.page_fields { + let _ = write!(s, ""); + } + s.push_str(""); +} + +fn write_data_fields(s: &mut String, c: &Cache, layout: &PivotTableLayout<'_>, fmt_id: &dyn Fn(&str) -> Option) { + if layout.values.is_empty() { + return; + } + let _ = write!(s, "", layout.values.len()); + let mut used: Vec = 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, " 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, " s.push_str(" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::RunningTotal => { - let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); - } - PivotShowAs::Rank => { - let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); - } - } - if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { - let _ = write!(s, " numFmtId=\"{id}\""); - } - if v.show_as == PivotShowAs::Rank { - let _ = write!( - s, - ">" - ); - } else { - s.push_str("/>"); + PivotShowAs::Rank => { + let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); } } - s.push_str(""); + 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, + ">" + ); + } else { + s.push_str("/>"); + } } - s.push_str(""); - if hide_values_row { - let _ = write!( - s, - "" - ); - } - s.push_str(""); - s + s.push_str(""); } /// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 55ef6757d..8b118ecdb 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -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 { + 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 to a model sheet index. @@ -426,7 +447,10 @@ pub fn parse_print_area(text: &str) -> Option { 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(',') { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 57b5ac7e3..7a37e41d8 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -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 { 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 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") { diff --git a/crates/xlsx/src/sheet_read/parts.rs b/crates/xlsx/src/sheet_read/parts.rs index c7626e6e9..3ff672289 100644 --- a/crates/xlsx/src/sheet_read/parts.rs +++ b/crates/xlsx/src/sheet_read/parts.rs @@ -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; diff --git a/crates/xlsx/src/sheet_read/rules.rs b/crates/xlsx/src/sheet_read/rules.rs index e0ad3129f..4e4500707 100644 --- a/crates/xlsx/src/sheet_read/rules.rs +++ b/crates/xlsx/src/sheet_read/rules.rs @@ -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") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index b03bfbd5e..10bb3d21d 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -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, "", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into())); - // sheetViews - s.push_str(""); - 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(" 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, ""); - } - _ => { - let _ = write!(s, ""); - } - } - s.push_str(""); + 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(""); } - // 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 = 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 = 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, ""); + } + let _ = write!(s, "{parts}", tables.len()); } - s.reserve(keys.len().saturating_mul(32)); - s.push_str(""); - 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(""); + (s, rels) +} + +struct CellWriteInput<'a> { + reference: CellRef, + cell: Option<&'a Cell>, + picture: Option<&'a std::sync::Arc>, + 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(""); + } + if !solid_bars.is_empty() { + let _ = write!( + s, + "" + ); + 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, "{lo}{hi}"); + let _ = write!(s, "{}", sqref(&cf.ranges)); + } } - let cells = keys.get(first..next).unwrap_or_default(); - if cells.is_empty() && info == LineInfo::default() { - continue; + s.push_str(""); + } + if !sheet.sparklines.is_empty() { + let _ = write!(s, ""); + 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, + "{}{}", + if sp.markers { " markers=\"1\"" } else { "" }, + esc(&src), + sp.cell.a1() + ); } - s.push_str(""); + } + if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { + s.push_str(""); + } +} + +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, ""); + } else if let Some(rid) = kept.lone_vml(rels, out) { + let _ = write!(s, ""); + } + 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> = 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, ""); + } +} + +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(""); + } + // 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, "", m(0), m(1), m(2), m(3), m(4), m(5)); + 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("'); - 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}>{}", esc(text)); + } + } + s.push_str(""); + } + for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { + let list: Vec = 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, ""); + } + let _ = write!(s, ""); } - s.push_str(""); } - s.push_str(""); + kept +} + +fn write_sheet_rules(s: &mut String, sheet: &Sheet, rels: &mut Rels, out: &mut Out) -> Vec { // sheetProtection if let Some(p) = &sheet.protection { s.push_str(" ( } s.push_str(""); } - // Printing. - let p = &sheet.print; - if p.gridlines || p.headings || p.center_h || p.center_v { - 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, "", m(0), m(1), m(2), m(3), m(4), m(5)); - 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("'); - // 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}>{}", esc(text)); - } - } - s.push_str(""); - } - for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] { - let list: Vec = 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, ""); - } - let _ = write!(s, ""); - } - } - - // 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> = 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, ""); - } - // 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, ""); - } else if let Some(rid) = kept.lone_vml(&mut rels, out) { - let _ = write!(s, ""); - } - s.push_str(&kept.elements()); - // Tables. - if !tables.is_empty() { - let mut parts = String::new(); - let mut used_names: HashSet = 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, ""); - } - let _ = write!(s, "{parts}", 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(""); - } - if !solid_bars.is_empty() { - let _ = write!( - s, - "" - ); - 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, "{lo}{hi}"); - let _ = write!(s, "{}", sqref(&cf.ranges)); - } - } - s.push_str(""); - } - if !sheet.sparklines.is_empty() { - let _ = write!(s, ""); - 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, - "{}{}", - if sp.markers { " markers=\"1\"" } else { "" }, - esc(&src), - sp.cell.a1() - ); - } - s.push_str(""); - } - if !solid_bars.is_empty() || !sheet.sparklines.is_empty() { - s.push_str(""); - } - s.push_str(""); - (s, rels) + prio } -fn write_cell( - s: &mut String, - c: CellRef, - cell: Option<&Cell>, - picture: Option<&std::sync::Arc>, - 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, + extra: &'a BTreeMap, + dynamic: &'a BTreeMap, +} + +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 = 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(""); + 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(""); + } + s.push_str(""); +} + +fn write_sheet_views(s: &mut String, sheet: &Sheet, selected: bool) { + s.push_str(""); + 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(" 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, ""); + } + _ => { + let _ = write!(s, ""); + } + } + s.push_str(""); +} + +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("'); - match f.array.or(dynamic.copied()) { - Some(r) => { - let _ = write!(s, "", r.a1()); + let text = esc(&crate::fmla::to_file(f)); + match (f.array, dynamic) { + (Some(r), _) => { + let _ = write!(s, "{text}", r.a1()); + } + (None, Some(r)) => { + let _ = write!(s, "{text}", r.a1()); + } + _ => { + let _ = write!(s, "{text}"); } - None => s.push_str(""), } - push_esc(s, &crate::fmla::to_file(f)); - s.push_str(""); match &value { Value::Number(n) => { - s.push_str(""); - push_num(s, *n); - s.push_str(""); + let _ = write!(s, "{}", num(*n)); } Value::Text(t) => { let _ = write!(s, "{}", 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(">"); - push_num(s, *n); - s.push_str(""); + let _ = write!(s, ">{}", num(*n)); } Value::Text(t) => { let i = out.sst.index(t); - s.push_str(" t=\"s\">"); - push_u64(s, u64::from(i)); - s.push_str(""); + let _ = write!(s, " t=\"s\">{i}"); } Value::Bool(b) => { let _ = write!(s, " t=\"b\">{}", *b as u8); diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 08da55991..6b9dff341 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -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))); diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 4ee815e86..0fb9f1b43 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -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#""#; + 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"")]); + assert!(matches!(read_xlsx(&wrong_root), Err(IoError::Format(message)) if message.contains("unexpected root element "))); + + let ods = make_zip(&[("content.xml", b"")]); + assert!(matches!(read_xlsx(&ods), Err(IoError::Format(message)) if message.contains("OpenDocument"))); +} + #[test] fn huge_coordinates_and_dimensions() { let body = r#" diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 343bfa9ee..28113be0d 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -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("[1]Sheet1!$A$1"), "{sx}"); assert!(sx.contains("SUM([1]Sheet1!A1:A3)*2"), "{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(">) { let ct = xml::parse(parts.get("[Content_Types].xml").unwrap()).unwrap(); let overrides: HashMap = ct .kids("Override") .map(|o| (o.attr("PartName").unwrap().trim_start_matches('/').to_string(), o.attr("ContentType").unwrap().to_string())) .collect(); let defaults: HashSet = ct.kids("Default").map(|d| d.attr("Extension").unwrap().to_string()).collect(); - // Every override names an existing part; every part has a content type. - for p in overrides.keys() { - assert!(parts.contains_key(p), "override for missing part {p}"); + for 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 = vec![]; - for (name, data) in &parts { - if !name.ends_with(".rels") { - continue; - } +} + +fn assert_relationship_targets_exist(parts: &HashMap>) { + let mut kinds: Vec = 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>) -> HashSet { + 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>, ids: &HashSet) { + 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>, 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>) { + 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] diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 14fc784f9..2298e513f 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -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] diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index da65de014..bd21b4939 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -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::>(), ["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 diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index b96df35c7..f03dd9b04 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -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, "", 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, "", esc(&crate::fmla::text_to_file(&n.formula))); - } - for (i, f) in out.chart_names.iter().enumerate() { - let _ = write!( - names, - "", - 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, - "{}!{}", - 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, - "{}", - kept.file_index(i), - esc(&titles.join(",")) - ); - } - } + let names = write_defined_names(wb, &kept, &out.chart_names); let mut w = format!("{XML_DECL}"); // A workbook read from a file keeps its 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, "", 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, "", esc(&crate::fmla::text_to_file(&n.formula))); + } + for (i, f) in chart_names.iter().enumerate() { + let _ = write!( + names, + "", + 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, + "{}!{}", + 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, + "{}", + kept.file_index(i), + esc(&titles.join(",")) + ); + } + } + + names +} + /// Cell metadata declaring dynamic-array formulas (MS-XLSX `XLDAPR`). const METADATA: &str = "\n";