Add craft-sheet: the cell positions, values and tables the spreadsheet formats share (#956)

craft-xls carried its own CellRef, Range, Value, CellError, Visibility, sheet-name
quoting, built-in number format table and indexed palette. craft-xlsx and craft-ods
need the same ones, so they move to a crate with no dependencies and craft-xls
re-exports them: an application gets one Value and one CellRef whichever file it read.

Added on the way: A1 parsing (CellRef::parse, Range::parse), Range::count and a1,
CellError::parse and the newer error values SpreadsheetML stores as text,
builtin_format_id for writers.
This commit is contained in:
Dylan M. Taylor
2026-11-11 19:24:39 -05:00
committed by GitHub
parent 16936716aa
commit eb640fd992
16 changed files with 630 additions and 352 deletions
Generated
+5
View File
@@ -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",
]
+1
View File
@@ -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" }
+1
View File
@@ -250,6 +250,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
(`\uN`, `\'hh`, single-byte and East Asian multibyte pages), escaping and dates, and a reader and writer
for a small neutral model (paragraphs, character formatting, outline levels, tables).
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
- [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies.
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
- [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft.
- [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "craft-sheet"
description = "Spreadsheet vocabulary shared by the spreadsheet format crates: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
+18
View File
@@ -0,0 +1,18 @@
# craft-sheet
What every spreadsheet file format says in the same words, kept once so that `craft-xls`,
`craft-xlsx` and `craft-ods` hand an application the same types:
- `CellRef` and `Range`: zero-based cell positions, and their A1 text both ways (`B3`,
`$B$3`, `A1:C2`), inside Excel's 1,048,576 by 16,384 grid.
- `Value` and `CellError`: what a cell stores, and the error values with their text (`#DIV/0!`).
- `Visibility`: visible, hidden, very hidden.
- `sheet_needs_quotes`, `quote_sheet`: a sheet name as a reference spells it (`'My Sheet'!A1`).
- `HAlign`, `VAlign`, `Underline`, `Script`, `BorderStyle`, `Pattern`: the cell formatting choices
BIFF8 and SpreadsheetML share.
- `builtin_format`, `builtin_format_id`: the number format codes behind the built-in ids
(ECMA-376 Part 1, 18.8.30; [MS-XLS] 2.4.126).
- `DEFAULT_PALETTE`: the 64 indexed colours ([MS-XLS] 2.4.188; ECMA-376 Part 1, 18.8.27).
It is not a file format and has no dependencies. It holds no document model: sheets, styles
and formulas are each format's own.
+252
View File
@@ -0,0 +1,252 @@
//! Cell positions and their A1 text.
use std::fmt::Write;
/// Rows in the grid of Excel 2007 and later.
pub const MAX_ROWS: u32 = 1_048_576;
/// Columns in the grid of Excel 2007 and later (`A` to `XFD`).
pub const MAX_COLS: u32 = 16_384;
/// 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 {
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<CellRef> {
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<Range> {
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<u32> {
if t.is_empty() || t.len() > 7 || !t.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
t.parse::<u32>().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<u32> {
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");
}
}
+18
View File
@@ -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};
+87
View File
@@ -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,
}
+94
View File
@@ -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<u32> {
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));
}
}
+133
View File
@@ -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> {
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<str>),
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<Arc<str>>) -> 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());
}
}
+1
View File
@@ -12,4 +12,5 @@ workspace = true
[dependencies]
craft-cfb = { workspace = true }
craft-sheet = { workspace = true }
thiserror = { workspace = true }
+2 -1
View File
@@ -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 |
+2 -92
View File
@@ -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<Expr>),
}
/// 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::<f64>().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");
}
}
+1 -2
View File
@@ -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)]
+1 -185
View File
@@ -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<str>),
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<Formula>,
}
#[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<u8>,
}
#[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 {
+1 -72
View File
@@ -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<u16, String>, 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);
}
}