From 8383a21cd0069ac698a56d9c5dd8ff37096e4e30 Mon Sep 17 00:00:00 2001 From: John McNamara Date: Thu, 9 Jul 2026 19:51:33 +0100 Subject: [PATCH 1/2] xlsx: add worksheet cell style extraction API Add a `worksheet_style()` API that reads cell formatting (font, fill, borders, alignment, number format, protection) from XLSX files into an RLE-compressed StyleRange, plus a streaming `next_style_id()` on XlsxCellReader. Theme and indexed colors are resolved, including tint. Inspired by PR #653. Co-authored-by: David DiMaria --- README.md | 5 +- examples/README.md | 2 + examples/read_cell_styles.rs | 68 ++ src/auto.rs | 12 +- src/formats.rs | 43 ++ src/lib.rs | 16 + src/style.rs | 1186 ++++++++++++++++++++++++++++++++ src/xlsx/cells_reader.rs | 68 ++ src/xlsx/mod.rs | 177 ++++- src/xlsx/style_parser.rs | 851 +++++++++++++++++++++++ tests/EMSI_JobChange_UK.xlsx | Bin 0 -> 121833 bytes tests/borders.xlsx | Bin 0 -> 8472 bytes tests/problematic_formats.xlsx | Bin 0 -> 9129 bytes tests/styles.xlsx | Bin 0 -> 8936 bytes tests/test.rs | 529 +++++++++++++- 15 files changed, 2951 insertions(+), 6 deletions(-) create mode 100644 examples/read_cell_styles.rs create mode 100644 src/style.rs create mode 100644 src/xlsx/style_parser.rs create mode 100644 tests/EMSI_JobChange_UK.xlsx create mode 100644 tests/borders.xlsx create mode 100644 tests/problematic_formats.xlsx create mode 100644 tests/styles.xlsx diff --git a/README.md b/README.md index c0482ab8..cf06d694 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,10 @@ Many (most) parts of the specifications are not implemented, the focus has been The main unsupported items are: - no support for writing excel files, this is a read-only library -- no support for reading extra content, such as formatting, excel parameter, encrypted components etc ... +- limited support for reading cell formatting: XLSX cell styles (font, fill, + borders, alignment and number format) can be read with `worksheet_style()`, + but there is no formatting support for the other file formats, or for other + extra content such as excel parameters, encrypted components etc ... - no support for reading VB for opendocuments ## Credits diff --git a/examples/README.md b/examples/README.md index 89b109f8..39d7ff9d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,8 @@ This directory contains some example of Calamine usage. worksheet pass. - `read_hyperlinks.rs`: Reads the hyperlinks defined in an XLSX worksheet, either by sheet name or by sheet index. +- `read_cell_styles.rs`: Reads cell style/formatting information (fonts, + fills, borders, alignment and number formats) from an XLSX worksheet. - `read_picture_data.rs`: Reads pictures and their metadata from an XLSX file. ### Serialization examples diff --git a/examples/read_cell_styles.rs b/examples/read_cell_styles.rs new file mode 100644 index 00000000..8e4e094c --- /dev/null +++ b/examples/read_cell_styles.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// +// Copyright 2016-2026, Johann Tuffe. + +//! Example of reading cell style/formatting information from a worksheet in +//! an XLSX file. + +use calamine::{open_workbook, Error, Reader, Xlsx}; + +fn main() -> Result<(), Error> { + let path = "tests/styles.xlsx"; + + let mut workbook: Xlsx<_> = open_workbook(path)?; + let sheet_name = workbook.sheet_names()[0].clone(); + + // Read the styles of all explicitly formatted cells in the worksheet. + let styles = workbook.worksheet_style(&sheet_name)?; + + println!( + "'{}': styled range {:?}..={:?}", + sheet_name, + styles.start(), + styles.end(), + ); + + // Iterate over the cells and print a summary of any visible formatting. + for (row, col, style) in styles.cells() { + if style.is_empty() { + continue; + } + + let mut summary = Vec::new(); + + if let Some(font) = &style.font { + if font.is_bold() { + summary.push("bold".to_string()); + } + if font.is_italic() { + summary.push("italic".to_string()); + } + if let Some(color) = &font.color { + summary.push(format!("font color {color}")); + } + } + + if let Some(fill) = &style.fill { + if let Some(color) = fill.get_color() { + summary.push(format!("fill {color}")); + } + } + + if let Some(borders) = &style.borders { + if borders.has_visible_borders() { + summary.push("borders".to_string()); + } + } + + if let Some(number_format) = &style.number_format { + summary.push(format!("format '{}'", number_format.format_code)); + } + + if !summary.is_empty() { + println!("({row}, {col}): {}", summary.join(", ")); + } + } + + Ok(()) +} diff --git a/src/auto.rs b/src/auto.rs index a380b26f..8ca1a038 100644 --- a/src/auto.rs +++ b/src/auto.rs @@ -10,7 +10,7 @@ use crate::vba::VbaProject; use crate::Picture; use crate::{ open_workbook, open_workbook_from_rs, Data, DataRef, HeaderRow, Metadata, Ods, Range, Reader, - ReaderRef, Xls, Xlsb, Xlsx, + ReaderRef, StyleRange, Xls, Xlsb, Xlsx, }; use std::fs::File; @@ -147,6 +147,16 @@ where } } + /// Get the cell styles for the worksheet with the given name. + fn worksheet_style(&mut self, name: &str) -> Result { + match self { + Sheets::Xls(e) => e.worksheet_style(name).map_err(Error::Xls), + Sheets::Xlsx(e) => e.worksheet_style(name).map_err(Error::Xlsx), + Sheets::Xlsb(e) => e.worksheet_style(name).map_err(Error::Xlsb), + Sheets::Ods(e) => e.worksheet_style(name).map_err(Error::Ods), + } + } + fn worksheets(&mut self) -> Vec<(String, Range)> { match self { Sheets::Xls(e) => e.worksheets(), diff --git a/src/formats.rs b/src/formats.rs index a47db4ed..3fc5b8a8 100644 --- a/src/formats.rs +++ b/src/formats.rs @@ -79,6 +79,49 @@ pub fn builtin_format_by_id(id: &[u8]) -> CellFormat { } } +// Get the format code string for a builtin number format id. +// +// The ids and format codes are defined in ECMA-376 section 18.8.30. Returns +// `None` for ids that are not built in, such as custom formats and the reserved and +// locale-specific formats. +pub(crate) fn builtin_format_code_by_id(id: u32) -> Option<&'static str> { + match id { + 0 => Some("General"), + 1 => Some("0"), + 2 => Some("0.00"), + 3 => Some("#,##0"), + 4 => Some("#,##0.00"), + 9 => Some("0%"), + 10 => Some("0.00%"), + 11 => Some("0.00E+00"), + 12 => Some("# ?/?"), + 13 => Some("# ??/??"), + 14 => Some("mm-dd-yy"), + 15 => Some("d-mmm-yy"), + 16 => Some("d-mmm"), + 17 => Some("mmm-yy"), + 18 => Some("h:mm AM/PM"), + 19 => Some("h:mm:ss AM/PM"), + 20 => Some("h:mm"), + 21 => Some("h:mm:ss"), + 22 => Some("m/d/yy h:mm"), + 37 => Some("#,##0 ;(#,##0)"), + 38 => Some("#,##0 ;[Red](#,##0)"), + 39 => Some("#,##0.00;(#,##0.00)"), + 40 => Some("#,##0.00;[Red](#,##0.00)"), + 41 => Some("_(* #,##0_);_(* (#,##0);_(* \"-\"_);_(@_)"), + 42 => Some("_($* #,##0_);_($* (#,##0);_($* \"-\"_);_(@_)"), + 43 => Some("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)"), + 44 => Some("_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"), + 45 => Some("mm:ss"), + 46 => Some("[h]:mm:ss"), + 47 => Some("mmss.0"), + 48 => Some("##0.0E+0"), + 49 => Some("@"), + _ => None, + } +} + /// Check if code corresponds to builtin date format /// /// See `is_builtin_date_format_id` diff --git a/src/lib.rs b/src/lib.rs index e869c490..db237035 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,6 +86,7 @@ mod cfb; mod datatype; mod formats; mod ods; +mod style; mod xls; mod xlsb; mod xlsx; @@ -111,6 +112,11 @@ pub use crate::de::{ }; pub use crate::errors::Error; pub use crate::ods::{Ods, OdsError}; +pub use crate::style::{ + Alignment, Border, BorderStyle, Borders, Color, Fill, FillPattern, Font, FontStyle, FontWeight, + HorizontalAlignment, NumberFormat, Protection, Style, StyleRange, StyleRangeCells, + TextRotation, UnderlineStyle, VerticalAlignment, +}; pub use crate::xls::{Xls, XlsError, XlsOptions}; pub use crate::xlsb::{Xlsb, XlsbError}; pub use crate::xlsx::{ @@ -337,6 +343,16 @@ where /// Read worksheet formula in corresponding worksheet path fn worksheet_formula(&mut self, _: &str) -> Result, Self::Error>; + /// Get the cell styles for the worksheet with the given name. + /// + /// Returns a [`StyleRange`] holding the styles of all cells in the + /// worksheet that have an explicit (non-default) format. Style reading is + /// currently only supported for XLSX files. The other file formats + /// currently return an empty range. + fn worksheet_style(&mut self, _name: &str) -> Result { + Ok(StyleRange::empty()) + } + /// Get all sheet names of this workbook, in workbook order /// /// # Examples diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 00000000..0f3707c6 --- /dev/null +++ b/src/style.rs @@ -0,0 +1,1186 @@ +// SPDX-License-Identifier: MIT +// +// Copyright 2016-2026, Johann Tuffe. + +//! Cell style types for spreadsheet formatting. +//! +//! This module contains the data model for cell formatting: colors, fonts, +//! fills, borders, alignment, number formats and protection, combined into a +//! [`Style`] or [`StyleRange`]. + +use std::fmt; + +/// Represents a color in ARGB format. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Color { + /// Alpha channel (0-255). + pub alpha: u8, + + /// Red channel (0-255). + pub red: u8, + + /// Green channel (0-255). + pub green: u8, + + /// Blue channel (0-255). + pub blue: u8, +} + +impl Color { + /// Create a new color from ARGB values. + pub fn new(alpha: u8, red: u8, green: u8, blue: u8) -> Self { + Self { + alpha, + red, + green, + blue, + } + } + + /// Create a color from RGB values (alpha = 255). + pub fn rgb(red: u8, green: u8, blue: u8) -> Self { + Self::new(255, red, green, blue) + } + + /// Create a color from an ARGB integer. + pub fn from_argb(argb: u32) -> Self { + Self { + alpha: ((argb >> 24) & 0xFF) as u8, + red: ((argb >> 16) & 0xFF) as u8, + green: ((argb >> 8) & 0xFF) as u8, + blue: (argb & 0xFF) as u8, + } + } + + /// Convert to an ARGB integer. + pub fn to_argb(&self) -> u32 { + ((self.alpha as u32) << 24) + | ((self.red as u32) << 16) + | ((self.green as u32) << 8) + | (self.blue as u32) + } + + /// Check if the color is black. + pub fn is_black(&self) -> bool { + self.red == 0 && self.green == 0 && self.blue == 0 + } + + /// Check if the color is white. + pub fn is_white(&self) -> bool { + self.red == 255 && self.green == 255 && self.blue == 255 + } +} + +impl fmt::Display for Color { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "#{:02X}{:02X}{:02X}", self.red, self.green, self.blue) + } +} + +/// Border style enumeration. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BorderStyle { + /// No border. + #[default] + None, + + /// Thin border. + Thin, + + /// Medium border. + Medium, + + /// Thick border. + Thick, + + /// Double border. + Double, + + /// Hair border. + Hair, + + /// Dashed border. + Dashed, + + /// Dotted border. + Dotted, + + /// Medium dashed border. + MediumDashed, + + /// Dash dot border. + DashDot, + + /// Dash dot dot border. + DashDotDot, + + /// Slant dash dot border. + SlantDashDot, +} + +/// Border side. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Border { + /// Border style. + pub style: BorderStyle, + + /// Border color. + pub color: Option, +} + +impl Border { + // Create a new border with style. + pub(crate) fn new(style: BorderStyle) -> Self { + Self { style, color: None } + } + + // Create a new border with style and color. + pub(crate) fn with_color(style: BorderStyle, color: Color) -> Self { + Self { + style, + color: Some(color), + } + } + + /// Check if border is visible. + pub fn is_visible(&self) -> bool { + self.style != BorderStyle::None + } +} + +/// All borders for a cell. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Borders { + /// Left border. + pub left: Border, + + /// Right border. + pub right: Border, + + /// Top border. + pub top: Border, + + /// Bottom border. + pub bottom: Border, + + /// Diagonal down border. + pub diagonal_down: Border, + + /// Diagonal up border. + pub diagonal_up: Border, +} + +impl Borders { + // Create new borders. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Check if any border is visible. + pub fn has_visible_borders(&self) -> bool { + self.left.is_visible() + || self.right.is_visible() + || self.top.is_visible() + || self.bottom.is_visible() + || self.diagonal_down.is_visible() + || self.diagonal_up.is_visible() + } +} + +/// Font weight. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum FontWeight { + /// Normal weight. + #[default] + Normal, + + /// Bold weight. + Bold, +} + +/// Font style. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum FontStyle { + /// Normal style. + #[default] + Normal, + + /// Italic style. + Italic, +} + +/// Underline style. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum UnderlineStyle { + /// No underline. + #[default] + None, + + /// Single underline. + Single, + + /// Double underline. + Double, + + /// Single accounting underline. + SingleAccounting, + + /// Double accounting underline. + DoubleAccounting, +} + +/// Font properties. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Font { + /// Font name. + pub name: Option, + + /// Font size in points. + pub size: Option, + + /// Font weight. + pub weight: FontWeight, + + /// Font style. + pub style: FontStyle, + + /// Underline style. + pub underline: UnderlineStyle, + + /// Strikethrough. + pub strikethrough: bool, + + /// Font color. + pub color: Option, + + /// Font family class as defined by OOXML (1 = Roman, 2 = Swiss, 3 = + /// Modern, 4 = Script, 5 = Decorative). + pub family: Option, +} + +impl Font { + // Create a new font. + pub(crate) fn new() -> Self { + Self::default() + } + + // Set font name. + pub(crate) fn set_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + // Set font size. + pub(crate) fn set_size(mut self, size: f64) -> Self { + self.size = Some(size); + self + } + + // Set font weight. + pub(crate) fn set_weight(mut self, weight: FontWeight) -> Self { + self.weight = weight; + self + } + + // Set font style. + pub(crate) fn set_style(mut self, style: FontStyle) -> Self { + self.style = style; + self + } + + // Set underline. + pub(crate) fn set_underline(mut self, underline: UnderlineStyle) -> Self { + self.underline = underline; + self + } + + // Set strikethrough. + pub(crate) fn set_strikethrough(mut self, strikethrough: bool) -> Self { + self.strikethrough = strikethrough; + self + } + + // Set font color. + pub(crate) fn set_color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + + // Set the font family class. + pub(crate) fn set_family(mut self, family: u8) -> Self { + self.family = Some(family); + self + } + + /// Check if font is bold. + pub fn is_bold(&self) -> bool { + self.weight == FontWeight::Bold + } + + /// Check if font is italic. + pub fn is_italic(&self) -> bool { + self.style == FontStyle::Italic + } + + /// Check if font has underline. + pub fn has_underline(&self) -> bool { + self.underline != UnderlineStyle::None + } + + /// Check if font has strikethrough. + pub fn has_strikethrough(&self) -> bool { + self.strikethrough + } +} + +/// Horizontal alignment. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum HorizontalAlignment { + /// Left alignment. + Left, + + /// Center alignment. + Center, + + /// Right alignment. + Right, + + /// Justify alignment. + Justify, + + /// Distributed alignment. + Distributed, + + /// Fill alignment. + Fill, + + /// General alignment (default). + #[default] + General, +} + +/// Vertical alignment. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum VerticalAlignment { + /// Top alignment. + Top, + + /// Center alignment. + Center, + + /// Bottom alignment. + #[default] + Bottom, + + /// Justify alignment. + Justify, + + /// Distributed alignment. + Distributed, +} + +/// Text rotation in degrees. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum TextRotation { + /// No rotation. + #[default] + None, + + /// Rotated by degrees (0-180). + Degrees(u16), + + /// Stacked text. + Stacked, +} + +/// Cell alignment properties. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Alignment { + /// Horizontal alignment. + pub horizontal: HorizontalAlignment, + + /// Vertical alignment. + pub vertical: VerticalAlignment, + + /// Text rotation. + pub text_rotation: TextRotation, + + /// Wrap text. + pub wrap_text: bool, + + /// Indent level. + pub indent: Option, + + /// Shrink to fit. + pub shrink_to_fit: bool, +} + +impl Alignment { + // Create new alignment. + pub(crate) fn new() -> Self { + Self::default() + } + + // Set horizontal alignment. + pub(crate) fn set_horizontal(mut self, horizontal: HorizontalAlignment) -> Self { + self.horizontal = horizontal; + self + } + + // Set vertical alignment. + pub(crate) fn set_vertical(mut self, vertical: VerticalAlignment) -> Self { + self.vertical = vertical; + self + } + + // Set text rotation. + pub(crate) fn set_text_rotation(mut self, rotation: TextRotation) -> Self { + self.text_rotation = rotation; + self + } + + // Set wrap text. + pub(crate) fn set_wrap_text(mut self, wrap: bool) -> Self { + self.wrap_text = wrap; + self + } + + // Set indent level. + pub(crate) fn set_indent(mut self, indent: u8) -> Self { + self.indent = Some(indent); + self + } + + // Set shrink to fit. + pub(crate) fn set_shrink_to_fit(mut self, shrink: bool) -> Self { + self.shrink_to_fit = shrink; + self + } +} + +/// Fill pattern type. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum FillPattern { + /// No fill. + #[default] + None, + + /// Solid fill. + Solid, + + /// Dark gray pattern. + DarkGray, + + /// Medium gray pattern. + MediumGray, + + /// Light gray pattern. + LightGray, + + /// Gray 125 pattern. + Gray125, + + /// Gray 0625 pattern. + Gray0625, + + /// Dark horizontal pattern. + DarkHorizontal, + + /// Dark vertical pattern. + DarkVertical, + + /// Dark down pattern. + DarkDown, + + /// Dark up pattern. + DarkUp, + + /// Dark grid pattern. + DarkGrid, + + /// Dark trellis pattern. + DarkTrellis, + + /// Light horizontal pattern. + LightHorizontal, + + /// Light vertical pattern. + LightVertical, + + /// Light down pattern. + LightDown, + + /// Light up pattern. + LightUp, + + /// Light grid pattern. + LightGrid, + + /// Light trellis pattern. + LightTrellis, +} + +/// Fill properties. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Fill { + /// Fill pattern. + pub pattern: FillPattern, + + /// Foreground color. + pub foreground_color: Option, + + /// Background color. + pub background_color: Option, +} + +impl Fill { + // Create new fill. + pub(crate) fn new() -> Self { + Self::default() + } + + // Create solid fill with color. + #[cfg(test)] + pub(crate) fn solid(color: Color) -> Self { + Self { + pattern: FillPattern::Solid, + foreground_color: Some(color), + background_color: None, + } + } + + // Set pattern. + pub(crate) fn set_pattern(mut self, pattern: FillPattern) -> Self { + self.pattern = pattern; + self + } + + // Set foreground color. + pub(crate) fn set_foreground_color(mut self, color: Color) -> Self { + self.foreground_color = Some(color); + self + } + + // Set background color. + pub(crate) fn set_background_color(mut self, color: Color) -> Self { + self.background_color = Some(color); + self + } + + /// Check if fill is visible. + pub fn is_visible(&self) -> bool { + self.pattern != FillPattern::None + } + + /// Get the main fill color (foreground if available, otherwise background). + pub fn get_color(&self) -> Option { + self.foreground_color.or(self.background_color) + } +} + +/// Number format. +#[derive(Debug, Clone, PartialEq)] +pub struct NumberFormat { + /// Format code. + pub format_code: String, + + /// Format ID. + pub format_id: Option, +} + +impl NumberFormat { + // Create new number format. + pub(crate) fn new(format_code: String) -> Self { + Self { + format_code, + format_id: None, + } + } + + // Create with format ID. + pub(crate) fn set_format_id(mut self, format_id: u32) -> Self { + self.format_id = Some(format_id); + self + } +} + +impl Default for NumberFormat { + fn default() -> Self { + Self { + format_code: "General".to_string(), + format_id: None, + } + } +} + +/// Cell protection properties. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Protection { + /// Cell is locked. + pub locked: bool, + + /// Cell is hidden. + pub hidden: bool, +} + +impl Protection { + // Create new protection. + pub(crate) fn new() -> Self { + Self::default() + } + + // Set locked. + pub(crate) fn set_locked(mut self, locked: bool) -> Self { + self.locked = locked; + self + } + + // Set hidden. + pub(crate) fn set_hidden(mut self, hidden: bool) -> Self { + self.hidden = hidden; + self + } +} + +/// Complete cell style. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Style { + /// Font properties. + pub font: Option, + + /// Fill properties. + pub fill: Option, + + /// Border properties. + pub borders: Option, + + /// Alignment properties. + pub alignment: Option, + + /// Number format. + pub number_format: Option, + + /// Protection properties. + pub protection: Option, +} + +impl Style { + // Create new style. + pub(crate) fn new() -> Self { + Self::default() + } + + // Set font. + pub(crate) fn set_font(mut self, font: Font) -> Self { + self.font = Some(font); + self + } + + // Set fill. + pub(crate) fn set_fill(mut self, fill: Fill) -> Self { + self.fill = Some(fill); + self + } + + // Set borders. + pub(crate) fn set_borders(mut self, borders: Borders) -> Self { + self.borders = Some(borders); + self + } + + // Set alignment. + pub(crate) fn set_alignment(mut self, alignment: Alignment) -> Self { + self.alignment = Some(alignment); + self + } + + // Set number format. + pub(crate) fn set_number_format(mut self, number_format: NumberFormat) -> Self { + self.number_format = Some(number_format); + self + } + + // Set protection. + pub(crate) fn set_protection(mut self, protection: Protection) -> Self { + self.protection = Some(protection); + self + } + + /// Check if style is empty (no properties set). + pub fn is_empty(&self) -> bool { + self.font.is_none() + && self.fill.is_none() + && self.borders.is_none() + && self.alignment.is_none() + && self.number_format.is_none() + && self.protection.is_none() + } + + /// Check if style has any visible properties. + pub fn has_visible_properties(&self) -> bool { + (self + .font + .as_ref() + .is_some_and(|f| f.color.is_some() || f.is_bold() || f.is_italic())) + || (self.fill.as_ref().is_some_and(|f| f.is_visible())) + || (self + .borders + .as_ref() + .is_some_and(|b| b.has_visible_borders())) + || (self.alignment.as_ref().is_some_and(|a| { + a.horizontal != HorizontalAlignment::General + || a.vertical != VerticalAlignment::Bottom + || a.text_rotation != TextRotation::None + || a.wrap_text + || a.indent.is_some() + || a.shrink_to_fit + })) + } +} + +/// RLE-compressed style storage for a worksheet range. +/// +/// Instead of storing one [`Style`] per cell (which wastes memory when many +/// cells share the same style), this stores: +/// +/// - A palette of the workbook's cell formats (`xf` records, indexed by style +/// id, with index 0 being the default format). +/// - Runs of consecutive cells (in row-major order over the bounding box of +/// the styled cells) that share the same style id. +/// +/// This dramatically reduces memory usage for large worksheets. +/// +/// Runs are stored as two parallel vectors: a style id per run and the +/// cumulative (exclusive) end offset of each run. The cumulative offsets +/// double as the prefix-sum index used for `O(log runs)` random access in +/// [`StyleRange::get`]. +#[derive(Debug, Clone, Default)] +pub struct StyleRange { + start: (u32, u32), + end: (u32, u32), + + // Palette of styles indexed by style id. Id 0 is the default format, + // which is also used for cells without an explicit style. + palette: Vec