diff --git a/examples/loaded_sheet_access_probe.rs b/examples/loaded_sheet_access_probe.rs new file mode 100644 index 00000000..59d9e287 --- /dev/null +++ b/examples/loaded_sheet_access_probe.rs @@ -0,0 +1,62 @@ +//! Reproducible probe for repeated access to an already loaded lazy worksheet. +//! +//! Run with `cargo run --release --example loaded_sheet_access_probe -- +//! `. The defaults are 10,000 styles and 10,000 accesses. +//! This reports elapsed time but deliberately makes no timing assertion: +//! results depend on hardware and allocator. +use std::{ + env, + fs, + path::PathBuf, + process, + time::Instant, +}; + +use umya_spreadsheet::{ + reader, + writer, +}; + +fn argument(index: usize, default: usize) -> usize { + env::args() + .nth(index) + .map(|value| value.parse().expect("arguments must be positive integers")) + .unwrap_or(default) +} + +fn main() { + let styles = argument(1, 10_000); + let accesses = argument(2, 10_000); + let path: PathBuf = env::temp_dir().join(format!( + "umya-loaded-sheet-access-probe-{}.xlsx", + process::id() + )); + + let mut source = umya_spreadsheet::new_file(); + let sheet = source.sheet_mut(0).unwrap(); + for row in 1..=styles { + sheet.cell_mut((1, row as u32)).set_value_number(row as u32); + sheet + .style_mut((1, row as u32)) + .font_mut() + .color_mut() + .set_argb_str(format!("FF{:06X}", row % 0x01_00_00_00)); + } + writer::xlsx::write(&source, &path).unwrap(); + + let mut book = reader::xlsx::lazy_read(&path).unwrap(); + let initial = Instant::now(); + assert_eq!(book.sheet_mut(0).unwrap().value("A1"), "1"); + let initial_elapsed = initial.elapsed(); + + let repeated = Instant::now(); + for _ in 0..accesses { + assert_eq!(book.sheet_mut(0).unwrap().value("A1"), "1"); + } + let repeated_elapsed = repeated.elapsed(); + + println!("styles={styles} accesses={accesses}"); + println!("initial_deserialize_ms={}", initial_elapsed.as_millis()); + println!("loaded_sheet_access_ms={}", repeated_elapsed.as_millis()); + fs::remove_file(path).unwrap(); +} diff --git a/src/structs/border.rs b/src/structs/border.rs index e0aa043c..2ad2e85f 100644 --- a/src/structs/border.rs +++ b/src/structs/border.rs @@ -110,7 +110,9 @@ impl Border { crate::helper::utils::md5_hash(format!( "{}{}", self.style.value_string(), - self.color().unwrap_or_default().argb_str() + self.color + .as_ref() + .map_or(String::new(), |color| color.hash_code()) )) } @@ -141,7 +143,16 @@ impl Border { reader, Event::Empty(ref e) => { if e.name().into_inner() == b"color" { - self.color.clone().unwrap_or_default().set_attributes(reader, e, true); + let mut color = self.color.take().map(|color| *color).unwrap_or_default(); + color.set_attributes(reader, e, true); + self.color = Some(Box::new(color)); + } + }, + Event::Start(ref e) => { + if e.name().into_inner() == b"color" { + let mut color = self.color.take().map(|color| *color).unwrap_or_default(); + color.set_attributes(reader, e, false); + self.color = Some(Box::new(color)); } }, Event::End(ref e) => { diff --git a/src/structs/color.rs b/src/structs/color.rs index bd33f878..6466632a 100644 --- a/src/structs/color.rs +++ b/src/structs/color.rs @@ -102,75 +102,9 @@ static INDEX_TO_COLOR: phf::Map = phf_map! { 63u32 => argb!(0xFF, 0x33, 0x33, 0x33), // Standard Colour #56 }; -static COLOR_STR_TO_INDEX: phf::Map<&'static str, u32> = phf_map! { - "FF000000" => 0u32, // System Colour #1 - Black - "FFFFFFFF" => 1u32, // System Colour #2 - White - "FFFF0000" => 2u32, // System Colour #3 - Red - "FF00FF00" => 3u32, // System Colour #4 - Green - "FF0000FF" => 4u32, // System Colour #5 - Blue - "FFFFFF00" => 5u32, // System Colour #6 - Yellow - "FFFF00FF" => 6u32, // System Colour #7- Magenta - "FF00FFFF" => 7u32, // System Colour #8- Cyan -// "FF000000" => 8u32, // System Colour #1 - Black - Duplicate Key ! -// "FFFFFFFF" => 9u32, // System Colour #2 - White - Duplicate Key ! -// "FFFF0000" => 10u32, // System Colour #3 - Red - Duplicate Key ! -// "FF00FF00" => 11u32, // System Colour #4 - Green - Duplicate Key ! -// "FF0000FF" => 12u32, // System Colour #5 - Blue - Duplicate Key ! -// "FFFFFF00" => 13u32, // System Colour #6 - Yellow - Duplicate Key ! -// "FFFF00FF" => 14u32, // System Colour #7- Magenta - Duplicate Key ! -// "FF00FFFF" => 15u32, // System Colour #8- Cyan - Duplicate Key ! - "FF800000" => 16u32, // Standard Colour #9 - "FF008000" => 17u32, // Standard Colour #10 - "FF000080" => 18u32, // Standard Colour #11 - "FF808000" => 19u32, // Standard Colour #12 - "FF800080" => 20u32, // Standard Colour #13 - "FF008080" => 21u32, // Standard Colour #14 - "FFC0C0C0" => 22u32, // Standard Colour #15 - "FF808080" => 23u32, // Standard Colour #16 - "FF9999FF" => 24u32, // Chart Fill Colour #17 - "FF993366" => 25u32, // Chart Fill Colour #18 - "FFFFFFCC" => 26u32, // Chart Fill Colour #19 - "FFCCFFFF" => 27u32, // Chart Fill Colour #20 - "FF660066" => 28u32, // Chart Fill Colour #21 - "FFFF8080" => 29u32, // Chart Fill Colour #22 - "FF0066CC" => 30u32, // Chart Fill Colour #23 - "FFCCCCFF" => 31u32, // Chart Fill Colour #24 -// "FF000080" => 32u32, // Chart Line Colour #25 - Duplicate Key ! -// "FFFF00FF" => 33u32, // Chart Line Colour #26 - Duplicate Key ! -// "FFFFFF00" => 34u32, // Chart Line Colour #27 - Duplicate Key ! -// "FF00FFFF" => 35u32, // Chart Line Colour #28 - Duplicate Key ! -// "FF800080" => 36u32, // Chart Line Colour #29 - Duplicate Key ! -// "FF800000" => 37u32, // Chart Line Colour #30 - Duplicate Key ! -// "FF008080" => 38u32, // Chart Line Colour #31 - Duplicate Key ! -// "FF0000FF" => 39u32, // Chart Line Colour #32 - Duplicate Key ! - "FF00CCFF" => 40u32, // Standard Colour #33 -// "FFCCFFFF" => 41u32, // Standard Colour #34 - Duplicate Key ! - "FFCCFFCC" => 42u32, // Standard Colour #35 - "FFFFFF99" => 43u32, // Standard Colour #36 - "FF99CCFF" => 44u32, // Standard Colour #37 - "FFFF99CC" => 45u32, // Standard Colour #38 - "FFCC99FF" => 46u32, // Standard Colour #39 - "FFFFCC99" => 47u32, // Standard Colour #40 - "FF3366FF" => 48u32, // Standard Colour #41 - "FF33CCCC" => 49u32, // Standard Colour #42 - "FF99CC00" => 50u32, // Standard Colour #43 - "FFFFCC00" => 51u32, // Standard Colour #44 - "FFFF9900" => 52u32, // Standard Colour #45 - "FFFF6600" => 53u32, // Standard Colour #46 - "FF666699" => 54u32, // Standard Colour #47 - "FF969696" => 55u32, // Standard Colour #48 - "FF003366" => 56u32, // Standard Colour #49 - "FF339966" => 57u32, // Standard Colour #50 - "FF003300" => 58u32, // Standard Colour #51 - "FF333300" => 59u32, // Standard Colour #52 - "FF993300" => 60u32, // Standard Colour #53 -// "FF993366" => 61u32, // Standard Colour #54 - Duplicate Key ! - "FF333399" => 62u32, // Standard Colour #55 - "FF333333" => 63u32, // Standard Colour #56 -}; - #[derive(Default, Debug, Clone, PartialEq, PartialOrd)] pub struct Color { + automatic: Option, indexed: Option, theme_index: Option, argb: Option, @@ -374,32 +308,36 @@ impl Color { } pub fn set_argb>(&mut self, value: S) -> &mut Self { - let argb = value.into(); - let indexed = COLOR_STR_TO_INDEX.get(Self::argb8_to_hex(argb).as_ref()); - - if let Some(v) = indexed { - self.indexed = Some(*v); - self.argb = None; - } else { - self.indexed = None; - self.argb = Some(argb); - } + self.indexed = None; + self.argb = Some(value.into()); self.theme_index = None; + self.automatic = None; self } pub fn set_argb_str>(&mut self, value: S) -> &mut Self { - let argb = Self::hex_to_argb8(value.as_ref()).unwrap(); - let indexed = COLOR_STR_TO_INDEX.get(value.as_ref()); + self.indexed = None; + self.argb = Some(Self::hex_to_argb8(value.as_ref()).unwrap()); + self.theme_index = None; + self.automatic = None; + self + } - if let Some(v) = indexed { - self.indexed = Some(*v); - self.argb = None; - } else { - self.indexed = None; - self.argb = Some(argb); - } + #[inline] + #[must_use] + pub fn automatic(&self) -> bool { + self.automatic.unwrap_or(false) + } + + /// Select the OOXML automatic colour. + /// + /// Automatic is a distinct OOXML selector, not a concrete RGB value. + #[inline] + pub fn set_automatic(&mut self, value: bool) -> &mut Self { + self.automatic = Some(value); + self.indexed = None; self.theme_index = None; + self.argb = None; self } @@ -418,6 +356,7 @@ impl Color { #[inline] pub fn set_indexed(&mut self, index: u32) -> &mut Self { + self.automatic = None; self.indexed = Some(index); self.theme_index = None; self.argb = None; @@ -439,6 +378,7 @@ impl Color { #[inline] pub fn set_theme_index(&mut self, index: u32) -> &mut Self { + self.automatic = None; self.indexed = None; self.theme_index = Some(index); self.argb = None; @@ -466,7 +406,8 @@ impl Color { #[inline] pub(crate) fn has_value(&self) -> bool { - self.theme_index.is_some() + self.automatic.is_some() + || self.theme_index.is_some() || self.indexed.is_some() || self.argb.is_some() || self.tint.is_some() @@ -474,12 +415,14 @@ impl Color { #[inline] pub(crate) fn hash_code(&self) -> String { + // Selector kinds are distinct even when they resolve to the same RGB. crate::helper::utils::md5_hash(format!( - "{}{}{}{}", - self.indexed.map_or(String::new(), |v| v.to_string()), - self.theme_index.map_or(String::new(), |v| v.to_string()), + "auto={:?};indexed={:?};theme={:?};rgb={};tint={:?}", + self.automatic, + self.indexed, + self.theme_index, self.argb.map_or(String::new(), Self::argb8_to_hex), - self.tint.map_or(String::new(), |v| v.to_string()) + self.tint, )) } @@ -501,8 +444,22 @@ impl Color { e: &BytesStart, empty_flg: bool, ) { + self.automatic = None; + self.indexed = None; + self.theme_index = None; + self.argb = None; + self.tint = None; for attr in e.attributes().with_checks(false).flatten() { match attr.key.0 { + b"auto" => { + if let Ok(v) = get_attribute_value(&attr) { + self.automatic = match v.as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }; + } + } b"indexed" => { if let Ok(v) = get_attribute_value(&attr) { if let Ok(num) = v.parse() { @@ -582,7 +539,9 @@ impl Color { fn write_to(&self, writer: &mut Writer>>, tag_name: &str) { let mut attributes: crate::structs::AttrCollection = Vec::new(); - if let Some(theme_index) = self.theme_index { + if let Some(automatic) = self.automatic { + attributes.push(("auto", if automatic { "1" } else { "0" }).into()); + } else if let Some(theme_index) = self.theme_index { attributes.push(("theme", theme_index.to_string()).into()); } else if let Some(indexed) = self.indexed { attributes.push(("indexed", indexed.to_string()).into()); @@ -619,7 +578,7 @@ mod tests { let mut obj = Color::default(); obj.set_argb_str("FFFF8080"); - assert_eq!(obj.indexed(), 29); + assert_eq!(obj.indexed(), 0); assert_eq!(obj.argb_str(), "FFFF8080"); let mut obj = Color::default(); @@ -627,4 +586,60 @@ mod tests { obj.set_theme_index(1); assert_eq!(obj.argb_with_theme(&theme), "000000"); } + + #[test] + fn selecting_a_color_clears_the_previous_selector() { + fn check(color: &Color, expected: &str) { + let mut writer = Writer::new(Cursor::new(Vec::new())); + color.write_to_color(&mut writer); + assert_eq!( + String::from_utf8(writer.into_inner().into_inner()).unwrap(), + expected + ); + } + let mut color = Color::default(); + color.set_automatic(true).set_indexed(1); + check(&color, r#""#); + color.set_automatic(false).set_theme_index(2); + check(&color, r#""#); + color + .set_automatic(true) + .set_argb(Color::hex_to_argb8("FFFF0000").unwrap()); + check(&color, r#""#); + color.set_automatic(true).set_argb_str("FFFFFFFF"); + check(&color, r#""#); + color.set_automatic(false); + check(&color, r#""#); + color.set_automatic(true); + check(&color, r#""#); + } + + #[test] + fn parsing_resets_tint_and_handles_expanded_automatic_colours() { + let mut color = Color::default(); + let mut reader = Reader::from_str(r#""#); + let mut buf = Vec::new(); + let first = match reader.read_event_into(&mut buf).unwrap() { + Event::Empty(value) => value.into_owned(), + _ => unreachable!(), + }; + color.set_attributes(&mut reader, &first, true); + assert_eq!(color.tint().to_bits(), 0.5_f64.to_bits()); + + let mut reader = Reader::from_str(r#""#); + let second = match reader.read_event_into(&mut buf).unwrap() { + Event::Start(value) => value.into_owned(), + _ => unreachable!(), + }; + color.set_attributes(&mut reader, &second, false); + assert!(!color.automatic()); + assert_eq!(color.tint().to_bits(), 0.0_f64.to_bits()); + + let mut writer = Writer::new(Cursor::new(Vec::new())); + color.write_to_color(&mut writer); + assert_eq!( + String::from_utf8(writer.into_inner().into_inner()).unwrap(), + r#""# + ); + } } diff --git a/src/structs/data_validation.rs b/src/structs/data_validation.rs index 0828f73b..94d9ded1 100644 --- a/src/structs/data_validation.rs +++ b/src/structs/data_validation.rs @@ -181,7 +181,7 @@ impl DataValidation { #[inline] #[must_use] - #[deprecated(since = "3.0.0", note = "Use error_messsage()")] + #[deprecated(since = "3.0.0", note = "Use error_message()")] pub fn get_error_message(&self) -> &str { self.error_message() } diff --git a/src/structs/font.rs b/src/structs/font.rs index 9090df92..804ab17c 100644 --- a/src/structs/font.rs +++ b/src/structs/font.rs @@ -676,6 +676,11 @@ impl Font { } _ => (), }, + Ok(Event::Start(ref e)) => { + if e.name().into_inner() == b"color" { + self.color.set_attributes(reader, e, false); + } + } Ok(Event::End(ref e)) => match e.name().into_inner() { b"font" | b"rPr" => return, _ => (), diff --git a/src/structs/gradient_stop.rs b/src/structs/gradient_stop.rs index cc86f0d2..ba7eeba3 100644 --- a/src/structs/gradient_stop.rs +++ b/src/structs/gradient_stop.rs @@ -113,6 +113,13 @@ impl GradientStop { self.set_color(obj); } }, + Event::Start(ref e) => { + if e.name().into_inner() == b"color" { + let mut obj = Color::default(); + obj.set_attributes(reader, e, false); + self.set_color(obj); + } + }, Event::End(ref e) => { if e.name().into_inner() == b"stop" { return diff --git a/src/structs/pattern_fill.rs b/src/structs/pattern_fill.rs index b5d1fe7a..194cb645 100644 --- a/src/structs/pattern_fill.rs +++ b/src/structs/pattern_fill.rs @@ -202,6 +202,21 @@ impl PatternFill { _ => (), } }, + Event::Start(ref e) => { + match e.name().into_inner() { + b"fgColor" => { + let mut obj = Color::default(); + obj.set_attributes(reader, e, false); + self.set_foreground_color(obj); + } + b"bgColor" => { + let mut obj = Color::default(); + obj.set_attributes(reader, e, false); + self.set_background_color(obj); + } + _ => (), + } + }, Event::End(ref e) => { if e.name().into_inner() == b"patternFill" { return diff --git a/src/structs/workbook.rs b/src/structs/workbook.rs index b4719378..4c1f190d 100644 --- a/src/structs/workbook.rs +++ b/src/structs/workbook.rs @@ -524,10 +524,19 @@ impl Workbook { /// deserialize by all worksheet. #[inline] pub fn read_sheet_collection(&mut self) -> &mut Self { + if self + .work_sheet_collection + .iter() + .all(Worksheet::is_deserialized) + { + return self; + } let shared_string_table = self.shared_string_table(); let stylesheet = self.stylesheet().clone(); for worksheet in &mut self.work_sheet_collection { - raw_to_deserialize_by_worksheet(worksheet, &shared_string_table, &stylesheet); + if !worksheet.is_deserialized() { + raw_to_deserialize_by_worksheet(worksheet, &shared_string_table, &stylesheet); + } } self } @@ -535,6 +544,10 @@ impl Workbook { /// deserialize a worksheet. #[inline] pub fn read_sheet(&mut self, index: usize) -> &mut Self { + let is_deserialized = self.work_sheet_collection.get(index).unwrap().is_deserialized(); + if is_deserialized { + return self; + } let shared_string_table = self.shared_string_table(); let stylesheet = self.stylesheet().clone(); let worksheet = self.work_sheet_collection.get_mut(index).unwrap(); @@ -617,17 +630,20 @@ impl Workbook { /// * `index` - sheet index /// # Return value /// * `Result<&mut Worksheet, XlsxError>`. - #[allow(clippy::manual_inspect)] pub fn sheet_mut(&mut self, index: usize) -> Result<&mut Worksheet, XlsxError> { + let is_deserialized = self + .work_sheet_collection + .get(index) + .map(Worksheet::is_deserialized) + .ok_or(XlsxError::NotFound())?; + if is_deserialized { + return Ok(self.work_sheet_collection.get_mut(index).unwrap()); + } let shared_string_table = self.shared_string_table(); let stylesheet = self.stylesheet().clone(); - self.work_sheet_collection - .get_mut(index) - .map(|v| { - raw_to_deserialize_by_worksheet(v, &shared_string_table, &stylesheet); - v - }) - .ok_or(XlsxError::NotFound()) + let worksheet = self.work_sheet_collection.get_mut(index).unwrap(); + raw_to_deserialize_by_worksheet(worksheet, &shared_string_table, &stylesheet); + Ok(worksheet) } #[deprecated(since = "3.0.0", note = "Use sheet_mut()")] diff --git a/tests/color_selector_fidelity.rs b/tests/color_selector_fidelity.rs new file mode 100644 index 00000000..8dcbc39e --- /dev/null +++ b/tests/color_selector_fidelity.rs @@ -0,0 +1,418 @@ +//! Assert referenced OOXML components, independently of the workbook reader. +use std::{ + collections::{ + BTreeMap, + BTreeSet, + }, + io::{ + Cursor, + Read, + Write, + }, +}; + +use quick_xml::{ + Reader, + Writer, + events::{ + BytesEnd, + Event, + }, +}; +use umya_spreadsheet::{ + self as umya, + Border, + Color, + ConditionalFormatValues, + ConditionalFormatting, + ConditionalFormattingRule, + Formula, + Style, +}; + +type Attributes = BTreeMap; +#[derive(Default, Debug)] +struct Element { + name: String, + attrs: Attributes, + children: Vec, +} +impl Element { + fn child(&self, name: &str) -> &Self { + self.children + .iter() + .find(|node| node.name == name) + .unwrap_or_else(|| panic!("missing {name} in {}", self.name)) + } + + fn id(&self, name: &str) -> usize { + self.attrs[name].parse().unwrap() + } +} +fn xml(source: &str) -> Element { + let mut reader = Reader::from_str(source); + let mut stack = vec![Element::default()]; + loop { + let event = reader.read_event().unwrap(); + let empty = matches!(&event, Event::Empty(_)); + match event { + Event::Start(e) | Event::Empty(e) => { + let node = Element { + name: String::from_utf8(e.name().as_ref().to_vec()).unwrap(), + attrs: e + .attributes() + .map(|a| { + let a = a.unwrap(); + ( + String::from_utf8(a.key.as_ref().to_vec()).unwrap(), + a.decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) + .unwrap() + .into_owned(), + ) + }) + .collect(), + children: vec![], + }; + if empty { + stack.last_mut().unwrap().children.push(node); + } else { + stack.push(node); + } + } + Event::End(e) => { + let node = stack.pop().unwrap(); + assert_eq!(node.name.as_bytes(), e.name().as_ref()); + stack.last_mut().unwrap().children.push(node); + } + Event::Eof => break, + _ => (), + } + } + assert_eq!(stack.len(), 1); + stack.pop().unwrap().children.pop().unwrap() +} +fn part(bytes: &[u8], name: &str) -> String { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut text = String::new(); + archive + .by_name(name) + .unwrap() + .read_to_string(&mut text) + .unwrap(); + text +} +fn save(book: &umya::Workbook) -> Vec { + let mut bytes = vec![]; + umya::writer::xlsx::write_writer(book, &mut bytes).unwrap(); + bytes +} +fn expected(key: &str, value: &str, tint: Option<&str>) -> Attributes { + let mut attrs = Attributes::from([(key.to_owned(), value.to_owned())]); + if let Some(tint) = tint { + attrs.insert("tint".into(), tint.into()); + } + attrs +} +fn style(color: Color, gradient: bool) -> Style { + let mut style = Style::default(); + style.font_mut().set_color(color.clone()); + if gradient { + let mut fill = umya::GradientFill::default(); + for position in [0.0, 1.0] { + let mut stop = umya::GradientStop::default(); + stop.set_position(position).set_color(color.clone()); + fill.set_gradient_stop(stop); + } + style.fill_mut().set_gradient_fill(fill); + } else { + style + .fill_mut() + .pattern_fill_mut() + .set_foreground_color(color.clone()) + .set_background_color(color.clone()); + } + style + .borders_mut() + .left_mut() + .set_border_style(Border::BORDER_THIN); + style.borders_mut().left_mut().set_color(color); + style +} +fn fixture(colors: &[Color], gradient: bool) -> umya::Workbook { + assert!(colors.len() <= 26); + let mut book = umya::new_file(); + let sheet = book.sheet_mut(0).unwrap(); + for (i, color) in colors.iter().enumerate() { + let i = u32::try_from(i).unwrap(); + let style = style(color.clone(), gradient); + sheet.cell_mut((i + 1, 1)).set_style(style.clone()); + sheet.row_dimension_mut(i + 3).set_style(style.clone()); + sheet + .column_dimension_by_number_mut(i + 1) + .set_style(style.clone()); + let mut rule = ConditionalFormattingRule::default(); + rule.set_type(ConditionalFormatValues::Expression) + .set_priority(i32::try_from(i + 1).unwrap()) + .set_style(style); + let mut formula = Formula::default(); + formula.set_string_value("TRUE()"); + rule.set_formula(formula); + let mut group = ConditionalFormatting::default(); + group.sequence_of_references_mut().set_sqref("A1:Z1"); + group.add_conditional_collection(rule); + sheet.add_conditional_formatting_collection(group); + } + book +} +fn assert_components( + font: &Element, + fill: &Element, + border: &Element, + want: &Attributes, + gradient: bool, +) { + assert_eq!(&font.child("color").attrs, want, "font"); + if gradient { + let stops = &fill.child("gradientFill").children; + assert_eq!(stops.len(), 2); + for (position, stop) in stops.iter().enumerate() { + assert_eq!(stop.name, "stop"); + assert_eq!(stop.attrs["position"], position.to_string()); + assert_eq!(&stop.child("color").attrs, want, "gradient stop"); + } + } else { + assert_eq!( + &fill.child("patternFill").child("fgColor").attrs, + want, + "foreground fill" + ); + assert_eq!( + &fill.child("patternFill").child("bgColor").attrs, + want, + "background fill" + ); + } + assert_eq!( + &border.child("left").child("color").attrs, + want, + "left border" + ); +} +fn assert_projection(bytes: &[u8], wants: &[Attributes], gradient: bool) { + let styles = xml(&part(bytes, "xl/styles.xml")); + let sheet = xml(&part(bytes, "xl/worksheets/sheet1.xml")); + let fonts = &styles.child("fonts").children; + let fills = &styles.child("fills").children; + let borders = &styles.child("borders").children; + let xfs = &styles.child("cellXfs").children; + let dxfs = &styles.child("dxfs").children; + let mut font_ids = BTreeSet::new(); + let mut fill_ids = BTreeSet::new(); + let mut border_ids = BTreeSet::new(); + let mut dxf_ids = BTreeSet::new(); + for (i, want) in wants.iter().enumerate() { + let address = format!("{}1", char::from(b'A' + u8::try_from(i).unwrap())); + let first_row = sheet + .child("sheetData") + .children + .iter() + .find(|r| r.attrs.get("r").map(String::as_str) == Some("1")) + .unwrap(); + let cell = first_row + .children + .iter() + .find(|c| c.attrs.get("r") == Some(&address)) + .unwrap(); + let row = sheet + .child("sheetData") + .children + .iter() + .find(|r| r.id("r") == i + 3) + .unwrap(); + let col = sheet + .child("cols") + .children + .iter() + .find(|c| (c.id("min")..=c.id("max")).contains(&(i + 1))) + .unwrap(); + for style_id in [cell.id("s"), row.id("s"), col.id("style")] { + let xf = &xfs[style_id]; + assert_components( + &fonts[xf.id("fontId")], + &fills[xf.id("fillId")], + &borders[xf.id("borderId")], + want, + gradient, + ); + } + let xf = &xfs[cell.id("s")]; + font_ids.insert(xf.id("fontId")); + fill_ids.insert(xf.id("fillId")); + border_ids.insert(xf.id("borderId")); + let rule = sheet + .children + .iter() + .filter(|n| n.name == "conditionalFormatting") + .flat_map(|n| &n.children) + .find(|n| n.name == "cfRule" && n.id("priority") == i + 1) + .unwrap(); + let dxf = &dxfs[rule.id("dxfId")]; + dxf_ids.insert(rule.id("dxfId")); + assert_components( + dxf.child("font"), + dxf.child("fill"), + dxf.child("border"), + want, + gradient, + ); + } + for ids in [font_ids, fill_ids, border_ids, dxf_ids] { + assert_eq!( + ids.len(), + wants.len(), + "distinct selector/tint identities aliased" + ); + } +} +fn expanded_colors(bytes: &[u8]) -> Vec { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut output = zip::ZipWriter::new(Cursor::new(vec![])); + let mut expanded = 0; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).unwrap(); + let mut data = vec![]; + entry.read_to_end(&mut data).unwrap(); + if entry.name() == "xl/styles.xml" { + let mut reader = Reader::from_reader(data.as_slice()); + let mut writer = Writer::new(vec![]); + loop { + match reader.read_event().unwrap() { + Event::Empty(e) + if matches!(e.name().as_ref(), b"color" | b"fgColor" | b"bgColor") => + { + let end = String::from_utf8(e.name().as_ref().to_vec()).unwrap(); + writer.write_event(Event::Start(e)).unwrap(); + writer.write_event(Event::End(BytesEnd::new(end))).unwrap(); + expanded += 1; + } + Event::Eof => break, + event => writer.write_event(event).unwrap(), + } + } + data = writer.into_inner(); + } + output + .start_file(entry.name(), zip::write::SimpleFileOptions::default()) + .unwrap(); + output.write_all(&data).unwrap(); + } + assert!(expanded > 0); + output.finish().unwrap().into_inner() +} +fn check(colors: Vec, wants: Vec) { + for gradient in [false, true] { + let original = save(&fixture(&colors, gradient)); + assert_projection(&original, &wants, gradient); + for input in [original.clone(), expanded_colors(&original)] { + let reopened = umya::reader::xlsx::read_reader(Cursor::new(input), true).unwrap(); + assert_projection(&save(&reopened), &wants, gradient); + } + } +} +#[test] +fn referenced_selector_and_tint_identities_survive_roundtrip() { + // Equal tint is essential: theme=1 and indexed=1 previously hashed + // identically. Themes 1/2 also exposed the border hash's omitted theme + // identity. + let mut colors = vec![]; + let mut wants = vec![]; + for index in [1, 2] { + colors.push(Color::default().set_theme_index(index).to_owned()); + wants.push(expected("theme", &index.to_string(), None)); + } + colors.push(Color::default().set_indexed(1).to_owned()); + wants.push(expected("indexed", "1", None)); + for (tint, text) in [(0.25, "0.25"), (-0.25, "-0.25")] { + colors.push( + Color::default() + .set_theme_index(1) + .set_tint(tint) + .to_owned(), + ); + wants.push(expected("theme", "1", Some(text))); + } + for (tint, text) in [(0.25, "0.25"), (-0.25, "-0.25")] { + colors.push(Color::default().set_indexed(1).set_tint(tint).to_owned()); + wants.push(expected("indexed", "1", Some(text))); + } + colors.push(Color::default().set_argb_str("FF000000").to_owned()); + wants.push(expected("rgb", "FF000000", None)); + colors.push(Color::default().set_indexed(0).to_owned()); + wants.push(expected("indexed", "0", None)); + colors.push( + Color::default() + .set_argb(Color::hex_to_argb8("FFFF0000").unwrap()) + .to_owned(), + ); + wants.push(expected("rgb", "FFFF0000", None)); + check(colors, wants); +} +#[test] +fn same_tint_theme_and_indexed_selectors_do_not_alias() { + check( + vec![ + Color::default().set_theme_index(1).to_owned(), + Color::default().set_indexed(1).to_owned(), + ], + vec![expected("theme", "1", None), expected("indexed", "1", None)], + ); +} + +#[test] +fn different_themes_do_not_alias_in_borders() { + check( + vec![ + Color::default().set_theme_index(1).to_owned(), + Color::default().set_theme_index(2).to_owned(), + ], + vec![expected("theme", "1", None), expected("theme", "2", None)], + ); +} + +#[test] +fn palette_rgb_setters_keep_rgb_identity() { + check( + vec![ + Color::default().set_argb_str("FF000000").to_owned(), + Color::default() + .set_argb(Color::hex_to_argb8("FFFF0000").unwrap()) + .to_owned(), + ], + vec![ + expected("rgb", "FF000000", None), + expected("rgb", "FFFF0000", None), + ], + ); +} + +#[test] +fn literal_border_color_survives_import() { + check( + vec![Color::default().set_argb_str("FFC00000").to_owned()], + vec![expected("rgb", "FFC00000", None)], + ); +} + +#[test] +fn automatic_selectors_survive_every_style_consumer() { + check( + vec![ + Color::default().set_automatic(true).to_owned(), + Color::default().set_automatic(false).to_owned(), + ], + vec![expected("auto", "1", None), expected("auto", "0", None)], + ); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 33714158..2c35736b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2508,6 +2508,291 @@ fn shared_formula_signatures( signatures } +#[test] +fn color_selectors_survive_styles_xml_serialization() { + let mut book = new_file(); + let sheet = book.sheet_mut(0).unwrap(); + + let rgb = Color::default().set_argb_str("FF000000").to_owned(); + sheet.style_mut("A1").font_mut().set_color(rgb.clone()); + sheet + .style_mut("A2") + .borders_mut() + .left_mut() + .set_border_style(Border::BORDER_THIN); + sheet + .style_mut("A2") + .borders_mut() + .left_mut() + .set_color(rgb.clone()); + sheet + .style_mut("A3") + .fill_mut() + .pattern_fill_mut() + .set_foreground_color(rgb); + sheet + .style_mut("A4") + .font_mut() + .set_color(Color::default().set_automatic(true).to_owned()); + + let xlsx = workbook_to_xlsx_bytes(&book); + let styles_xml = zip_entry_to_string(&xlsx, "xl/styles.xml"); + // Inspect the ZIP projection: RGB must not be rewritten as indexed. + assert_eq!(styles_xml.matches(r#"rgb="FF000000""#).count(), 3); + assert!(styles_xml.contains(r#""#)); +} + +#[test] +fn theme_and_indexed_styles_do_not_alias_in_cell_row_column_or_dxf() { + let mut book = new_file(); + let mut themed = Style::default(); + let mut indexed = Style::default(); + for (style, theme) in [(&mut themed, true), (&mut indexed, false)] { + let mut color = Color::default(); + if theme { + color.set_theme_index(1); + } else { + color.set_indexed(1); + } + style.font_mut().set_color(color.clone()); + style + .fill_mut() + .pattern_fill_mut() + .set_foreground_color(color.clone()); + style + .borders_mut() + .left_mut() + .set_border_style(Border::BORDER_THIN); + style.borders_mut().left_mut().set_color(color); + } + let sheet = book.sheet_mut(0).unwrap(); + sheet.cell_mut("A1").set_style(themed.clone()); + sheet.cell_mut("B1").set_style(indexed.clone()); + sheet.row_dimension_mut(2).set_style(themed.clone()); + sheet + .column_dimension_by_number_mut(3) + .set_style(indexed.clone()); + let mut rule = ConditionalFormattingRule::default(); + rule.set_type(ConditionalFormatValues::Expression) + .set_priority(1) + .set_style(themed); + let mut formula = Formula::default(); + formula.set_string_value("TRUE()"); + rule.set_formula(formula); + let mut group = ConditionalFormatting::default(); + group.sequence_of_references_mut().set_sqref("A1:B2"); + group.add_conditional_collection(rule); + sheet.add_conditional_formatting_collection(group); + let bytes = workbook_to_xlsx_bytes(&book); + let styles = zip_entry_to_string(&bytes, "xl/styles.xml"); + // Smoke check; color_selector_fidelity.rs independently resolves all + // referenced IDs. + assert!(styles.matches(r#"theme="1""#).count() >= 4, "{styles}"); + assert!(styles.matches(r#"indexed="1""#).count() >= 3, "{styles}"); + let reopened = reader::xlsx::read_reader(std::io::Cursor::new(bytes), true).unwrap(); + let sheet = reopened.sheet(0).unwrap(); + let consumers = [ + sheet.style("A1"), + sheet.style("B1"), + sheet.row_dimension(2).unwrap().style(), + sheet + .column_dimensions() + .iter() + .find(|c| c.col_num() == 3) + .unwrap() + .style(), + sheet.conditional_formatting_collection()[0].conditional_collection()[0] + .style() + .unwrap(), + ]; + for (index, style) in consumers.into_iter().enumerate() { + let color = style.font().unwrap().color(); + let fill = style + .fill() + .unwrap() + .pattern_fill() + .unwrap() + .foreground_color() + .unwrap(); + let border = style.borders().unwrap().left().color().unwrap(); + for actual in [color, fill, &border] { + if index == 1 || index == 3 { + assert_eq!(actual.indexed(), 1); + assert_eq!(actual.tint().to_bits(), 0.0_f64.to_bits()); + } else { + assert_eq!(actual.theme_index(), 1); + assert_eq!(actual.tint().to_bits(), 0.0_f64.to_bits()); + } + } + } +} + +#[test] +fn expanded_font_and_fill_colours_survive_all_style_consumers() { + let mut book = new_file(); + let mut style = Style::default(); + let mut color = Color::default(); + color.set_theme_index(1).set_tint(0.25); + style.font_mut().set_color(color.clone()); + style + .fill_mut() + .pattern_fill_mut() + .set_foreground_color(color.clone()); + style + .fill_mut() + .pattern_fill_mut() + .set_background_color(Color::default().set_indexed(1).set_tint(-0.25).to_owned()); + style + .borders_mut() + .left_mut() + .set_border_style(Border::BORDER_THIN); + style.borders_mut().left_mut().set_color(color.clone()); + let sheet = book.sheet_mut(0).unwrap(); + sheet.cell_mut("A1").set_style(style.clone()); + sheet.row_dimension_mut(2).set_style(style.clone()); + sheet + .column_dimension_by_number_mut(3) + .set_style(style.clone()); + let mut rule = ConditionalFormattingRule::default(); + rule.set_type(ConditionalFormatValues::Expression) + .set_priority(1) + .set_style(style); + let mut formula = Formula::default(); + formula.set_string_value("TRUE()"); + rule.set_formula(formula); + let mut group = ConditionalFormatting::default(); + group.sequence_of_references_mut().set_sqref("A1:B2"); + group.add_conditional_collection(rule); + sheet.add_conditional_formatting_collection(group); + let input = workbook_to_xlsx_bytes(&book); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(input)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let mut data = Vec::new(); + entry.read_to_end(&mut data).unwrap(); + if entry.name() == "xl/styles.xml" { + data = String::from_utf8(data) + .unwrap() + .replace( + r#""#, + r#""#, + ) + .replace( + r#""#, + r#""#, + ) + .replace( + r#""#, + r#""#, + ) + .into_bytes(); + } + out.start_file(entry.name(), zip::write::SimpleFileOptions::default()) + .unwrap(); + std::io::Write::write_all(&mut out, &data).unwrap(); + } + let reopened = reader::xlsx::read_reader( + std::io::Cursor::new(out.finish().unwrap().into_inner()), + true, + ) + .unwrap(); + let sheet = reopened.sheet(0).unwrap(); + for style in [ + sheet.style("A1"), + sheet.row_dimension(2).unwrap().style(), + sheet + .column_dimensions() + .iter() + .find(|c| c.col_num() == 3) + .unwrap() + .style(), + sheet.conditional_formatting_collection()[0].conditional_collection()[0] + .style() + .unwrap(), + ] { + assert_eq!(style.font().unwrap().color().theme_index(), 1); + assert_eq!( + style + .fill() + .unwrap() + .pattern_fill() + .unwrap() + .foreground_color() + .unwrap() + .theme_index(), + 1 + ); + assert_eq!( + style + .fill() + .unwrap() + .pattern_fill() + .unwrap() + .background_color() + .unwrap() + .indexed(), + 1 + ); + } +} + +#[test] +fn border_colours_import_on_all_sides_from_expanded_xml() { + let mut book = new_file(); + let borders = book.sheet_mut(0).unwrap().style_mut("C3").borders_mut(); + borders.set_diagonal_up(true); + let mut set_red = |border: &mut Border| { + border.set_border_style(Border::BORDER_THIN); + border.set_color(Color::default().set_argb_str("FFC00000").to_owned()); + }; + set_red(borders.left_mut()); + set_red(borders.right_mut()); + set_red(borders.top_mut()); + set_red(borders.bottom_mut()); + set_red(borders.diagonal_mut()); + set_red(borders.vertical_mut()); + set_red(borders.horizontal_mut()); + let source = workbook_to_xlsx_bytes(&book); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(source)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let mut data = Vec::new(); + entry.read_to_end(&mut data).unwrap(); + if entry.name() == "xl/styles.xml" { + data = String::from_utf8(data) + .unwrap() + .replace( + r#""#, + r#""#, + ) + .into_bytes(); + } + out.start_file(entry.name(), zip::write::SimpleFileOptions::default()) + .unwrap(); + std::io::Write::write_all(&mut out, &data).unwrap(); + } + let reopened = reader::xlsx::read_reader( + std::io::Cursor::new(out.finish().unwrap().into_inner()), + true, + ) + .unwrap(); + let borders = reopened.sheet(0).unwrap().style("C3").borders().unwrap(); + assert!(borders.diagonal_up()); + for border in [ + borders.left(), + borders.right(), + borders.top(), + borders.bottom(), + borders.diagonal(), + borders.vertical(), + borders.horizontal(), + ] { + assert_eq!(border.color().unwrap().argb_str(), "FFC00000"); + } +} + #[test] fn formula_cached_values_are_written_with_typed_xml_and_roundtrip() { let mut book = new_file(); diff --git a/tests/loaded_sheet_access.rs b/tests/loaded_sheet_access.rs new file mode 100644 index 00000000..21c4d0d5 --- /dev/null +++ b/tests/loaded_sheet_access.rs @@ -0,0 +1,57 @@ +//! Public API regression coverage for lazy loaded-sheet access. +use std::{ + fs, + path::PathBuf, + process, +}; + +use umya_spreadsheet::{ + XlsxError, + reader, + writer, +}; + +fn fixture_path() -> PathBuf { + std::env::temp_dir().join(format!( + "umya-loaded-sheet-access-{}-{}.xlsx", + process::id(), + std::thread::current().name().unwrap_or("test") + )) +} + +#[test] +fn loaded_sheet_access_preserves_lazy_deserialization_and_errors() { + let path = fixture_path(); + let mut source = umya_spreadsheet::new_file(); + source.new_sheet("Second").unwrap(); + source + .sheet_mut(0) + .unwrap() + .cell_mut("A1") + .set_value("first"); + source + .sheet_by_name_mut("Second") + .unwrap() + .cell_mut("A1") + .set_value("second"); + writer::xlsx::write(&source, &path).unwrap(); + + let mut book = reader::xlsx::lazy_read(&path).unwrap(); + assert!(matches!(book.sheet(0), Err(XlsxError::NotDeserialized()))); + assert!(matches!( + book.sheet_by_name("Second"), + Err(XlsxError::NotDeserialized()) + )); + assert!(matches!(book.sheet_mut(2), Err(XlsxError::NotFound()))); + + assert_eq!(book.sheet_mut(0).unwrap().value("A1"), "first"); + assert_eq!(book.sheet(0).unwrap().value("A1"), "first"); + assert!(matches!(book.sheet(1), Err(XlsxError::NotDeserialized()))); + + book.read_sheet_by_name("Second"); + assert_eq!(book.sheet_by_name("Second").unwrap().value("A1"), "second"); + + book.sheet_collection_mut(); + assert_eq!(book.sheet_collection().len(), 2); + fs::remove_file(path).unwrap(); +}