diff --git a/src/auto.rs b/src/auto.rs index dca74aa3..0c56f5c8 100644 --- a/src/auto.rs +++ b/src/auto.rs @@ -7,8 +7,8 @@ use crate::errors::Error; use crate::vba::VbaProject; use crate::{ - open_workbook, open_workbook_from_rs, Data, DataRef, HeaderRow, Metadata, Ods, Range, Reader, - ReaderRef, Xls, Xlsb, Xlsx, + open_workbook, open_workbook_from_rs, Data, DataRef, HeaderRow, IndexSet, Metadata, Ods, Range, + Reader, ReaderRef, Xls, Xlsb, Xlsx, }; use std::fs::File; use std::io::BufReader; @@ -175,8 +175,32 @@ where match self { Sheets::Xlsx(e) => e.worksheet_range_ref(name).map_err(Error::Xlsx), Sheets::Xlsb(e) => e.worksheet_range_ref(name).map_err(Error::Xlsb), - Sheets::Xls(_) => unimplemented!(), - Sheets::Ods(_) => unimplemented!(), + _ => Err(Error::Msg( + // Xls and Ods are eager, owned-data readers and don't produce + // borrowed `DataRef` ranges (they don't implement `ReaderRef`) + "`worksheet_range_ref` is only supported for Xlsx and Xlsb", + )), + } + } + + fn worksheet_range_ref_region<'a>( + &'a mut self, + name: &str, + cols: impl Into, + rows: impl Into, + ) -> Result>, Self::Error> { + let cols = cols.into(); + let rows = rows.into(); + match self { + Sheets::Xlsx(e) => e + .worksheet_range_ref_region(name, cols, rows) + .map_err(Error::Xlsx), + Sheets::Xlsb(e) => e + .worksheet_range_ref_region(name, cols, rows) + .map_err(Error::Xlsb), + _ => Err(Error::Msg( + "`worksheet_range_ref_region` is only supported for Xlsx and Xlsb", + )), } } } diff --git a/src/index_set.rs b/src/index_set.rs new file mode 100644 index 00000000..7a339b26 --- /dev/null +++ b/src/index_set.rs @@ -0,0 +1,246 @@ +//! A normalized set of 0-based indices used to project worksheet reads onto a +//! subset of columns and/or rows. + +use std::ops::{Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}; + +/// A normalized set of 0-based indices for projecting columns or rows on a +/// worksheet read. +/// +/// Construct via `From`/`Into` from a range, a single index, a list, or a list +/// of ranges. An empty set (e.g. from `..` or `IndexSet::default()`) selects +/// **everything** (no projection). +/// +/// ``` +/// use calamine::IndexSet; +/// +/// let _: IndexSet = (0..5).into(); // a contiguous range +/// let _: IndexSet = (5..).into(); // open-ended: 5 to the last index +/// let _: IndexSet = [1, 3, 5].into(); // a discrete list +/// let _: IndexSet = [0..3, 8..10].into(); // disjoint ranges +/// let _: IndexSet = (..).into(); // all (no projection) +/// ``` +/// +/// Overlapping or duplicate inputs merge silently; the order of inputs does not +/// matter. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IndexSet { + /// Sorted, merged, non-overlapping half-open `[start, end)` intervals. + /// Empty == "all" (no projection). An open-ended upper bound is `u32::MAX`. + /// + /// Indices are assumed to be well below `u32::MAX`; `u32::MAX` is reserved + /// as the exclusive open-ended upper-bound sentinel, so it is not itself a + /// representable/selectable index. + spans: Vec<(u32, u32)>, +} + +impl IndexSet { + /// Build a normalized `IndexSet` from raw half-open `[start, end)` spans. + /// Spans with `start >= end` contribute nothing. Overlapping and adjacent + /// spans are merged. + fn from_spans(mut raw: Vec<(u32, u32)>) -> Self { + raw.retain(|&(s, e)| s < e); + raw.sort_unstable_by_key(|&(s, _)| s); + let mut spans: Vec<(u32, u32)> = Vec::with_capacity(raw.len()); + for (s, e) in raw { + match spans.last_mut() { + // Overlapping or adjacent with the previous span: extend it. + Some(last) if s <= last.1 => last.1 = last.1.max(e), + _ => spans.push((s, e)), + } + } + IndexSet { spans } + } + + /// True if this set selects everything (no projection). + pub fn is_all(&self) -> bool { + self.spans.is_empty() + } + + /// True if index `i` is selected. An empty set selects everything. + pub(crate) fn keep(&self, i: u32) -> bool { + if self.spans.is_empty() { + return true; + } + // Find the last span whose start is <= i, then bounds-check its end. + match self.spans.binary_search_by(|&(s, _)| s.cmp(&i)) { + Ok(_) => true, // i is exactly a span start + Err(0) => false, // before the first span + Err(idx) => i < self.spans[idx - 1].1, + } + } + + /// Number of selected indices that fall within `0..bound`. Used only for a + /// capacity estimate, so saturating arithmetic is fine. Returns `bound` for + /// the "all" set. + pub(crate) fn selected_count(&self, bound: u32) -> u64 { + if self.spans.is_empty() { + return bound as u64; + } + // Relies on `spans` being normalized (non-overlapping), so summing + // clamped span widths counts each selected index exactly once. + self.spans + .iter() + .map(|&(s, e)| { + let s = s.min(bound); + let e = e.min(bound); + (e - s) as u64 + }) + .sum() + } +} + +impl From<&[Range]> for IndexSet { + fn from(ranges: &[Range]) -> Self { + IndexSet::from_spans(ranges.iter().map(|r| (r.start, r.end)).collect()) + } +} + +impl From<&[u32]> for IndexSet { + fn from(list: &[u32]) -> Self { + IndexSet::from_spans(list.iter().map(|&i| (i, i.saturating_add(1))).collect()) + } +} + +impl From<[Range; N]> for IndexSet { + fn from(ranges: [Range; N]) -> Self { + IndexSet::from(&ranges[..]) + } +} + +impl From<[u32; N]> for IndexSet { + fn from(list: [u32; N]) -> Self { + IndexSet::from(&list[..]) + } +} + +impl From> for IndexSet { + fn from(r: Range) -> Self { + IndexSet::from_spans(vec![(r.start, r.end)]) + } +} + +impl From> for IndexSet { + fn from(r: RangeFrom) -> Self { + IndexSet::from_spans(vec![(r.start, u32::MAX)]) + } +} + +impl From for IndexSet { + fn from(_: RangeFull) -> Self { + IndexSet::default() + } +} + +impl From> for IndexSet { + fn from(r: RangeInclusive) -> Self { + let (start, end) = (*r.start(), *r.end()); + IndexSet::from_spans(vec![(start, end.saturating_add(1))]) + } +} + +impl From> for IndexSet { + fn from(r: RangeTo) -> Self { + IndexSet::from_spans(vec![(0, r.end)]) + } +} + +impl From> for IndexSet { + fn from(r: RangeToInclusive) -> Self { + IndexSet::from_spans(vec![(0, r.end.saturating_add(1))]) + } +} + +impl From for IndexSet { + fn from(i: u32) -> Self { + IndexSet::from_spans(vec![(i, i.saturating_add(1))]) + } +} + +impl From>> for IndexSet { + fn from(ranges: Vec>) -> Self { + IndexSet::from(&ranges[..]) + } +} + +impl From> for IndexSet { + fn from(list: Vec) -> Self { + IndexSet::from(&list[..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each input set reduces to the expected normalized spans. + #[test] + fn normalization() { + let cases: &[(IndexSet, &[(u32, u32)])] = &[ + ((..).into(), &[]), + ((5..5).into(), &[]), // degenerate -> nothing -> "all" + ((0..5).into(), &[(0, 5)]), + ((0..=4).into(), &[(0, 5)]), + ((5..).into(), &[(5, u32::MAX)]), + ([3u32, 1, 2, 1].into(), &[(1, 4)]), // unsorted+dup, adjacent merge + ([0..3, 8..10].into(), &[(0, 3), (8, 10)]), // disjoint + ([0..5, 3..10].into(), &[(0, 10)]), // overlapping + ([0..10, 2..5].into(), &[(0, 10)]), // contained + ]; + for (set, expected) in cases { + assert_eq!(set.spans, expected.to_vec(), "spans for {set:?}"); + assert_eq!(set.is_all(), expected.is_empty(), "is_all for {set:?}"); + } + // The empty set is the canonical "all", so `..` and `default()` should agree. + assert_eq!(IndexSet::from(..), IndexSet::default()); + } + + /// `keep(i)` for representative in/out indices, including half-open boundaries, gaps + /// between disjoint ranges, and open-ended ranges probed past any real sheet bound. + #[test] + fn membership() { + let cases: &[(IndexSet, &[(u32, bool)])] = &[ + (IndexSet::default(), &[(0, true), (999, true)]), // all + ((0..5).into(), &[(0, true), (4, true), (5, false)]), // half-open + ((0..=4).into(), &[(4, true), (5, false)]), + ((5..).into(), &[(4, false), (5, true), (1_000_000, true)]), + ( + [3u32, 1, 2, 1].into(), + &[(0, false), (1, true), (3, true), (4, false)], + ), + ( + [0..3, 8..10].into(), + &[ + (2, true), + (3, false), + (7, false), + (8, true), + (9, true), + (10, false), + ], + ), + ]; + for (set, probes) in cases { + for &(i, kept) in *probes { + assert_eq!(set.keep(i), kept, "keep({i}) for {set:?}"); + } + } + } + + /// `selected_count(bound)` capacity estimate, clamped to the bound. + #[test] + fn selected_count_clamps_to_bound() { + let cases: &[(IndexSet, u32, u64)] = &[ + ([0..3, 8..10].into(), 100, 5), // 3 + 2 + ([0..3, 8..10].into(), 9, 4), // 3 + (9 - 8) + (IndexSet::default(), 7, 7), // "all" -> bound + ((8..10).into(), 5, 0), // span starts beyond bound + ]; + for (set, bound, expected) in cases { + assert_eq!( + set.selected_count(*bound), + *expected, + "selected_count({bound}) for {set:?}" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2c1b3c79..acd0c0bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,7 @@ mod xlsx; mod de; mod errors; +mod index_set; pub mod changelog; pub mod vba; @@ -108,6 +109,7 @@ pub use crate::de::{ DeError, RangeDeserializer, RangeDeserializerBuilder, RowDeserializer, ToCellDeserializer, }; pub use crate::errors::Error; +pub use crate::index_set::IndexSet; pub use crate::ods::{Ods, OdsError}; pub use crate::xls::{Xls, XlsError, XlsOptions}; pub use crate::xlsb::{Xlsb, XlsbError}; @@ -383,18 +385,63 @@ pub trait ReaderRef: Reader where RS: Read + Seek, { - /// Get worksheet range where shared string values are only borrowed. - /// - /// This is implemented only for [`calamine::Xlsx`](crate::Xlsx) and [`calamine::Xlsb`](crate::Xlsb), as Xls and Ods formats - /// do not support lazy iteration. - fn worksheet_range_ref<'a>(&'a mut self, name: &str) - -> Result>, Self::Error>; + /// Get the worksheet range where shared string values are only borrowed, + /// projected onto a subset of columns and rows. + /// + /// `cols` and `rows` accept anything convertible to an [`IndexSet`]: a range + /// (`0..5`, `0..=4`, `5..`), a single index, a list (`[1, 3, 5]`), or a list + /// of ranges (`[0..3, 8..10]`). Pass `..` to select every column / row. + /// + /// Overlapping selections merge silently. When a header row is configured + /// via [`Reader::with_header_row`], that row is retained even if the row + /// selection would exclude it (unless the header row and every projected + /// column are entirely empty), and rows above it are always dropped. + /// + /// This is implemented only for [`calamine::Xlsx`](crate::Xlsx) and + /// [`calamine::Xlsb`](crate::Xlsb), as Xls and Ods formats do not support + /// lazy iteration. + fn worksheet_range_ref_region<'a>( + &'a mut self, + name: &str, + cols: impl Into, + rows: impl Into, + ) -> Result>, Self::Error>; + + /// Get the worksheet range where shared string values are only borrowed. + /// + /// Shortcut for [`worksheet_range_ref_region`](ReaderRef::worksheet_range_ref_region) + /// with no column/row projection. + /// + /// This is implemented only for [`calamine::Xlsx`](crate::Xlsx) and + /// [`calamine::Xlsb`](crate::Xlsb), as Xls and Ods formats do not support + /// lazy iteration. + fn worksheet_range_ref<'a>( + &'a mut self, + name: &str, + ) -> Result>, Self::Error> { + self.worksheet_range_ref_region(name, .., ..) + } - /// Get the nth worksheet range where shared string values are only borrowed. Shortcut for getting the nth - /// worksheet name, then the corresponding worksheet. - /// - /// This is implemented only for [`calamine::Xlsx`](crate::Xlsx) and [`calamine::Xlsb`](crate::Xlsb), as Xls and Ods formats - /// do not support lazy iteration. + /// Owned-data equivalent of + /// [`worksheet_range_ref_region`](ReaderRef::worksheet_range_ref_region); + /// see that method for the column/row-selection semantics. + fn worksheet_range_region( + &mut self, + name: &str, + cols: impl Into, + rows: impl Into, + ) -> Result, Self::Error> { + let rge = self.worksheet_range_ref_region(name, cols, rows)?; + let inner = rge.inner.into_iter().map(Into::into).collect(); + Ok(Range { + start: rge.start, + end: rge.end, + inner, + }) + } + + /// Get the nth worksheet range where shared string values are only borrowed. + /// Shortcut for getting the nth worksheet name, then the corresponding worksheet. fn worksheet_range_at_ref( &mut self, n: usize, diff --git a/src/utils.rs b/src/utils.rs index b4f4248b..9da5fc0a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -11,6 +11,9 @@ use std::io::{Read, Seek}; use quick_xml::{escape::resolve_xml_entity, events::BytesRef}; use zip::read::ZipArchive; +use crate::datatype::DataRef; +use crate::{Cell, Dimensions, HeaderRow, IndexSet, Range}; + const UNICODE_ESCAPE_LENGTH: usize = 7; // Length of _x00HH_. macro_rules! from_err { @@ -65,6 +68,67 @@ pub fn read_f64(s: &[u8]) -> f64 { f64::from_le_bytes(s[..8].try_into().unwrap()) } +/// Collect a worksheet's cells into a [`Range`], honouring the header-row +/// option and column/row projection. +/// +/// `cols` and `rows` are normalized index sets; an empty set selects every +/// column / row respectively. Overlapping or duplicate indices in either set +/// are merged. +pub fn collect_cells_into_range<'a, E>( + header_row: HeaderRow, + cols: &IndexSet, + rows: &IndexSet, + dimensions: Dimensions, + mut next_cell: impl FnMut() -> Result>>, E>, +) -> Result>, E> { + // `Row(idx)` floors the kept rows at the header row; `FirstNonEmptyRow` keeps all. + let min_row = match header_row { + HeaderRow::FirstNonEmptyRow => 0, + HeaderRow::Row(idx) => idx, + }; + + // Keep non-empty cells within both projections; the header row is always retained. + let header_idx = match header_row { + HeaderRow::Row(idx) => Some(idx), + HeaderRow::FirstNonEmptyRow => None, + }; + let keep = |row: u32, col: u32| { + row >= min_row && cols.keep(col) && (rows.keep(row) || Some(row) == header_idx) + }; + + // Reserve for the *projected* cell count, not the full sheet. + let full_cols = dimensions.end.1 - dimensions.start.1 + 1; + let full_rows = dimensions.end.0 - dimensions.start.0 + 1; + let projected_len = dimensions + .len() + .saturating_mul(cols.selected_count(full_cols)) + .saturating_mul(rows.selected_count(full_rows)) + / (full_cols as u64).max(1) + / (full_rows as u64).max(1); + let mut cells = Vec::new(); + if projected_len < 100_000 { + cells.reserve(projected_len as usize); + } + while let Some(cell) = next_cell()? { + if !matches!(cell.val, DataRef::Empty) && keep(cell.pos.0, cell.pos.1) { + cells.push(cell); + } + } + + // If no cell survived on the header row, anchor it with an empty cell in a + // kept column so it stays in the range. Skipped when no cells survived at all. + if let HeaderRow::Row(header_row_idx) = header_row { + if let Some(first) = cells.first() { + if first.pos.0 != header_row_idx { + let col = first.pos.1; + cells.push(Cell::new((header_row_idx, col), DataRef::Empty)); + } + } + } + + Ok(Range::from_sparse(cells)) +} + /// Push literal column into a String buffer pub fn push_column(mut col: u32, buf: &mut String) { if col < 26 { diff --git a/src/xlsb/mod.rs b/src/xlsb/mod.rs index 42d417ad..e62b11eb 100644 --- a/src/xlsb/mod.rs +++ b/src/xlsb/mod.rs @@ -23,12 +23,12 @@ use zip::result::ZipError; use crate::datatype::DataRef; use crate::formats::{builtin_format_by_code, detect_custom_number_format, CellFormat}; use crate::utils::{ - build_zip_path_cache, cached_zip_path, push_column, read_f64, read_i32, read_u16, read_u32, - read_usize, + build_zip_path_cache, cached_zip_path, collect_cells_into_range, push_column, read_f64, + read_i32, read_u16, read_u32, read_usize, }; use crate::vba::VbaProject; use crate::{ - Cell, Data, HeaderRow, Metadata, Range, Reader, ReaderRef, Sheet, SheetType, SheetVisible, + Data, HeaderRow, IndexSet, Metadata, Range, Reader, ReaderRef, Sheet, SheetType, SheetVisible, }; /// A Xlsb specific error @@ -536,13 +536,7 @@ impl Reader for Xlsb { /// MS-XLSB 2.1.7.62 fn worksheet_range(&mut self, name: &str) -> Result, XlsbError> { - let rge = self.worksheet_range_ref(name)?; - let inner = rge.inner.into_iter().map(|v| v.into()).collect(); - Ok(Range { - start: rge.start, - end: rge.end, - inner, - }) + self.worksheet_range_region(name, .., ..) } /// MS-XLSB 2.1.7.62 @@ -580,66 +574,18 @@ impl Reader for Xlsb { } impl ReaderRef for Xlsb { - fn worksheet_range_ref<'a>(&'a mut self, name: &str) -> Result>, XlsbError> { + fn worksheet_range_ref_region<'a>( + &'a mut self, + name: &str, + cols: impl Into, + rows: impl Into, + ) -> Result>, XlsbError> { + let cols = cols.into(); + let rows = rows.into(); let header_row = self.options.header_row; let mut cell_reader = self.worksheet_cells_reader(name)?; - let len = cell_reader.dimensions().len(); - let mut cells = Vec::new(); - if len < 100_000 { - cells.reserve(len as usize); - } - - match header_row { - HeaderRow::FirstNonEmptyRow => { - // the header row is the row of the first non-empty cell - loop { - match cell_reader.next_cell() { - Ok(Some(Cell { - val: DataRef::Empty, - .. - })) => (), - Ok(Some(cell)) => cells.push(cell), - Ok(None) => break, - Err(e) => return Err(e), - } - } - } - HeaderRow::Row(header_row_idx) => { - // If `header_row` is a row index, we only add non-empty cells after this index. - loop { - match cell_reader.next_cell() { - Ok(Some(Cell { - val: DataRef::Empty, - .. - })) => (), - Ok(Some(cell)) => { - if cell.pos.0 >= header_row_idx { - cells.push(cell); - } - } - Ok(None) => break, - Err(e) => return Err(e), - } - } - - // If `header_row` is set and the first non-empty cell is not at the `header_row`, we add - // an empty cell at the beginning with row `header_row` and same column as the first non-empty cell. - if cells.first().is_some_and(|c| c.pos.0 != header_row_idx) { - cells.insert( - 0, - Cell { - pos: ( - header_row_idx, - cells.first().expect("cells should not be empty").pos.1, - ), - val: DataRef::Empty, - }, - ); - } - } - } - - Ok(Range::from_sparse(cells)) + let dims = cell_reader.dimensions(); + collect_cells_into_range(header_row, &cols, &rows, dims, || cell_reader.next_cell()) } } diff --git a/src/xlsx/mod.rs b/src/xlsx/mod.rs index 8819dfdf..94e53740 100644 --- a/src/xlsx/mod.rs +++ b/src/xlsx/mod.rs @@ -25,12 +25,13 @@ use zip::result::ZipError; use crate::datatype::DataRef; use crate::formats::{builtin_format_by_id, detect_custom_number_format, CellFormat}; use crate::utils::{ - build_zip_path_cache, cached_zip_path, unescape_entity_to_buffer, unescape_xml, + build_zip_path_cache, cached_zip_path, collect_cells_into_range, unescape_entity_to_buffer, + unescape_xml, }; use crate::vba::VbaProject; use crate::{ - Cell, CellErrorType, Data, Dimensions, HeaderRow, Metadata, Range, Reader, ReaderRef, Sheet, - SheetType, SheetVisible, Table, + CellErrorType, Data, Dimensions, HeaderRow, IndexSet, Metadata, Range, Reader, ReaderRef, + Sheet, SheetType, SheetVisible, Table, }; pub use cells_reader::{ XlsxCellFormula, XlsxCellFormulaMetadataRecord, XlsxCellReader, XlsxFormulaMetadata, @@ -2286,13 +2287,7 @@ impl Reader for Xlsx { } fn worksheet_range(&mut self, name: &str) -> Result, XlsxError> { - let rge = self.worksheet_range_ref(name)?; - let inner = rge.inner.into_iter().map(|v| v.into()).collect(); - Ok(Range { - start: rge.start, - end: rge.end, - inner, - }) + self.worksheet_range_region(name, .., ..) } fn worksheet_formula(&mut self, name: &str) -> Result, XlsxError> { @@ -2339,7 +2334,14 @@ impl Reader for Xlsx { } impl ReaderRef for Xlsx { - fn worksheet_range_ref<'a>(&'a mut self, name: &str) -> Result>, XlsxError> { + fn worksheet_range_ref_region<'a>( + &'a mut self, + name: &str, + cols: impl Into, + rows: impl Into, + ) -> Result>, XlsxError> { + let cols = cols.into(); + let rows = rows.into(); let header_row = self.options.header_row; let mut cell_reader = match self.worksheet_cells_reader(name) { Ok(reader) => reader, @@ -2349,63 +2351,8 @@ impl ReaderRef for Xlsx { } Err(e) => return Err(e), }; - let len = cell_reader.dimensions().len(); - let mut cells = Vec::new(); - if len < 100_000 { - cells.reserve(len as usize); - } - - match header_row { - HeaderRow::FirstNonEmptyRow => { - // the header row is the row of the first non-empty cell - loop { - match cell_reader.next_cell() { - Ok(Some(Cell { - val: DataRef::Empty, - .. - })) => (), - Ok(Some(cell)) => cells.push(cell), - Ok(None) => break, - Err(e) => return Err(e), - } - } - } - HeaderRow::Row(header_row_idx) => { - // If `header_row` is a row index, we only add non-empty cells after this index. - loop { - match cell_reader.next_cell() { - Ok(Some(Cell { - val: DataRef::Empty, - .. - })) => (), - Ok(Some(cell)) => { - if cell.pos.0 >= header_row_idx { - cells.push(cell); - } - } - Ok(None) => break, - Err(e) => return Err(e), - } - } - - // If `header_row` is set and the first non-empty cell is not at the `header_row`, we add - // an empty cell at the beginning with row `header_row` and same column as the first non-empty cell. - if cells.first().is_some_and(|c| c.pos.0 != header_row_idx) { - cells.insert( - 0, - Cell { - pos: ( - header_row_idx, - cells.first().expect("cells should not be empty").pos.1, - ), - val: DataRef::Empty, - }, - ); - } - } - } - - Ok(Range::from_sparse(cells)) + let dims = cell_reader.dimensions(); + collect_cells_into_range(header_row, &cols, &rows, dims, || cell_reader.next_cell()) } } diff --git a/tests/col-projection.xlsx b/tests/col-projection.xlsx new file mode 100644 index 00000000..ec005390 Binary files /dev/null and b/tests/col-projection.xlsx differ diff --git a/tests/test.rs b/tests/test.rs index 273df63b..094bae41 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -6,8 +6,8 @@ use calamine::vba::Reference; use calamine::Data::{Bool, DateTime, DateTimeIso, DurationIso, Empty, Error, Float, Int, String}; use calamine::{ open_workbook, open_workbook_auto, DataRef, DataType, Dimensions, ExcelDateTime, - ExcelDateTimeType, HeaderRow, Ods, Range, Reader, ReaderRef, Sheet, SheetType, SheetVisible, - Xls, Xlsb, Xlsx, XlsxFormulaMetadata, + ExcelDateTimeType, HeaderRow, IndexSet, Ods, Range, Reader, ReaderRef, Sheet, SheetType, + SheetVisible, Xls, Xlsb, Xlsx, XlsxFormulaMetadata, }; use calamine::{CellErrorType::*, Data}; use rstest::rstest; @@ -3362,3 +3362,212 @@ fn test_hyperlinks_xlsx() { Err(calamine::XlsxError::WorksheetNotFound(_)) )); } + +/// Assert projected `cols` reproduce the full sheet's values +/// at the kept cols, with dropped columns reading `Empty`. +#[rstest] +#[case::disjoint("col-projection.xlsx", "Sheet1", IndexSet::from([0u32, 2]), &[0, 2])] +#[case::list("col-projection.xlsx", "Sheet1", IndexSet::from([0u32, 1, 2]), &[0, 1, 2])] +#[case::range("col-projection.xlsx", "Sheet1", IndexSet::from(0u32..3), &[0, 1, 2])] +#[case::single("col-projection.xlsx", "Sheet1", IndexSet::from(2u32), &[2])] +#[case::unsorted_dup("col-projection.xlsx", "Sheet1", IndexSet::from([2u32, 0, 0]), &[0, 2])] +#[case::xlsb("issues.xlsb", "issue2", IndexSet::from([1u32]), &[1])] +fn test_region_columns( + #[case] fixture: &str, + #[case] sheet: &str, + #[case] cols: IndexSet, + #[case] kept: &[u32], +) { + let path = test_path(fixture); + let mut full_wb = open_workbook_auto(&path).expect(&path); + let full = full_wb.worksheet_range_ref_region(sheet, .., ..).unwrap(); + let mut proj_wb = open_workbook_auto(&path).expect(&path); + let proj = proj_wb.worksheet_range_ref_region(sheet, cols, ..).unwrap(); + + // Bounding box spans the lowest..=highest kept column. Dropped interior + // columns are Empty, and every kept column matches the full sheet. + let (lo, hi) = (kept[0], *kept.last().unwrap()); + assert_eq!(proj.width() as u32, hi - lo + 1); + assert_eq!(proj.height(), full.height()); + for row in 0..proj.height() { + for c in lo..=hi { + let expected = if kept.contains(&c) { + full.get((row, c as usize)) + } else { + Some(&DataRef::Empty) + }; + assert_eq!( + proj.get((row, (c - lo) as usize)), + expected, + "row {row} col {c}" + ); + } + } +} + +/// Row projection reproduces the full sheet's selected row window. +#[rstest] +#[case::head("col-projection.xlsx", "Sheet1", IndexSet::from(0u32..2), 0, Some(1))] +#[case::tail("col-projection.xlsx", "Sheet1", IndexSet::from(1u32..), 1, None)] +#[case::xlsb("issues.xlsb", "issue2", IndexSet::from(0u32..2), 0, Some(1))] +fn test_region_rows( + #[case] fixture: &str, + #[case] sheet: &str, + #[case] rows: IndexSet, + #[case] start: u32, + #[case] end: Option, +) { + let path = test_path(fixture); + let mut x = open_workbook_auto(&path).expect(&path); + let full = x.worksheet_range_region(sheet, .., ..).unwrap(); + let proj = x.worksheet_range_region(sheet, .., rows).unwrap(); + + // `None` -> open-ended range + let last = end.unwrap_or_else(|| full.end().unwrap().0); + assert_eq!(proj.start().map(|(r, _)| r), Some(start)); + assert_eq!(proj.end().map(|(r, _)| r), Some(last)); + + // `get` is relative to each range's start; both fixtures start at column 0 + let full_start = full.start().unwrap().0; + for i in 0..proj.height() { + let full_row = (start as usize + i) - full_start as usize; + for c in 0..proj.width() { + assert_eq!( + proj.get((i, c)), + full.get((full_row, c)), + "rel row {i} col {c}" + ); + } + } +} + +#[test] +fn test_region_full_equals_plain() { + // The unprojected region (`.., ..`) matches `worksheet_range_ref`. + let mut x: Xlsx<_> = wb("col-projection.xlsx"); + let full = x.worksheet_range_ref_region("Sheet1", .., ..).unwrap(); + let mut plain_wb: Xlsx<_> = wb("col-projection.xlsx"); + let plain = plain_wb.worksheet_range_ref("Sheet1").unwrap(); + assert_eq!(full, plain); + assert!( + full.width() > 3, + "fixture must be wider than projection subsets" + ); +} + +#[test] +fn test_region_both_axes() { + let mut x: Xlsx<_> = wb("col-projection.xlsx"); + let full = x.worksheet_range_region("Sheet1", .., ..).unwrap(); + let both = x.worksheet_range_region("Sheet1", 0..2, 0..2).unwrap(); + assert_eq!(both.start(), Some((0, 0))); + assert_eq!(both.end().map(|(r, _)| r), Some(1)); + assert_eq!(both.width(), 2); + for row in 0..2 { + for col in 0..2 { + assert_eq!(both.get((row, col)), full.get((row, col))); + } + } +} + +#[test] +fn test_region_overlaps_merge() { + // Overlapping/duplicate selections are valid and merge silently. + let mut x: Xlsx<_> = wb("col-projection.xlsx"); + let merged = x + .worksheet_range_region("Sheet1", [0..2, 1..3], ..) + .unwrap(); + let contiguous = x.worksheet_range_region("Sheet1", 0..3, ..).unwrap(); + assert_eq!(merged, contiguous); +} + +/// Column projection composes with header row; dropped cols read +/// Empty and the header floor is preserved for both header modes. +#[rstest] +#[case(HeaderRow::Row(8))] +#[case(HeaderRow::FirstNonEmptyRow)] +fn test_region_header_columns(#[case] header: HeaderRow) { + let mut x: Xlsx<_> = wb("header-row.xlsx"); + x.with_header_row(header); + let full = x.worksheet_range_region("Sheet1", .., ..).unwrap(); + let proj = x.worksheet_range_region("Sheet1", [0, 2], ..).unwrap(); + assert_eq!(proj.start(), full.start()); + assert_eq!(proj.width(), 3); + for row in 0..proj.height() { + assert_eq!(proj.get((row, 0)), full.get((row, 0))); + assert_eq!(proj.get((row, 2)), full.get((row, 2))); + assert_eq!(proj.get((row, 1)), Some(&Empty)); + } +} + +#[test] +fn test_region_header_row_always_retained() { + // HeaderRow::Row(8) with a row selection that EXCLUDES row 8; header + // row should still be present, and rows above 8 must still be dropped. + let mut x: Xlsx<_> = wb("header-row.xlsx"); + let range = x + .with_header_row(HeaderRow::Row(8)) + .worksheet_range_ref_region("Sheet1", .., 9..10) + .unwrap(); + assert_eq!(range.start().map(|(r, _)| r), Some(8)); + assert_eq!(range.end().map(|(r, _)| r), Some(9)); +} + +#[test] +fn test_region_xlsb_with_header_row() { + let mut x: Xlsb<_> = wb("date.xlsb"); + x.with_header_row(HeaderRow::Row(1)); + let full = x.worksheet_range_region("Sheet1", .., ..).unwrap(); + let proj = x.worksheet_range_region("Sheet1", [1], ..).unwrap(); + assert_eq!(proj.start(), Some((1, 1))); + assert_eq!(proj.end(), Some((2, 1))); + assert_eq!(proj.width(), 1); + for row in 0..proj.height() { + assert_eq!(proj.get((row, 0)), full.get((row, 1))); + } +} + +#[test] +fn test_region_through_sheets_auto() { + let path = test_path("col-projection.xlsx"); + let mut sheets = open_workbook_auto(&path).expect(&path); + let dispatched = sheets + .worksheet_range_ref_region("Sheet1", [0, 2], ..) + .unwrap(); + + let mut direct: Xlsx<_> = wb("col-projection.xlsx"); + let expected = direct + .worksheet_range_ref_region("Sheet1", [0, 2], ..) + .unwrap(); + assert_eq!(dispatched, expected); + + // Owned variant also dispatches through `Sheets`. + let mut owned = open_workbook_auto(&path).expect(&path); + assert_eq!( + owned + .worksheet_range_region("Sheet1", [0, 1, 2], ..) + .unwrap() + .width(), + 3 + ); +} + +#[test] +fn test_region_unsupported_for_xls_ods() { + for file in ["any_sheets.xls", "any_sheets.ods"] { + let path = test_path(file); + let mut sheets = open_workbook_auto(&path).expect(&path); + let name = sheets.sheet_names()[0].clone(); + assert!( + matches!( + sheets.worksheet_range_ref_region(&name, [0], ..), + Err(calamine::Error::Msg(_)) + ), + "{file}: expected Error::Msg, not a panic" + ); + assert!(matches!( + sheets.worksheet_range_ref(&name), + Err(calamine::Error::Msg(_)) + )); + } +}