diff --git a/Cargo.lock b/Cargo.lock index 2fc2f829..7f405cb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1570,6 +1570,10 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-sheet" +version = "0.1.0" + [[package]] name = "craft-silhouette-studio" version = "0.1.0" @@ -1794,6 +1798,7 @@ name = "craft-xls" version = "0.1.0" dependencies = [ "craft-cfb", + "craft-sheet", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 3634d3b8..a611a202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ craft-pst = { path = "crates/pst" } craft-quit = { path = "crates/quit" } craft-rtf = { path = "crates/rtf" } craft-raw = { path = "crates/raw" } +craft-sheet = { path = "crates/sheet" } craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } craft-silhouette-studio = { path = "crates/silhouette-studio" } diff --git a/README.md b/README.md index b48364a8..4e8f4e78 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Self { + CellRef { row, col } + } + + /// The A1 text (`B3`). + pub fn a1(self) -> String { + let mut s = String::new(); + self.push_a1(&mut s); + s + } + + /// Appends the A1 text. + pub fn push_a1(self, s: &mut String) { + push_col(s, self.col); + let _ = write!(s, "{}", u64::from(self.row) + 1); + } + + /// Reads `B3`, `$B$3`, `b3`: a cell inside the grid, with nothing after it. + pub fn parse(s: &str) -> Option { + match CellRef::parse_prefix(s)? { + (cell, "") => Some(cell), + _ => None, + } + } + + /// Reads an A1 reference at the start of `s` and returns it with the rest of `s`. + pub fn parse_prefix(s: &str) -> Option<(CellRef, &str)> { + let s = s.strip_prefix('$').unwrap_or(s); + let letters = s.bytes().take_while(u8::is_ascii_alphabetic).count(); + let (cols, rest) = s.split_at_checked(letters)?; + let col = col_from_letters(cols)?; + let rest = rest.strip_prefix('$').unwrap_or(rest); + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digits == 0 || digits > 7 { + return None; + } + let (rows, rest) = rest.split_at_checked(digits)?; + let row: u32 = rows.parse().ok()?; + if row == 0 || row > MAX_ROWS { + return None; + } + Some((CellRef::new(row - 1, col), rest)) + } +} + +impl std::fmt::Display for CellRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.a1()) + } +} + +/// A rectangle of cells, both corners included; `first` is the top left one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Range { + pub first: CellRef, + pub last: CellRef, +} + +impl Range { + /// The rectangle spanned by two corners, in any order. + pub fn new(a: CellRef, b: CellRef) -> Self { + Range { first: CellRef::new(a.row.min(b.row), a.col.min(b.col)), last: CellRef::new(a.row.max(b.row), a.col.max(b.col)) } + } + + pub fn contains(&self, c: CellRef) -> bool { + (self.first.row..=self.last.row).contains(&c.row) && (self.first.col..=self.last.col).contains(&c.col) + } + + pub fn is_single(&self) -> bool { + self.first == self.last + } + + /// How many cells the rectangle holds. + pub fn count(&self) -> u64 { + (u64::from(self.last.row - self.first.row) + 1) * (u64::from(self.last.col - self.first.col) + 1) + } + + /// The A1 text: `B3` for one cell, `A1:C2` otherwise. + pub fn a1(&self) -> String { + let mut s = self.first.a1(); + if !self.is_single() { + s.push(':'); + self.last.push_a1(&mut s); + } + s + } + + /// Reads `A1:C2`, `$A$1:$C$2`, one cell (`B3`), whole columns (`A:C`) and whole rows + /// (`1:3`), with surrounding space ignored. Whole columns and rows span the grid. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let Some((a, b)) = s.split_once(':') else { + return CellRef::parse(s).map(|c| Range { first: c, last: c }); + }; + if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) { + return Some(Range::new(x, y)); + } + let (a, b) = (a.strip_prefix('$').unwrap_or(a), b.strip_prefix('$').unwrap_or(b)); + if let (Some(x), Some(y)) = (col_from_letters(a), col_from_letters(b)) { + return Some(Range::new(CellRef::new(0, x), CellRef::new(MAX_ROWS - 1, y))); + } + let row = |t: &str| -> Option { + if t.is_empty() || t.len() > 7 || !t.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + t.parse::().ok().filter(|r| (1..=MAX_ROWS).contains(r)).map(|r| r - 1) + }; + Some(Range::new(CellRef::new(row(a)?, 0), CellRef::new(row(b)?, MAX_COLS - 1))) + } +} + +impl std::fmt::Display for Range { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.a1()) + } +} + +/// Appends a column's letters: 0 is `A`, 25 is `Z`, 26 is `AA`. +pub fn push_col(s: &mut String, col: u32) { + let mut letters = [0u8; 8]; + let mut i = letters.len(); + let mut n = u64::from(col) + 1; + while n > 0 && i > 0 { + i -= 1; + if let Some(l) = letters.get_mut(i) { + *l = b'A' + ((n - 1) % 26) as u8; + } + n = (n - 1) / 26; + } + for &l in letters.get(i..).unwrap_or_default() { + s.push(char::from(l)); + } +} + +/// A column's letters: 0 is `A`, 16383 is `XFD`. +pub fn col_letters(col: u32) -> String { + let mut s = String::new(); + push_col(&mut s, col); + s +} + +/// `A` is 0, `xfd` is 16383. `None` for empty text, anything but one to three letters, or a +/// column beyond `XFD`. +pub fn col_from_letters(s: &str) -> Option { + if s.is_empty() || s.len() > 3 || !s.bytes().all(|b| b.is_ascii_alphabetic()) { + return None; + } + let n = s.bytes().fold(0u32, |n, b| n * 26 + u32::from(b.to_ascii_uppercase() - b'A') + 1); + (n <= MAX_COLS).then(|| n - 1) +} + +/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` +/// in any case. +fn is_r1c1_token(name: &str) -> bool { + let mut chars = name.chars().peekable(); + let mut letters = 0; + for letter in ['r', 'c'] { + if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { + chars.next(); + letters += 1; + while chars.peek().is_some_and(char::is_ascii_digit) { + chars.next(); + } + } + } + letters > 0 && chars.next().is_none() +} + +/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or +/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell or as +/// an R1C1 reference, or is a boolean. +pub fn sheet_needs_quotes(name: &str) -> bool { + let Some(first) = name.chars().next() else { + return true; + }; + if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + // No `$` can be here (it is not alphanumeric), so this is exactly "an A1 cell in the grid". + CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it. +pub fn quote_sheet(name: &str) -> String { + if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a1_text_both_ways() { + assert_eq!(CellRef::new(2, 27).a1(), "AB3"); + assert_eq!(CellRef::parse("AB3"), Some(CellRef::new(2, 27))); + assert_eq!(CellRef::parse("$ab$3"), Some(CellRef::new(2, 27))); + assert_eq!(CellRef::parse("XFD1048576"), Some(CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))); + assert_eq!(CellRef::new(MAX_ROWS - 1, MAX_COLS - 1).a1(), "XFD1048576"); + for bad in ["", "A", "1", "A0", "XFE1", "A1048577", "A1x", "A+1", "A-1", "AAAA1", "A12345678", "É1"] { + assert_eq!(CellRef::parse(bad), None, "{bad}"); + } + assert_eq!(CellRef::parse_prefix("B2:C3"), Some((CellRef::new(1, 1), ":C3"))); + assert_eq!(col_letters(16383), "XFD"); + assert_eq!((col_letters(26), col_letters(u32::MAX).len()), ("AA".to_string(), 7)); + assert_eq!(col_from_letters("xfd"), Some(16383)); + assert_eq!(col_from_letters("XFE"), None); + } + + #[test] + fn ranges() { + let r = Range::parse(" $C$2:A1 "); + assert_eq!(r, Some(Range { first: CellRef::new(0, 0), last: CellRef::new(1, 2) })); + assert_eq!(r.map(|r| (r.a1(), r.count())), Some(("A1:C2".to_string(), 6))); + assert_eq!(Range::parse("B3").map(|r| (r.is_single(), r.a1())), Some((true, "B3".to_string()))); + assert_eq!(Range::parse("B:$A"), Some(Range::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, 1)))); + assert_eq!(Range::parse("3:2"), Some(Range::new(CellRef::new(1, 0), CellRef::new(2, MAX_COLS - 1)))); + assert_eq!(Range::parse("1:1048577"), None); + assert_eq!(Range::parse("0:1"), None); + assert_eq!(Range::parse("A1:"), None); + assert_eq!(Range::parse("A:1"), None); + assert!(Range::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)).count() == 17_179_869_184); + } + + #[test] + fn sheet_names_are_quoted_when_excel_quotes_them() { + for plain in ["Sheet1", "Data_2", "Übersicht", "a.b", "XFE1", "A1048577", "RC1x"] { + assert!(!sheet_needs_quotes(plain), "{plain}"); + } + for quoted in ["", "My Sheet", "1st", ".x", "A1", "xfd1048576", "R1C1", "rc", "C", "TRUE", "false", "a-b", "It's"] { + assert!(sheet_needs_quotes(quoted), "{quoted}"); + } + assert_eq!(quote_sheet("It's"), "'It''s'"); + assert_eq!(quote_sheet("Sheet1"), "Sheet1"); + } +} diff --git a/crates/sheet/src/lib.rs b/crates/sheet/src/lib.rs new file mode 100644 index 00000000..337a3daf --- /dev/null +++ b/crates/sheet/src/lib.rs @@ -0,0 +1,18 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Spreadsheet vocabulary shared by the spreadsheet format crates (`craft-xls`, `craft-xlsx`, +//! `craft-ods`): cell positions and their A1 text, cell values and error values, sheet-name +//! quoting, the cell formatting enumerations, Excel's built-in number formats and its indexed +//! colour palette. +//! +//! Nothing here is a file format; each format crate re-exports what it uses, so an application +//! gets one `Value` and one `CellRef` whichever file it read. + +mod a1; +mod style; +mod tables; +mod value; + +pub use a1::{CellRef, MAX_COLS, MAX_ROWS, Range, col_from_letters, col_letters, push_col, quote_sheet, sheet_needs_quotes}; +pub use style::{BorderStyle, HAlign, Pattern, Script, Underline, VAlign}; +pub use tables::{DEFAULT_PALETTE, builtin_format, builtin_format_id}; +pub use value::{CellError, Value, Visibility}; diff --git a/crates/sheet/src/style.rs b/crates/sheet/src/style.rs new file mode 100644 index 00000000..d3c0576b --- /dev/null +++ b/crates/sheet/src/style.rs @@ -0,0 +1,87 @@ +//! The cell formatting choices BIFF8 and SpreadsheetML share. Each format has its own spelling +//! for them (a number in an XF record, a name in `styles.xml`). + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Underline { + #[default] + None, + Single, + Double, + SingleAccounting, + DoubleAccounting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Script { + #[default] + Baseline, + Superscript, + Subscript, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum HAlign { + #[default] + General, + Left, + Center, + Right, + Fill, + Justify, + CenterAcross, + Distributed, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum VAlign { + Top, + Center, + #[default] + Bottom, + Justify, + Distributed, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum BorderStyle { + #[default] + None, + Thin, + Medium, + Dashed, + Dotted, + Thick, + Double, + Hair, + MediumDashed, + DashDot, + MediumDashDot, + DashDotDot, + MediumDashDotDot, + SlantDashDot, +} + +/// Fill patterns, named as SpreadsheetML names them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Pattern { + #[default] + None, + Solid, + Gray50, + Gray75, + Gray25, + DarkHorizontal, + DarkVertical, + DarkDown, + DarkUp, + DarkGrid, + DarkTrellis, + LightHorizontal, + LightVertical, + LightDown, + LightUp, + LightGrid, + LightTrellis, + Gray125, + Gray0625, +} diff --git a/crates/sheet/src/tables.rs b/crates/sheet/src/tables.rs new file mode 100644 index 00000000..b30935d5 --- /dev/null +++ b/crates/sheet/src/tables.rs @@ -0,0 +1,94 @@ +//! Tables Excel files refer to by number. + +/// Built-in number format codes (en-US) by id. Ids not listed (23–36 are East Asian +/// locale formats) read as General. +const BUILTIN_FORMATS: [(u32, &str); 36] = [ + (0, "General"), + (1, "0"), + (2, "0.00"), + (3, "#,##0"), + (4, "#,##0.00"), + (5, "\"$\"#,##0_);\\(\"$\"#,##0\\)"), + (6, "\"$\"#,##0_);[Red]\\(\"$\"#,##0\\)"), + (7, "\"$\"#,##0.00_);\\(\"$\"#,##0.00\\)"), + (8, "\"$\"#,##0.00_);[Red]\\(\"$\"#,##0.00\\)"), + (9, "0%"), + (10, "0.00%"), + (11, "0.00E+00"), + (12, "# ?/?"), + (13, "# ??/??"), + (14, "m/d/yyyy"), + (15, "d-mmm-yy"), + (16, "d-mmm"), + (17, "mmm-yy"), + (18, "h:mm AM/PM"), + (19, "h:mm:ss AM/PM"), + (20, "h:mm"), + (21, "h:mm:ss"), + (22, "m/d/yyyy h:mm"), + (37, "#,##0 ;(#,##0)"), + (38, "#,##0 ;[Red](#,##0)"), + (39, "#,##0.00;(#,##0.00)"), + (40, "#,##0.00;[Red](#,##0.00)"), + (41, "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"), + (42, "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"), + (43, "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)"), + (44, "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), + (45, "mm:ss"), + (46, "[h]:mm:ss"), + (47, "mmss.0"), + (48, "##0.0E+0"), + (49, "@"), +]; + +pub fn builtin_format(id: u32) -> Option<&'static str> { + BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) +} + +/// Id to write for a format code when it is a locale-independent built-in. The currency formats +/// 5 to 8 and the accounting formats with a currency, 42 and 44, are left out (`None`, to be +/// written as custom formats) because their meaning depends on the reader's locale. +pub fn builtin_format_id(code: &str) -> Option { + BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !matches!(i, 5..=8 | 42 | 44)).map(|(i, _)| *i) +} + +/// The indexed colour table of a workbook that does not replace it, as `0xRRGGBB`: the eight +/// fixed colours, then the 56 a BIFF8 Palette record ([MS-XLS] 2.4.188) or a SpreadsheetML +/// `indexedColors` list (ECMA-376 Part 1, 18.8.27) replaces. +pub const DEFAULT_PALETTE: [u32; 64] = [ + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, + 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, + 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_format_ids() { + assert_eq!((0..=60).filter(|&id| builtin_format(id).is_some()).count(), 36); + assert_eq!(builtin_format(0), Some("General")); + assert_eq!(builtin_format(14), Some("m/d/yyyy")); + assert_eq!(builtin_format(22), Some("m/d/yyyy h:mm")); + assert_eq!(builtin_format(49), Some("@")); + // 23 to 36 are locale formats that are not defined here. + assert_eq!((23..=36).find_map(builtin_format), None); + assert_eq!(builtin_format(50), None); + for id in 0..=60 { + if let Some(code) = builtin_format(id) { + let back = builtin_format_id(code); + assert_eq!(back, (!matches!(id, 5..=8 | 42 | 44)).then_some(id), "{id}"); + } + } + assert_eq!(builtin_format_id("yyyy"), None); + } + + #[test] + fn the_palette_repeats_its_eight_fixed_colours() { + assert_eq!(DEFAULT_PALETTE.get(..8), DEFAULT_PALETTE.get(8..16)); + assert_eq!(DEFAULT_PALETTE.get(63), Some(&0x333333)); + } +} diff --git a/crates/sheet/src/value.rs b/crates/sheet/src/value.rs new file mode 100644 index 00000000..066dc3e9 --- /dev/null +++ b/crates/sheet/src/value.rs @@ -0,0 +1,133 @@ +//! What a cell stores. + +use std::sync::Arc; + +/// The error values a spreadsheet file stores. The first eight are the ones BIFF8 has +/// ([MS-XLS] 2.5.10, BErr); the rest are newer and appear in SpreadsheetML as text. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CellError { + Null, + Div0, + Value, + Ref, + Name, + Num, + NA, + GettingData, + Spill, + Calc, + Blocked, + Python, + Busy, + Timeout, +} + +impl CellError { + /// Every error value, in the order above. + pub const ALL: [CellError; 14] = [ + CellError::Null, + CellError::Div0, + CellError::Value, + CellError::Ref, + CellError::Name, + CellError::Num, + CellError::NA, + CellError::GettingData, + CellError::Spill, + CellError::Calc, + CellError::Blocked, + CellError::Python, + CellError::Busy, + CellError::Timeout, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + CellError::Null => "#NULL!", + CellError::Div0 => "#DIV/0!", + CellError::Value => "#VALUE!", + CellError::Ref => "#REF!", + CellError::Name => "#NAME?", + CellError::Num => "#NUM!", + CellError::NA => "#N/A", + CellError::GettingData => "#GETTING_DATA", + CellError::Spill => "#SPILL!", + CellError::Calc => "#CALC!", + CellError::Blocked => "#BLOCKED!", + CellError::Python => "#PYTHON!", + CellError::Busy => "#BUSY!", + CellError::Timeout => "#TIMEOUT!", + } + } + + /// The error with this text, in any case. + pub fn parse(s: &str) -> Option { + CellError::ALL.iter().find(|e| e.as_str().eq_ignore_ascii_case(s)).copied() + } +} + +impl std::fmt::Display for CellError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A cell's stored value (for a formula cell, its saved result). +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Value { + #[default] + Empty, + Number(f64), + Text(Arc), + Bool(bool), + Error(CellError), +} + +impl Value { + /// A number, or `#NUM!` when it is NaN or infinite (Excel never stores those). Negative zero + /// becomes zero. + pub fn number(v: f64) -> Value { + if v.is_finite() { Value::Number(if v == 0.0 { 0.0 } else { v }) } else { Value::Error(CellError::Num) } + } + + pub fn text(s: impl Into>) -> Value { + Value::Text(s.into()) + } + + pub fn is_empty(&self) -> bool { + matches!(self, Value::Empty) + } +} + +/// Whether a sheet shows: `VeryHidden` cannot be unhidden from the user interface. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Visibility { + #[default] + Visible, + Hidden, + VeryHidden, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_parse_back_from_their_text() { + for e in CellError::ALL { + assert_eq!(CellError::parse(e.as_str()), Some(e)); + assert_eq!(CellError::parse(&e.as_str().to_ascii_lowercase()), Some(e)); + } + assert_eq!(CellError::parse("#NOPE!"), None); + assert_eq!(CellError::Div0.to_string(), "#DIV/0!"); + } + + #[test] + fn numbers_are_finite() { + assert_eq!(Value::number(f64::NAN), Value::Error(CellError::Num)); + assert_eq!(Value::number(f64::INFINITY), Value::Error(CellError::Num)); + assert!(matches!(Value::number(-0.0), Value::Number(n) if n.is_sign_positive())); + assert!(Value::Empty.is_empty() && !Value::text("").is_empty()); + } +} diff --git a/crates/xls/Cargo.toml b/crates/xls/Cargo.toml index f742798c..41d3362f 100644 --- a/crates/xls/Cargo.toml +++ b/crates/xls/Cargo.toml @@ -12,4 +12,5 @@ workspace = true [dependencies] craft-cfb = { workspace = true } +craft-sheet = { workspace = true } thiserror = { workspace = true } diff --git a/crates/xls/README.md b/crates/xls/README.md index c0b6c748..7fbb9328 100644 --- a/crates/xls/README.md +++ b/crates/xls/README.md @@ -3,7 +3,8 @@ A clean-room reader for Excel 97-2003 workbooks (`.xls`, BIFF8). It turns the bytes of a file into a `Workbook` that keeps the file's own terms: cells, formulas as text, the format table, column and row records. Mapping that onto an application's document is the caller's work. -No `unsafe`; the only dependencies are `craft-cfb` (the compound file container) and `thiserror`. +No `unsafe`; the only dependencies are `craft-cfb` (the compound file container), `craft-sheet` (cell +positions, values and tables shared with the other spreadsheet formats) and `thiserror`. No third-party implementation was consulted. | Document | Used for | diff --git a/crates/xls/src/expr.rs b/crates/xls/src/expr.rs index 704e2a57..f4e0b114 100644 --- a/crates/xls/src/expr.rs +++ b/crates/xls/src/expr.rs @@ -3,12 +3,9 @@ use std::fmt::Write; use std::sync::Arc; -use crate::CellError; +use craft_sheet::{push_col, quote_sheet, sheet_needs_quotes}; -/// Rows and columns of the grid a sheet name is tested against when deciding whether it reads -/// as a cell reference (the SpreadsheetML grid, which is the larger one). -const GRID_ROWS: u32 = 1_048_576; -const GRID_COLS: u32 = 16_384; +use crate::CellError; /// One end of a reference, with `$` anchors. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -116,31 +113,6 @@ pub(crate) enum Expr { Paren(Box), } -/// 0 → `A`, 25 → `Z`, 26 → `AA`. -fn push_col(s: &mut String, col: u32) { - let mut letters = [0u8; 8]; - let mut i = letters.len(); - let mut n = u64::from(col) + 1; - while n > 0 && i > 0 { - i -= 1; - if let Some(l) = letters.get_mut(i) { - *l = b'A' + ((n - 1) % 26) as u8; - } - n = (n - 1) / 26; - } - for &l in letters.get(i..).unwrap_or_default() { - s.push(char::from(l)); - } -} - -/// The A1 text of a cell (`B3`), for messages and tests. -pub(crate) fn a1(row: u32, col: u32) -> String { - let mut s = String::new(); - push_col(&mut s, col); - let _ = write!(s, "{}", u64::from(row) + 1); - s -} - fn push_anchor(s: &mut String, a: &Anchor) { if a.col_abs { s.push('$'); @@ -152,53 +124,6 @@ fn push_anchor(s: &mut String, a: &Anchor) { let _ = write!(s, "{}", u64::from(a.row) + 1); } -/// Whether `name` is an A1 cell reference inside the grid (`A1`, `xfd1048576`). -fn is_a1(name: &str) -> bool { - let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count(); - let Some((cols, digits)) = name.split_at_checked(letters) else { return false }; - if cols.is_empty() || cols.len() > 3 || digits.is_empty() || digits.len() > 7 || !digits.bytes().all(|b| b.is_ascii_digit()) { - return false; - } - let col = cols.bytes().fold(0u32, |n, b| n * 26 + u32::from(b.to_ascii_uppercase() - b'A') + 1); - let row: u32 = digits.parse().unwrap_or(0); - (1..=GRID_COLS).contains(&col) && (1..=GRID_ROWS).contains(&row) -} - -/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` -/// in any case. -fn is_r1c1_token(name: &str) -> bool { - let mut chars = name.chars().peekable(); - let mut letters = 0; - for letter in ['r', 'c'] { - if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { - chars.next(); - letters += 1; - while chars.peek().is_some_and(char::is_ascii_digit) { - chars.next(); - } - } - } - letters > 0 && chars.next().is_none() -} - -/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or -/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell or as -/// an R1C1 reference, or is a boolean. -pub fn sheet_needs_quotes(name: &str) -> bool { - let Some(first) = name.chars().next() else { - return true; - }; - if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { - return true; - } - is_a1(name) || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") -} - -/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it. -pub fn quote_sheet(name: &str) -> String { - if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } -} - fn push_reference(s: &mut String, r: &Reference) { match &r.sheet { SheetSel::Current => {} @@ -402,19 +327,4 @@ mod tests { assert_eq!(number_to_text(1e-10), "1E-10"); assert!(number(f64::MAX).parse::().is_ok_and(f64::is_finite)); } - - #[test] - fn sheet_names_are_quoted_when_excel_quotes_them() { - for plain in ["Sheet1", "Data_2", "Übersicht", "a.b"] { - assert!(!sheet_needs_quotes(plain), "{plain}"); - } - for quoted in ["", "My Data", "1st", ".x", "A1", "xfd1048576", "R1C1", "rc", "true", "It's"] { - assert!(sheet_needs_quotes(quoted), "{quoted}"); - } - // Outside the grid it is no cell reference. - assert!(!sheet_needs_quotes("XFE1")); - assert!(!sheet_needs_quotes("A1048577")); - assert_eq!(quote_sheet("It's"), "'It''s'"); - assert_eq!(a1(2, 27), "AB3"); - } } diff --git a/crates/xls/src/lib.rs b/crates/xls/src/lib.rs index 20d73ab4..0dc33f61 100644 --- a/crates/xls/src/lib.rs +++ b/crates/xls/src/lib.rs @@ -40,13 +40,12 @@ mod styles; #[doc(hidden)] pub mod testing; -pub use expr::{quote_sheet, sheet_needs_quotes}; +pub use craft_sheet::{DEFAULT_PALETTE, quote_sheet, sheet_needs_quotes}; pub use model::{ Alignment, BorderLine, BorderStyle, Borders, Calc, CalcMode, Cell, CellError, CellFormat, CellRef, Color, Column, DefinedName, Fill, Font, Formula, HAlign, Pattern, Range, Row, Script, Sheet, Underline, VAlign, Value, View, Visibility, Workbook, }; pub use read::{is_xls, read, read_with}; -pub use styles::DEFAULT_PALETTE; /// Why a workbook could not be read. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/xls/src/model.rs b/crates/xls/src/model.rs index 3aedf869..ce88a043 100644 --- a/crates/xls/src/model.rs +++ b/crates/xls/src/model.rs @@ -2,98 +2,8 @@ //! the format (widths and heights keep the file's units, formulas are text). use std::collections::BTreeMap; -use std::sync::Arc; -/// A cell position, zero-based. Ordered by row, then column. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct CellRef { - pub row: u32, - pub col: u32, -} - -impl CellRef { - pub const fn new(row: u32, col: u32) -> Self { - CellRef { row, col } - } - - /// The A1 text (`B3`). - pub fn a1(self) -> String { - crate::expr::a1(self.row, self.col) - } -} - -/// A rectangle of cells, both corners included; `first` is the top left one. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct Range { - pub first: CellRef, - pub last: CellRef, -} - -impl Range { - /// The rectangle spanned by two corners, in any order. - pub fn new(a: CellRef, b: CellRef) -> Self { - Range { first: CellRef::new(a.row.min(b.row), a.col.min(b.col)), last: CellRef::new(a.row.max(b.row), a.col.max(b.col)) } - } - - pub fn contains(&self, c: CellRef) -> bool { - (self.first.row..=self.last.row).contains(&c.row) && (self.first.col..=self.last.col).contains(&c.col) - } - - pub fn is_single(&self) -> bool { - self.first == self.last - } -} - -/// The error values a BIFF8 file stores ([MS-XLS] 2.5.10, BErr). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CellError { - Null, - Div0, - Value, - Ref, - Name, - Num, - NA, - GettingData, -} - -impl CellError { - pub fn as_str(&self) -> &'static str { - match self { - CellError::Null => "#NULL!", - CellError::Div0 => "#DIV/0!", - CellError::Value => "#VALUE!", - CellError::Ref => "#REF!", - CellError::Name => "#NAME?", - CellError::Num => "#NUM!", - CellError::NA => "#N/A", - CellError::GettingData => "#GETTING_DATA", - } - } -} - -/// A cell's stored value (for a formula cell, its saved result). -#[derive(Clone, Debug, Default, PartialEq)] -pub enum Value { - #[default] - Empty, - Number(f64), - Text(Arc), - Bool(bool), - Error(CellError), -} - -impl Value { - /// A number, or `#NUM!` when it is NaN or infinite (Excel never stores those). Negative zero - /// becomes zero. - pub fn number(v: f64) -> Value { - if v.is_finite() { Value::Number(if v == 0.0 { 0.0 } else { v }) } else { Value::Error(CellError::Num) } - } - - pub fn is_empty(&self) -> bool { - matches!(self, Value::Empty) - } -} +pub use craft_sheet::{BorderStyle, CellError, CellRef, HAlign, Pattern, Range, Script, Underline, VAlign, Value, Visibility}; /// A cell's formula. #[derive(Clone, Debug, PartialEq, Eq)] @@ -114,15 +24,6 @@ pub struct Cell { pub formula: Option, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Visibility { - #[default] - Visible, - Hidden, - /// Hidden, and not offered by Excel's Unhide dialog. - VeryHidden, -} - /// A column's ColInfo record ([MS-XLS] 2.4.53). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Column { @@ -224,24 +125,6 @@ pub enum Color { Rgb(u32), } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum Underline { - #[default] - None, - Single, - Double, - SingleAccounting, - DoubleAccounting, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum Script { - #[default] - Baseline, - Superscript, - Subscript, -} - /// A Font record ([MS-XLS] 2.4.122). #[derive(Clone, Debug, Default, PartialEq)] pub struct Font { @@ -261,29 +144,6 @@ pub struct Font { pub charset: Option, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum HAlign { - #[default] - General, - Left, - Center, - Right, - Fill, - Justify, - CenterAcross, - Distributed, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum VAlign { - Top, - Center, - #[default] - Bottom, - Justify, - Distributed, -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Alignment { pub h: HAlign, @@ -295,25 +155,6 @@ pub struct Alignment { pub shrink: bool, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum BorderStyle { - #[default] - None, - Thin, - Medium, - Dashed, - Dotted, - Thick, - Double, - Hair, - MediumDashed, - DashDot, - MediumDashDot, - DashDotDot, - MediumDashDotDot, - SlantDashDot, -} - /// One border side. A side without a style has the automatic colour. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct BorderLine { @@ -333,31 +174,6 @@ pub struct Borders { pub diag_up: BorderLine, } -/// Fill patterns, named as SpreadsheetML names them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum Pattern { - #[default] - None, - Solid, - Gray50, - Gray75, - Gray25, - DarkHorizontal, - DarkVertical, - DarkDown, - DarkUp, - DarkGrid, - DarkTrellis, - LightHorizontal, - LightVertical, - LightDown, - LightUp, - LightGrid, - LightTrellis, - Gray125, - Gray0625, -} - /// A cell's fill. Without a pattern both colours are automatic. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Fill { diff --git a/crates/xls/src/styles.rs b/crates/xls/src/styles.rs index 59d68784..ea0fcae8 100644 --- a/crates/xls/src/styles.rs +++ b/crates/xls/src/styles.rs @@ -9,61 +9,7 @@ use crate::model::{ Alignment, BorderLine, BorderStyle, Borders, CellFormat, Color, Fill, Font, HAlign, Pattern, Script, Underline, VAlign, Workbook, }; use crate::records::Cursor; - -/// Built-in number format codes (en-US) by id. Ids not listed (23–36 are East Asian -/// locale formats) read as General. -const BUILTIN_FORMATS: [(u32, &str); 36] = [ - (0, "General"), - (1, "0"), - (2, "0.00"), - (3, "#,##0"), - (4, "#,##0.00"), - (5, "\"$\"#,##0_);\\(\"$\"#,##0\\)"), - (6, "\"$\"#,##0_);[Red]\\(\"$\"#,##0\\)"), - (7, "\"$\"#,##0.00_);\\(\"$\"#,##0.00\\)"), - (8, "\"$\"#,##0.00_);[Red]\\(\"$\"#,##0.00\\)"), - (9, "0%"), - (10, "0.00%"), - (11, "0.00E+00"), - (12, "# ?/?"), - (13, "# ??/??"), - (14, "m/d/yyyy"), - (15, "d-mmm-yy"), - (16, "d-mmm"), - (17, "mmm-yy"), - (18, "h:mm AM/PM"), - (19, "h:mm:ss AM/PM"), - (20, "h:mm"), - (21, "h:mm:ss"), - (22, "m/d/yyyy h:mm"), - (37, "#,##0 ;(#,##0)"), - (38, "#,##0 ;[Red](#,##0)"), - (39, "#,##0.00;(#,##0.00)"), - (40, "#,##0.00;[Red](#,##0.00)"), - (41, "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"), - (42, "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"), - (43, "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)"), - (44, "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), - (45, "mm:ss"), - (46, "[h]:mm:ss"), - (47, "mmss.0"), - (48, "##0.0E+0"), - (49, "@"), -]; - -pub(crate) fn builtin_format(id: u32) -> Option<&'static str> { - BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) -} - -/// The colour table a workbook without a Palette record uses, as `0xRRGGBB`: the eight fixed -/// colours, then the 56 a Palette record replaces ([MS-XLS] 2.4.188). -pub const DEFAULT_PALETTE: [u32; 64] = [ - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, - 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, - 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, - 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, - 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, -]; +use craft_sheet::{DEFAULT_PALETTE, builtin_format}; /// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). const MAX_FONTS: usize = 1024; @@ -453,20 +399,3 @@ pub(crate) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut W wb.cell_styles = named; wb.formats.iter().map(|f| *f == plain).collect() } - -#[cfg(test)] -mod tests { - use super::builtin_format; - - #[test] - fn builtin_format_ids() { - assert_eq!((0..=60).filter(|&id| builtin_format(id).is_some()).count(), 36); - assert_eq!(builtin_format(0), Some("General")); - assert_eq!(builtin_format(14), Some("m/d/yyyy")); - assert_eq!(builtin_format(22), Some("m/d/yyyy h:mm")); - assert_eq!(builtin_format(49), Some("@")); - // 23-36 are locale formats, above 49 is custom. - assert_eq!((23..=36).find_map(builtin_format), None); - assert_eq!(builtin_format(50), None); - } -}