mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
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:
Generated
+5
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,5 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-cfb = { workspace = true }
|
||||
craft-sheet = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user