//! Worksheets. use std::collections::BTreeMap; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value}; use serde::{Deserialize, Serialize}; use crate::cell::{Cell, CellPicture}; use crate::features::*; use crate::store::CellStore; use crate::style::{Color, StyleId}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Visibility { #[default] Visible, Hidden, VeryHidden, } /// How a sheet is displayed (View › Workbook Views; `` in XLSX). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SheetViewMode { #[default] Normal, /// Pages on paper with rulers, margins and in-place headers and footers. PageLayout, PageBreakPreview, } impl SheetViewMode { /// The XLSX / command spelling: `normal`, `pageLayout` or `pageBreakPreview`. pub fn as_str(self) -> &'static str { match self { SheetViewMode::Normal => "normal", SheetViewMode::PageLayout => "pageLayout", SheetViewMode::PageBreakPreview => "pageBreakPreview", } } pub fn parse(s: &str) -> Option { match s { "normal" => Some(SheetViewMode::Normal), "pageLayout" => Some(SheetViewMode::PageLayout), "pageBreakPreview" => Some(SheetViewMode::PageBreakPreview), _ => None, } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub name: String, pub cells: CellStore, /// Static pictures are sparse rich content. Ordinary cells carry no picture pointer. #[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")] pub cell_pictures: BTreeMap>, /// Values spilled by dynamic-array formulas (maintained by calc), keyed by cell. #[serde(skip)] pub spill: BTreeMap, /// Anchor → spill range. #[serde(skip)] pub spill_ranges: BTreeMap, pub cols: BTreeMap, pub rows: BTreeMap, /// Default column width and row height in points. pub default_col_width: f32, pub default_row_height: f32, pub merges: Vec, /// Frozen rows and columns (top/left). pub freeze: Option<(u32, u32)>, pub tab_color: Option, pub visibility: Visibility, pub show_gridlines: bool, pub show_headings: bool, pub show_formulas: bool, pub show_zeros: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, pub validations: Vec, pub tables: Vec, pub autofilter: Option, #[serde(with = "crate::cellmap")] pub comments: BTreeMap, #[serde(with = "crate::cellmap")] pub hyperlinks: BTreeMap, pub charts: Vec, pub images: Vec, pub shapes: Vec, pub sparklines: Vec, /// On-sheet slicer widgets (filter a table column by clicking value tiles). #[serde(default)] pub slicers: Vec, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, pub protection: Option, /// Allow Edit Ranges (meaningful while the sheet is protected). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edit_ranges: Vec, pub print: PrintSettings, /// Last selection and scroll position, saved with the file. pub view_active: CellRef, pub view_top_left: CellRef, /// Normal, Page Layout or Page Break Preview. #[serde(default)] pub view: SheetViewMode, /// Unmodelled file content of this sheet kept for saving (form controls, OLE objects, query /// tables). Goes away with the sheet; not in JSON. #[serde(skip)] pub preserved: Option>, } /// Default width of a column (points) and height of a row (points) for the default font. pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { name: name.into(), cells: CellStore::new(), cell_pictures: BTreeMap::new(), spill: BTreeMap::new(), spill_ranges: BTreeMap::new(), cols: BTreeMap::new(), rows: BTreeMap::new(), default_col_width: DEFAULT_COL_WIDTH, default_row_height: DEFAULT_ROW_HEIGHT, merges: vec![], freeze: None, tab_color: None, visibility: Visibility::Visible, show_gridlines: true, show_headings: true, show_formulas: false, show_zeros: true, right_to_left: false, zoom: 100, cond_formats: vec![], validations: vec![], tables: vec![], autofilter: None, comments: BTreeMap::new(), hyperlinks: BTreeMap::new(), charts: vec![], images: vec![], shapes: vec![], sparklines: vec![], slicers: vec![], pivots: vec![], protection: None, edit_ranges: vec![], print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), view: SheetViewMode::Normal, preserved: None, } } pub fn cell(&self, c: CellRef) -> Option<&Cell> { self.cells.get(c) } /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { match self.cells.get(c) { Some(cell) if !cell.value.is_empty() || cell.formula.is_some() => cell.value.clone(), _ => self.spill.get(&c).cloned().unwrap_or(Value::Empty), } } pub fn value_ref(&self, c: CellRef) -> Option<&Value> { match self.cells.get(c) { Some(cell) if !cell.value.is_empty() || cell.formula.is_some() => Some(&cell.value), _ => self.spill.get(&c), } } pub fn style_id(&self, c: CellRef) -> StyleId { if let Some(cell) = self.cells.get(c) { return cell.style; } if let Some(s) = self.rows.get(&c.row).and_then(|r| r.style) { return s; } self.cols.get(&c.col).and_then(|r| r.style).unwrap_or_default() } pub fn set_value(&mut self, c: CellRef, v: Value) { let style = self.style_id(c); self.set_cell(c, Cell { value: v, formula: None, style }); } /// Replaces content, removing any picture at this address. Style-only edits use set_style. pub fn set_cell(&mut self, c: CellRef, cell: Cell) { self.cell_pictures.remove(&c); self.cells.set(c, cell); } pub fn remove_cell(&mut self, c: CellRef) -> Option { self.cell_pictures.remove(&c); self.cells.remove(c) } /// Removes content in a range; returns scalar cells, with pictures removed from the side map. pub fn take_cells(&mut self, range: RangeRef) -> Vec<(CellRef, Cell)> { self.cell_pictures.retain(|c, _| !range.contains(*c)); self.cells.take_range(range) } /// Pictures retain a scalar fallback for calculation and unsupported consumers. pub fn set_picture(&mut self, c: CellRef, picture: Arc) { let style = self.style_id(c); self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style }); self.cell_pictures.insert(c, picture); } pub fn input_text(&self, c: CellRef) -> String { if self.cell_pictures.contains_key(&c) { String::new() } else { self.cell(c).map(Cell::input_text).unwrap_or_default() } } pub fn set_style(&mut self, c: CellRef, style: StyleId) { let mut cell = self.cells.get(c).cloned().unwrap_or_default(); cell.style = style; self.cells.set(c, cell); } // ---- geometry /// Whether this cell has a table or ordinary AutoFilter dropdown. pub fn has_filter_button(&self, c: CellRef) -> bool { self.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && af.range.contains(c)) || self.tables.iter().any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && t.range.contains(c)) } /// Horizontal header space in unzoomed grid units (96 units per inch). /// Observed in Windows Excel 16.0 build 20326: AutoFit reserves 12.5 pt at /// 100-200% zoom, 25 pt at 50%, regardless of header row height. Below 100% /// the dropdown keeps its screen width. This is geometry, not font padding. pub fn filter_button_space(&self, c: CellRef) -> f32 { if self.has_filter_button(c) { (12.5 * 96.0 / 72.0) / (self.zoom as f32 / 100.0).clamp(0.1, 1.0) } else { 0.0 } } pub fn col_width(&self, col: u32) -> f32 { match self.cols.get(&col) { Some(i) if i.hidden => 0.0, Some(LineInfo { size: Some(w), .. }) => *w, _ => self.default_col_width, } } pub fn row_height(&self, row: u32) -> f32 { match self.rows.get(&row) { Some(i) if i.hidden => 0.0, Some(LineInfo { size: Some(h), .. }) => *h, _ => self.default_row_height, } } pub fn is_row_hidden(&self, row: u32) -> bool { self.rows.get(&row).is_some_and(|r| r.hidden) } /// Whether `row` is hidden by an applied filter: a hidden row below the header of the /// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table. /// Other hidden rows were hidden by hand. pub fn is_row_filtered(&self, row: u32) -> bool { if !self.is_row_hidden(row) { return false; } let in_sheet_filter = self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row); in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row)) } pub fn is_col_hidden(&self, col: u32) -> bool { self.cols.get(&col).is_some_and(|r| r.hidden) } /// Y offset (points) of the top of `row`. pub fn row_top(&self, row: u32) -> f64 { let mut y = row as f64 * self.default_row_height as f64; for (r, info) in self.rows.range(..row) { let _ = r; let h = if info.hidden { 0.0 } else { info.size.unwrap_or(self.default_row_height) }; y += h as f64 - self.default_row_height as f64; } y } pub fn col_left(&self, col: u32) -> f64 { let mut x = col as f64 * self.default_col_width as f64; for (_, info) in self.cols.range(..col) { let w = if info.hidden { 0.0 } else { info.size.unwrap_or(self.default_col_width) }; x += w as f64 - self.default_col_width as f64; } x } /// Row containing y (points from the top), clamped. pub fn row_at(&self, y: f64) -> u32 { line_at(y, self.default_row_height as f64, &self.rows, gridcraft_core::MAX_ROWS) } pub fn col_at(&self, x: f64) -> u32 { line_at(x, self.default_col_width as f64, &self.cols, gridcraft_core::MAX_COLS) } /// The merged range containing `c`, if any. pub fn merge_at(&self, c: CellRef) -> Option { self.merges.iter().find(|m| m.contains(c)).copied() } pub fn table_at(&self, c: CellRef) -> Option<&Table> { self.tables.iter().find(|t| t.range.contains(c)) } pub fn used_range(&self) -> Option { let mut r = self.cells.used_range(); for sr in self.spill_ranges.values() { r = Some(r.map_or(*sr, |x| x.union(sr))); } r } pub fn is_protected(&self) -> bool { self.protection.is_some() } } /// Prefix sums over the custom-sized lines of an axis: O(log n) position ↔ line lookups even /// when every row has an explicit height (common in imported files). #[derive(Clone, Debug, Default)] pub struct LineIndex { default: f64, max: u32, /// (line, start position of that line, its size), sorted by line. lines: Vec<(u32, f64, f64)>, } impl LineIndex { pub fn new(custom: &BTreeMap, default: f32, max: u32) -> LineIndex { let default = default as f64; let mut lines = Vec::with_capacity(custom.len()); let mut pos = 0.0; let mut prev = 0u32; for (&line, info) in custom { pos += (line - prev) as f64 * default; let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64) }; lines.push((line, pos, size)); pos += size; prev = line + 1; } LineIndex { default, max, lines } } /// Start position of `line`. pub fn start(&self, line: u32) -> f64 { match self.lines.binary_search_by(|e| e.0.cmp(&line)) { Ok(i) => self.lines.get(i).map_or(0.0, |e| e.1), Err(0) => line as f64 * self.default, Err(i) => match self.lines.get(i - 1) { Some(&(l, p, s)) => p + s + (line - l - 1) as f64 * self.default, None => line as f64 * self.default, }, } } /// The line containing `pos`. pub fn at(&self, pos: f64) -> u32 { if pos <= 0.0 { return 0; } // Last custom line starting at or before pos. let i = self.lines.partition_point(|e| e.1 <= pos); let (base_line, base_pos) = match i.checked_sub(1).and_then(|k| self.lines.get(k)) { Some(&(l, p, s)) => { if pos < p + s { return l.min(self.max - 1); } (l + 1, p + s) } None => (0, 0.0), }; if self.default <= 0.0 { return base_line.min(self.max - 1); } let k = ((pos - base_pos) / self.default).floor(); let r = base_line as f64 + k; if r >= self.max as f64 { self.max - 1 } else { r as u32 } } } /// Inverse of the cumulative size along an axis with sparse custom sizes. fn line_at(pos: f64, default: f64, custom: &BTreeMap, max: u32) -> u32 { if pos <= 0.0 { return 0; } let mut start_pos = 0.0; // position of line `idx` let mut idx: u32 = 0; for (&line, info) in custom.iter() { // Lines idx..line have default size. let span = (line - idx) as f64 * default; if pos < start_pos + span { let k = ((pos - start_pos) / default).floor() as u32; return (idx + k).min(max - 1); } start_pos += span; let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64) }; if pos < start_pos + size { return line.min(max - 1); } start_pos += size; idx = line + 1; } if default <= 0.0 { return idx.min(max - 1); } let k = ((pos - start_pos) / default).floor(); let r = idx as f64 + k; if r >= max as f64 { max - 1 } else { r as u32 } } #[cfg(test)] mod tests { use super::*; #[test] fn line_index_matches_linear() { let mut s = Sheet::new("S"); for r in (0..5000).step_by(3) { s.rows.insert(r, LineInfo { size: Some(10.0 + (r % 7) as f32), hidden: r % 11 == 0, ..Default::default() }); } let idx = LineIndex::new(&s.rows, s.default_row_height, gridcraft_core::MAX_ROWS); for r in [0u32, 1, 2, 3, 4, 100, 999, 4998, 5000, 6000] { assert!((idx.start(r) - s.row_top(r)).abs() < 1e-6, "start {r}"); } for y in [0.0, 5.0, 33.3, 1000.0, 47_000.0, 90_000.0] { assert_eq!(idx.at(y), s.row_at(y), "at {y}"); } } #[test] fn geometry() { let mut s = Sheet::new("S"); assert_eq!(s.row_top(3), 60.0); s.rows.insert(1, LineInfo { size: Some(40.0), ..Default::default() }); s.rows.insert(2, LineInfo { hidden: true, ..Default::default() }); assert_eq!(s.row_top(1), 20.0); assert_eq!(s.row_top(2), 60.0); assert_eq!(s.row_top(3), 60.0); assert_eq!(s.row_top(4), 80.0); assert_eq!(s.row_at(10.0), 0); assert_eq!(s.row_at(30.0), 1); assert_eq!(s.row_at(59.0), 1); assert_eq!(s.row_at(61.0), 3); assert_eq!(s.row_at(85.0), 4); assert_eq!(s.col_at(1e12), gridcraft_core::MAX_COLS - 1); assert_eq!(s.col_at(-5.0), 0); assert_eq!(s.col_left(2), 128.0); } } // CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats. mod picture_map { use super::*; use serde::{Deserializer, Serializer}; pub fn serialize(map: &BTreeMap>, serializer: S) -> Result { map.iter().collect::>().serialize(serializer) } pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result>, D::Error> { Ok(Vec::<(CellRef, Arc)>::deserialize(deserializer)?.into_iter().collect()) } }