diff --git a/Cargo.lock b/Cargo.lock index 7b227c217..cb35096ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,6 +1829,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xlsx" +version = "0.1.0" +dependencies = [ + "craft-opc", + "craft-sheet", + "craft-xml", + "thiserror 2.0.21", +] + [[package]] name = "craft-xml" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6b5a23328..9f46a4741 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,7 @@ craft-vcard = { path = "crates/vcard" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } craft-xls = { path = "crates/xls" } +craft-xlsx = { path = "crates/xlsx" } craft-xcf = { path = "crates/xcf" } craft-xml = { path = "crates/xml" } craft-zip = { path = "crates/zip" } diff --git a/README.md b/README.md index 33f37171c..1784d1e47 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Result<(), craft_xlsx::Error> { +//! # let bytes = craft_xlsx::testing::workbook(&[("Prices", r#"02.5"#)], &["Tea"]); +//! let mut package = craft_xlsx::Package::open(&bytes, &craft_xlsx::Limits::default())?; +//! let workbook = package.workbook()?; +//! let strings = package.shared_strings()?; +//! let mut values = Vec::new(); +//! package.sheet(&workbook.sheets[0], |row| { +//! values.extend(row.cells.iter().map(|c| c.value.resolve(&strings, workbook.date_1904))); +//! Ok(()) +//! })?; +//! assert_eq!(values, [craft_xlsx::Value::text("Tea"), craft_xlsx::Value::Number(2.5)]); +//! # Ok(()) +//! # } +//! ``` + +mod package; +mod sheet; +mod strings; +mod styles; +#[doc(hidden)] +pub mod testing; +mod workbook; +mod xlsb; + +pub use craft_sheet::{ + BorderStyle, CellError, CellRef, DEFAULT_PALETTE, HAlign, MAX_COLS, MAX_ROWS, Pattern, Range, Script, Underline, VAlign, Value, Visibility, + builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes, +}; +pub use package::{Package, is_xlsb}; +pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial}; +pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text}; +pub use styles::{ + Alignment, Border, BorderEdge, CellStyle, Color, ColorSource, Dxf, Fill, Font, FontScheme, GradientFill, PatternFill, Protection, Styles, Xf, +}; +pub use workbook::{DefinedName, SheetEntry, SheetKind, Workbook}; + +/// Why a workbook could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a ZIP package, or an entry is damaged. + #[error("the file is not a valid zip package: {0}")] + Zip(String), + /// A part that must be read holds XML that cannot be. + #[error("the file contains invalid XML: {0}")] + Xml(String), + /// The package is not a workbook, or a binary part is damaged. + #[error("the file is not a valid spreadsheet: {0}")] + Invalid(String), + /// More than [`Limits`] or a fixed cap allows. + #[error("the file is too large: {0}")] + TooLarge(String), +} + +impl From for Error { + fn from(e: craft_opc::Error) -> Self { + match e { + e if e.is_limit() => Error::TooLarge(e.to_string()), + craft_opc::Error::Zip(m) => Error::Zip(m), + craft_opc::Error::Xml { .. } => Error::Xml(e.to_string()), + e => Error::Invalid(e.to_string()), + } + } +} + +impl From for Error { + fn from(e: craft_xml::Error) -> Self { + if e.is_limit() { Error::TooLarge(e.to_string()) } else { Error::Xml(e.to_string()) } + } +} + +/// What a read refuses to exceed. The defaults suit a spreadsheet application; a caller that +/// only wants a table of values can lower them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// The ZIP package: entries, bytes per part and bytes in all. + pub package: craft_opc::Limits, + /// One XML part. Rows and shared strings are streamed, so `max_nodes` bounds one row or one + /// string and what is left of a part, not a sheet's length. + pub xml: craft_xml::Limits, + /// Records read from a binary workbook, every part together. Records are skipped or decoded + /// without being kept, so this bounds parse time, not memory. Excel writes four records per + /// row besides the cells, and one per formatted empty cell, so a sheet needs many more + /// records than cells. + pub binary_records: usize, + /// Cells with a value read from a binary workbook, over all its sheets; also the most + /// shared-string items. + pub binary_cells: usize, + /// Bytes of decoded text (shared and inline strings) in a binary workbook, over all its parts. + pub binary_text: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + package: craft_opc::Limits::new(200_000, 512 << 20, 1 << 30), + xml: craft_xml::Limits { max_input: 512 << 20, ..craft_xml::Limits::DEFAULT }, + // The limits of `craft-xls`. + binary_records: 40_000_000, + binary_cells: 4_000_000, + binary_text: 256 << 20, + } + } +} + +/// What a read skipped or repaired, each distinct message once and at most 1000 of them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Warnings(pub(crate) Vec); + +impl Warnings { + pub(crate) fn warn(&mut self, m: impl Into) { + let m = m.into(); + if self.0.len() < 1000 && !self.0.contains(&m) { + self.0.push(m); + } + } +} diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs new file mode 100644 index 000000000..dc772e46e --- /dev/null +++ b/crates/xlsx/src/package.rs @@ -0,0 +1,275 @@ +//! The OPC package of a workbook and the parts this crate reads from it. + +use craft_opc::{ContentTypes, LazyPackage, Rels}; +use craft_xml::{El, Options, UnknownEntity}; + +use crate::sheet::{Row, RowReader, SheetPart}; +use crate::strings::StringItem; +use crate::styles::Styles; +use crate::workbook::{SheetEntry, Workbook}; +use crate::{Error, Limits, Warnings, xlsb}; + +/// Most bytes of `[Content_Types].xml` read from a binary workbook. +const MAX_CONTENT_TYPES: u64 = 1 << 20; + +/// An open workbook package. Parts are inflated when they are asked for, against the +/// package's [`Limits`]. +pub struct Package<'a> { + opc: LazyPackage<'a>, + limits: Limits, + warnings: Warnings, + workbook_part: String, + /// Content types, for a binary workbook (which checks every part it reads against them). + binary: Option, + workbook_rels: Option, + budget: xlsb::Budget, + /// Shared strings in a binary workbook, once counted: a cell's index is checked against it. + binary_strings: Option, +} + +/// Is this a binary workbook (`.xlsb`): a package whose one workbook relationship points at a +/// part of the XLSB workbook content type? +pub fn is_xlsb(bytes: &[u8]) -> bool { + Package::open(bytes, &Limits::default()).is_ok_and(|p| p.is_binary()) +} + +impl<'a> Package<'a> { + /// Opens the package and finds the workbook part. Nothing else is read yet. + pub fn open(bytes: &'a [u8], limits: &Limits) -> Result, Error> { + let mut opc = LazyPackage::open(bytes, &limits.package)?; + let root = opc.rels("")?; + let documents: Vec<_> = root.all("officeDocument").collect(); + let mut binary = None; + let workbook_part = match documents.first() { + Some(rel) if !rel.external && rel.target.to_ascii_lowercase().ends_with(".bin") => { + let types = opc.read_limited("[Content_Types].xml", MAX_CONTENT_TYPES)?.map(|b| ContentTypes::parse(&b)).unwrap_or_default(); + if documents.len() != 1 { + return Err(xlsb::invalid("expected one workbook relationship")); + } + xlsb::check_type(&types, &rel.target, xlsb::MAIN)?; + binary = Some(types); + rel.target.clone() + } + Some(rel) if !rel.external => rel.target.clone(), + _ => "xl/workbook.xml".to_string(), + }; + Ok(Package { + opc, + limits: *limits, + warnings: Warnings::default(), + workbook_part, + binary, + workbook_rels: None, + budget: xlsb::Budget::default(), + binary_strings: None, + }) + } + + /// A binary workbook (`.xlsb`). + pub fn is_binary(&self) -> bool { + self.binary.is_some() + } + + /// The workbook part's name. + pub fn workbook_part(&self) -> &str { + &self.workbook_part + } + + /// What reading skipped or repaired so far, each message once. + pub fn warnings(&self) -> &[String] { + &self.warnings.0 + } + + /// Adds a warning (a caller reading further parts keeps one list). + pub fn warn(&mut self, m: impl Into) { + self.warnings.warn(m); + } + + /// The package underneath, for the parts this crate does not read. + pub fn opc(&mut self) -> &mut LazyPackage<'a> { + &mut self.opc + } + + /// The XML options parts are read with: local names, an undefined entity kept as written, + /// [`Limits::xml`]. + pub fn xml_options(&self) -> Options<'static> { + Options { unknown_entity: UnknownEntity::Literal, ..Options::local() }.with_limits(self.limits.xml) + } + + /// Part `part` as XML; `Ok(None)` when there is no such part. + pub fn read_xml(&mut self, part: &str) -> Result, Error> { + let options = self.xml_options(); + Ok(self.opc.read_xml(part, &options)?) + } + + /// A part the workbook can do without: missing or unreadable is a warning and `None`; + /// only a limit is an error. + pub fn optional_xml(&mut self, part: &str) -> Result, Error> { + match self.read_xml(part) { + Ok(Some(e)) => Ok(Some(e)), + Ok(None) => { + self.warn(format!("missing part {part}")); + Ok(None) + } + Err(Error::TooLarge(m)) => Err(Error::TooLarge(m)), + Err(e) => { + self.warn(format!("skipped {part}: {e}")); + Ok(None) + } + } + } + + /// The relationships of `part`. + pub fn rels(&mut self, part: &str) -> Result { + Ok(self.opc.rels(part)?) + } + + /// The workbook part's relationships. + pub fn workbook_rels(&mut self) -> Result { + if let Some(r) = &self.workbook_rels { + return Ok(r.clone()); + } + let rels = self.opc.rels(&self.workbook_part)?; + self.workbook_rels = Some(rels.clone()); + Ok(rels) + } + + /// The workbook part. + pub fn workbook(&mut self) -> Result { + if self.binary.is_some() { + let bytes = self.required(&self.workbook_part.clone())?; + let rels = self.workbook_rels()?; + return xlsb::workbook(&bytes, &rels, &self.limits, &self.budget, &mut self.warnings); + } + let options = self.xml_options(); + let root = match self.opc.read_xml(&self.workbook_part, &options)? { + Some(root) => root, + None if self.workbook_part != "xl/workbook.xml" && self.opc.has("xl/workbook.xml") => { + self.workbook_part = "xl/workbook.xml".into(); + self.workbook_rels = None; + self.opc.read_xml("xl/workbook.xml", &options)?.ok_or_else(|| Error::Invalid("no workbook part".into()))? + } + None if self.opc.has("content.xml") => return Err(Error::Invalid("this is an OpenDocument file, not XLSX".into())), + None => return Err(Error::Invalid("no workbook part".into())), + }; + if root.name != "workbook" { + return Err(Error::Invalid(format!("unexpected root element <{}> in workbook part", root.name))); + } + let rels = self.workbook_rels()?; + Ok(Workbook::from_xml(root, &rels)) + } + + /// The shared strings, empty when the workbook has none. + pub fn shared_strings(&mut self) -> Result, Error> { + let rels = self.workbook_rels()?; + if self.binary.is_some() { + let found: Vec<_> = rels.all("sharedStrings").collect(); + let Some(rel) = found.first() else { + self.binary_strings = Some(0); + return Ok(Vec::new()); + }; + if found.len() > 1 { + return Err(xlsb::invalid("multiple shared-string relationships")); + } + if rel.external { + return Err(xlsb::invalid("external shared-string relationship")); + } + if let Some(types) = &self.binary { + xlsb::check_type(types, &rel.target, xlsb::STRINGS)?; + } + let bytes = self.required(&rel.target)?; + let strings = xlsb::shared_strings(&bytes, &self.limits, &self.budget, &mut self.warnings)?; + self.binary_strings = Some(strings.len()); + return Ok(strings); + } + let Some(rel) = rels.first("sharedStrings").filter(|r| !r.external) else { return Ok(Vec::new()) }; + let Some(bytes) = self.opc.read(&rel.target)? else { + self.warn(format!("missing part {}", rel.target)); + return Ok(Vec::new()); + }; + let mut items = Vec::new(); + let (_, err) = self.xml_options().parse_streaming_partial(&bytes, &["si"], |si| { + items.push(StringItem::read(&si)); + Ok::<(), craft_xml::Error>(()) + }); + if let Some(e) = err { + self.warn(format!("shared strings are damaged ({e}); kept {} strings", items.len())); + } + let dropped: usize = items.iter().map(|s| s.dropped_phonetic_runs).sum(); + if dropped > 0 { + self.warn(format!("shared strings: {dropped} phonetic runs were dropped (bad sb/eb or no reading)")); + } + Ok(items) + } + + /// The styles part, `None` when the workbook has none (or is binary: its styles are not read). + pub fn styles(&mut self) -> Result, Error> { + if self.binary.is_some() { + return Ok(None); + } + let rels = self.workbook_rels()?; + let Some(rel) = rels.first("styles").filter(|r| !r.external) else { + self.warn("no styles part"); + return Ok(None); + }; + Ok(self.optional_xml(&rel.target.clone())?.map(Styles::read)) + } + + /// Reads a worksheet, handing each row to `each` in file order; an error from `each` stops the + /// read and is returned. A damaged worksheet gives the rows before the damage and says so + /// in [`SheetPart::damaged`] and in the warnings. + pub fn sheet(&mut self, entry: &SheetEntry, mut each: impl FnMut(Row) -> Result<(), Error>) -> Result { + let part = entry.part.clone().ok_or_else(|| Error::Invalid(format!("sheet \"{}\" has no part", entry.name)))?; + if self.binary.is_some() { + if let Some(types) = &self.binary { + xlsb::check_type(types, &part, xlsb::WORKSHEET)?; + } + let strings = match self.binary_strings { + Some(n) => n, + None => self.shared_strings()?.len(), + }; + let bytes = self.required(&part)?; + let mut sheet = xlsb::worksheet(&bytes, strings, &self.limits, &self.budget, &mut self.warnings, &mut each)?; + sheet.part = part; + return Ok(sheet); + } + let bytes = self.required(&part)?; + let rels = self.opc.rels(&part)?; + let mut rows = RowReader::default(); + let (root, err) = self.xml_options().parse_streaming_partial(&bytes, &["row"], |e| match rows.row(&e) { + Some(row) => each(row).map_err(Stop::Caller), + None => Ok(()), + }); + let damaged = match err { + Some(Stop::Caller(e)) => return Err(e), + Some(Stop::Xml(e)) => { + self.warn(format!("sheet \"{}\" is damaged ({e}); kept {} rows", entry.name, rows.rows)); + Some(e.to_string()) + } + None => None, + }; + if rows.dropped > 0 { + self.warn(format!("sheet \"{}\": {} cells or rows outside the 1,048,576 × 16,384 grid were dropped", entry.name, rows.dropped)); + } + let mut sheet = + SheetPart { part, root: root.unwrap_or_default(), rels, rows: rows.rows, dropped: rows.dropped, damaged, ..SheetPart::default() }; + sheet.lift(); + Ok(sheet) + } + + fn required(&mut self, part: &str) -> Result, Error> { + self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}"))) + } +} + +/// Why a streamed worksheet stopped: the XML, or the caller. +enum Stop { + Xml(craft_xml::Error), + Caller(Error), +} + +impl From for Stop { + fn from(e: craft_xml::Error) -> Self { + Stop::Xml(e) + } +} diff --git a/crates/xlsx/src/sheet.rs b/crates/xlsx/src/sheet.rs new file mode 100644 index 000000000..5375436e8 --- /dev/null +++ b/crates/xlsx/src/sheet.rs @@ -0,0 +1,387 @@ +//! A worksheet part (`CT_Worksheet`, ECMA-376 Part 1, 18.3): rows and cells as a stream, the +//! rest of the part as a tree. + +use std::sync::Arc; + +use craft_opc::Rels; +use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value}; +use craft_xml::El; + +use crate::strings::{StringItem, decode_xstring}; + +/// What a cell's `` holds, by its type (`t`, 18.18.11). +#[derive(Clone, Debug, Default, PartialEq)] +pub enum CellValue { + #[default] + Empty, + Number(f64), + /// `t="s"`: an index into the shared strings. + Shared(u32), + /// `t="str"` (a formula's text result), `_xHHHH_` escapes decoded. + Text(Arc), + /// `t="inlineStr"`: the `` item, or the `` text when there is no ``. + Inline(StringItem), + Bool(bool), + /// `t="e"`. An error text no [`CellError`] has reads as `#VALUE!`. + Error(CellError), + /// `t="d"`: an ISO 8601 date, time or both, as written. See [`iso_date_serial`]. + Date(String), + /// A `` that does not read as its type says (a number that is not one, a shared-string + /// index that is not a number). Kept for the caller to report. + Invalid(String), +} + +impl CellValue { + /// The value a cell shows: shared strings looked up, dates as serial numbers in the + /// workbook's date system. A missing shared string, an unreadable date or an invalid value + /// is empty. + pub fn resolve(&self, strings: &[StringItem], date_1904: bool) -> Value { + match self { + CellValue::Empty | CellValue::Invalid(_) => Value::Empty, + CellValue::Number(n) => Value::number(*n), + CellValue::Shared(i) => strings.get(*i as usize).map_or(Value::Empty, |s| Value::Text(s.text.clone())), + CellValue::Text(t) => Value::Text(t.clone()), + CellValue::Inline(s) => Value::Text(s.text.clone()), + CellValue::Bool(b) => Value::Bool(*b), + CellValue::Error(e) => Value::Error(*e), + CellValue::Date(d) => iso_date_serial(d, date_1904).map_or(Value::Empty, Value::number), + } + } +} + +/// ``'s `t` (18.18.6). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FormulaKind { + #[default] + Normal, + Shared, + Array, + /// A what-if data table: `r1`, `r2` and its flags are in [`CellFormula::attrs`]. + DataTable, +} + +/// A cell's ``. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CellFormula { + pub kind: FormulaKind, + /// The formula as the file spells it (`_xlfn.` prefixes and all), trimmed. Empty for a + /// cell that shares the formula of its group's first cell. + pub text: String, + /// `si`: the shared formula group. + pub shared_index: Option, + /// `ref`: the range of an array formula or of a shared formula group. + pub range: Option, + /// Every attribute as written. + pub attrs: Vec<(String, String)>, +} + +/// One ``. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Cell { + pub at: CellRef, + /// `s`: an index into [`crate::Styles::cell_xfs`], 0 when absent. + pub style: u32, + pub value: CellValue, + pub formula: Option, + /// `vm`: value metadata (a rich value such as a picture in the cell), 1-based. + pub value_metadata: Option, + /// `cm`: cell metadata (a dynamic array formula), 1-based. + pub cell_metadata: Option, + /// `ph`: show the phonetic reading. + pub phonetic: bool, +} + +/// One `` and its cells. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Row { + /// Zero-based. + pub index: u32, + /// `ht`, points. + pub height: Option, + pub custom_height: bool, + pub hidden: bool, + pub outline_level: u8, + pub collapsed: bool, + /// `s` when `customFormat` is set: the format of the row's empty cells. + pub style: Option, + pub cells: Vec, +} + +/// One ``: columns `first..=last` (zero-based). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Column { + pub first: u32, + pub last: u32, + /// Characters of the default font's maximum digit width, padding included. + pub width: Option, + pub custom_width: bool, + pub best_fit: bool, + pub hidden: bool, + pub outline_level: u8, + pub collapsed: bool, + pub style: Option, +} + +/// A worksheet after its rows were handed out: the rest of the part and what was lifted out of it. +#[derive(Clone, Debug, Default)] +pub struct SheetPart { + pub part: String, + /// The worksheet element without its rows: properties, views, columns, merged cells, + /// conditional formats, validation, hyperlinks, page setup, extensions. Empty for a binary + /// worksheet. + pub root: El, + pub rels: Rels, + pub columns: Vec, + pub merges: Vec, + /// Rows read. + pub rows: u64, + /// Rows and cells dropped because their position was outside the grid or unreadable. + pub dropped: u64, + /// Why reading stopped early, when the part is damaged; the rows before the damage were + /// handed out. + pub damaged: Option, +} + +impl SheetPart { + pub(crate) fn lift(&mut self) { + if let Some(cols) = self.root.child("cols") { + for c in cols.children("col") { + let (Some(min), Some(max)) = (c.attr_u32("min"), c.attr_u32("max")) else { continue }; + if min == 0 || min > MAX_COLS || max < min { + continue; + } + self.columns.push(Column { + first: min - 1, + last: max.min(MAX_COLS) - 1, + width: c.attr_f64("width"), + custom_width: c.flag("customWidth", false), + best_fit: c.flag("bestFit", false), + hidden: c.flag("hidden", false), + outline_level: c.attr_u32("outlineLevel").unwrap_or(0).min(7) as u8, + collapsed: c.flag("collapsed", false), + style: c.attr_u32("style"), + }); + } + } + if let Some(m) = self.root.child("mergeCells") { + self.merges.extend(m.children("mergeCell").filter_map(|m| m.attr("ref").and_then(Range::parse)).filter(|r| !r.is_single())); + } + } +} + +/// Reads rows one at a time, remembering where the last one ended. +#[derive(Default)] +pub(crate) struct RowReader { + next_row: u32, + pub(crate) rows: u64, + pub(crate) dropped: u64, +} + +impl RowReader { + /// `None` for a row outside the grid (counted in `dropped`). + pub(crate) fn row(&mut self, e: &El) -> Option { + self.rows += 1; + let index = match e.attr_u32("r") { + Some(r) if (1..=MAX_ROWS).contains(&r) => r - 1, + Some(_) => { + self.dropped += 1; + return None; + } + None if self.next_row < MAX_ROWS => self.next_row, + None => { + self.dropped += 1; + return None; + } + }; + self.next_row = index + 1; + let mut row = Row { + index, + height: e.attr_f64("ht"), + custom_height: e.flag("customHeight", false), + hidden: e.flag("hidden", false), + outline_level: e.attr_u32("outlineLevel").unwrap_or(0).min(7) as u8, + collapsed: e.flag("collapsed", false), + style: if e.flag("customFormat", false) { e.attr_u32("s") } else { None }, + cells: Vec::new(), + }; + let mut next_col = 0u32; + for c in e.children("c") { + let at = match c.attr("r") { + Some(a) => match CellRef::parse(a.trim()) { + Some(p) => p, + None => { + self.dropped += 1; + continue; + } + }, + None if next_col < MAX_COLS => CellRef::new(index, next_col), + None => { + self.dropped += 1; + continue; + } + }; + next_col = at.col + 1; + row.cells.push(cell(c, at)); + } + Some(row) + } +} + +fn cell(c: &El, at: CellRef) -> Cell { + let v = c.child("v").map(|v| v.text()); + let v = v.as_deref(); + let value = match c.attr("t").unwrap_or("n") { + "s" => match v.map(str::trim) { + Some(t) => t.parse().map_or_else(|_| CellValue::Invalid(t.to_string()), CellValue::Shared), + None => CellValue::Empty, + }, + "str" => v.map_or(CellValue::Empty, |v| CellValue::Text(decode_xstring(v).into())), + "inlineStr" => match c.child("is") { + Some(is) => CellValue::Inline(StringItem::read(is)), + None => v.map_or(CellValue::Empty, |v| CellValue::Text(decode_xstring(v).into())), + }, + "b" => v.map_or(CellValue::Empty, |v| CellValue::Bool(matches!(v.trim(), "1" | "true" | "TRUE"))), + "e" => v.map_or(CellValue::Empty, |v| CellValue::Error(CellError::parse(v.trim()).unwrap_or(CellError::Value))), + "d" => match v.map(str::trim).filter(|v| !v.is_empty()) { + Some(d) => CellValue::Date(d.to_string()), + None => CellValue::Empty, + }, + _ => match v.map(str::trim).filter(|v| !v.is_empty()) { + Some(t) => match t.parse::() { + Ok(n) if n.is_finite() => CellValue::Number(if n == 0.0 { 0.0 } else { n }), + _ => CellValue::Invalid(t.to_string()), + }, + None => CellValue::Empty, + }, + }; + let formula = c.child("f").map(|f| CellFormula { + kind: match f.attr("t") { + Some("shared") => FormulaKind::Shared, + Some("array") => FormulaKind::Array, + Some("dataTable") => FormulaKind::DataTable, + _ => FormulaKind::Normal, + }, + text: f.text().trim().to_string(), + shared_index: f.attr_u32("si"), + range: f.attr("ref").and_then(Range::parse), + attrs: f.attrs.clone(), + }); + Cell { + at, + style: c.attr_u32("s").unwrap_or(0), + value, + formula, + value_metadata: c.attr_u32("vm"), + cell_metadata: c.attr_u32("cm"), + phonetic: c.flag("ph", false), + } +} + +/// Days from 1970-01-01 to a proleptic Gregorian date (the civil-from-days inverse). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// The serial number of a `t="d"` value: `2024-02-29`, `2024-02-29T13:45:30`, `13:45` (a time +/// alone), a trailing `Z` or `+hh:mm` ignored. Years 1900 (1904 in the 1904 system) to 9999; +/// a day past the month's end counts on into the next month, as Excel's `DATE` does. In the +/// 1900 system, dates before 1 March 1900 are one lower than the day count, for the 29 February +/// 1900 that Excel counts. +pub fn iso_date_serial(s: &str, date_1904: bool) -> Option { + let s = s.trim().trim_end_matches('Z'); + let (date, time) = match s.split_once('T') { + Some((d, t)) => (d, Some(t)), + None if s.contains(':') => ("", Some(s)), + None => (s, None), + }; + let mut serial = 0.0; + if !date.is_empty() { + let mut it = date.split('-'); + let y: i64 = it.next()?.parse().ok()?; + let m: i64 = it.next()?.parse().ok()?; + let d: i64 = it.next()?.parse().ok()?; + if it.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) || y > 9999 || y < if date_1904 { 1904 } else { 1900 } { + return None; + } + let first = days_from_civil(y, m, 1); + let first = + if date_1904 { first - days_from_civil(1904, 1, 1) } else { first - days_from_civil(1899, 12, 30) - i64::from(y == 1900 && m <= 2) }; + let n = first + d - 1; + let max = days_from_civil(9999, 12, 31) - if date_1904 { days_from_civil(1904, 1, 1) } else { days_from_civil(1899, 12, 30) }; + if !(0..=max).contains(&n) { + return None; + } + serial = n as f64; + } + if let Some(t) = time { + let t = t.split('+').next().unwrap_or(t); + let mut it = t.split(':'); + let h: f64 = it.next()?.parse().ok()?; + let mi: f64 = it.next().unwrap_or("0").parse().ok()?; + let se: f64 = it.next().unwrap_or("0").parse().ok()?; + if !(0.0..24.0).contains(&h) || !(0.0..60.0).contains(&mi) || !(0.0..61.0).contains(&se) { + return None; + } + serial += (h * 3600.0 + mi * 60.0 + se) / 86400.0; + } + Some(serial) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_dates() { + assert_eq!(iso_date_serial("1900-01-01", false), Some(1.0)); + assert_eq!(iso_date_serial("1900-02-28", false), Some(59.0)); + assert_eq!(iso_date_serial("1900-03-01", false), Some(61.0)); + assert_eq!(iso_date_serial("2024-02-29T12:00:00Z", false), Some(45351.5)); + assert_eq!(iso_date_serial("1904-01-02", true), Some(1.0)); + assert_eq!(iso_date_serial("06:00", false), Some(0.25)); + assert_eq!(iso_date_serial("2023-02-31", false), iso_date_serial("2023-03-03", false)); + assert_eq!(iso_date_serial("9999-12-31", false), Some(2_958_465.0)); + for bad in ["1899-12-31", "10000-01-01", "2024-13-01", "2024-01-00", "x", "2024-01-01T25:00", "2024-01-01-01"] { + assert_eq!(iso_date_serial(bad, false), None, "{bad}"); + } + assert_eq!(iso_date_serial("1903-12-31", true), None); + } + + #[test] + fn rows_and_cells() { + let xml = r#"7A1*24 + 1#SPILL!#WHAT1in_x000A_lineabca_x0009_2024-01-01"#; + let mut r = RowReader::default(); + let row = r.row(&craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default()).unwrap_or_default(); + assert_eq!((row.index, row.height, row.custom_height, row.style, row.outline_level), (2, Some(20.0), true, Some(4), 7)); + assert_eq!(r.dropped, 1); + let at: Vec = row.cells.iter().map(|c| c.at.a1()).collect(); + assert_eq!(at, ["B3", "C3", "E3", "F3", "G3", "H3", "I3", "J3", "K3", "L3"]); + let values: Vec<&CellValue> = row.cells.iter().map(|c| &c.value).collect(); + assert_eq!(values[0], &CellValue::Shared(7)); + assert_eq!(values[2], &CellValue::Error(CellError::Spill)); + assert_eq!(values[3], &CellValue::Error(CellError::Value)); + assert_eq!(values[4], &CellValue::Bool(true)); + assert!(matches!(values[5], CellValue::Inline(s) if &*s.text == "in\nline")); + assert_eq!(values[6], &CellValue::Invalid("abc".into())); + assert_eq!(values[7], &CellValue::Text("a\t".into())); + assert_eq!(values[8].resolve(&[], false), Value::Number(45292.0)); + let f = row.cells[1].formula.clone().unwrap_or_default(); + assert_eq!((f.kind, f.text.as_str(), f.shared_index, f.range.map(|r| r.a1())), (FormulaKind::Shared, "A1*2", Some(0), Some("C3:C9".into()))); + let last = &row.cells[9]; + assert_eq!((last.value_metadata, last.cell_metadata, last.phonetic, last.style), (Some(1), Some(2), true, 0)); + assert_eq!(row.cells[0].value.resolve(&[], false), Value::Empty); + // Without `r`, the next row follows the last one. + let next = r.row(&craft_xml::El::new("row")).map(|r| r.index); + assert_eq!(next, Some(3)); + assert!(r.row(&craft_xml::El::new("row").with_attr("r", "1048577")).is_none()); + assert!(r.row(&craft_xml::El::new("row").with_attr("r", "0")).is_none()); + assert_eq!((r.rows, r.dropped), (4, 3)); + } +} diff --git a/crates/xlsx/src/strings.rs b/crates/xlsx/src/strings.rs new file mode 100644 index 000000000..641975032 --- /dev/null +++ b/crates/xlsx/src/strings.rs @@ -0,0 +1,196 @@ +//! String items (`CT_Rst`, ECMA-376 Part 1, 18.4): shared strings, inline strings and comment +//! text, with the `_xHHHH_` escapes of `ST_Xstring` (22.9.2.19). + +use std::sync::Arc; + +use craft_xml::El; + +use crate::styles::Font; + +/// A formatted run of a string item (``). +#[derive(Clone, Debug, PartialEq)] +pub struct TextRun { + pub text: String, + /// ``, `None` when the run has none. + pub font: Option, +} + +/// A phonetic reading over part of a string's text (``, 18.4.6). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhoneticRun { + /// Offsets into the base text in UTF-16 code units, `start < end`, both within the text. + pub start: u32, + pub end: u32, + pub text: String, +} + +/// `` (18.4.7). `kind` and `alignment` are kept only when they are values the +/// schema defines (`ST_PhoneticType`, `ST_PhoneticAlignment`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PhoneticProps { + pub font_id: u32, + pub kind: Option, + pub alignment: Option, +} + +/// One string item: a shared string (``), an inline string (``) or comment text. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StringItem { + /// The whole text, runs joined, phonetic readings left out. + pub text: Arc, + /// The formatted runs, empty for a plain string. + pub runs: Vec, + pub phonetic_runs: Vec, + pub phonetic: Option, + /// `` elements that were dropped: no offsets, `sb >= eb`, past the end of the text, or + /// no reading. + pub dropped_phonetic_runs: usize, +} + +impl StringItem { + pub(crate) fn read(e: &El) -> StringItem { + let text = rich_text(e); + let runs = e + .children("r") + .map(|r| TextRun { text: decode_xstring(&r.children("t").map(|t| t.text()).collect::()), font: r.child("rPr").map(Font::read) }) + .collect(); + // The length in UTF-16 code units: the widest reading of "the total length of the base + // text", so a run is only dropped when it cannot refer to the text at all. + let len = u32::try_from(text.encode_utf16().count()).unwrap_or(u32::MAX); + let mut phonetic_runs = Vec::new(); + let mut seen = 0usize; + for r in e.children("rPh") { + seen += 1; + let (Some(sb), Some(eb)) = (r.attr_u32("sb"), r.attr_u32("eb")) else { continue }; + let (start, end) = (sb.min(len), eb.min(len)); + let reading = r.child("t").map(|t| decode_xstring(&t.text())).unwrap_or_default(); + if start < end && !reading.is_empty() { + phonetic_runs.push(PhoneticRun { start, end, text: reading }); + } + } + const KINDS: &[&str] = &["halfwidthKatakana", "fullwidthKatakana", "Hiragana", "noConversion"]; + const ALIGNMENTS: &[&str] = &["noControl", "left", "center", "distributed"]; + let phonetic = e.child("phoneticPr").map(|p| PhoneticProps { + font_id: p.attr_u32("fontId").unwrap_or(0), + kind: p.attr("type").map(str::trim).filter(|v| KINDS.contains(v)).map(str::to_string), + alignment: p.attr("alignment").map(str::trim).filter(|v| ALIGNMENTS.contains(v)).map(str::to_string), + }); + StringItem { dropped_phonetic_runs: seen - phonetic_runs.len(), text: Arc::from(text), runs, phonetic_runs, phonetic } + } +} + +/// The text of a string item: `` and the `` of each ``, in order, with `_xHHHH_` +/// escapes decoded. Phonetic runs are not part of the text. +pub fn rich_text(e: &El) -> String { + let mut s = String::new(); + for c in e.elements() { + match c.name.as_str() { + "t" => s.push_str(&c.text()), + "r" => c.children("t").for_each(|t| s.push_str(&t.text())), + _ => {} + } + } + decode_xstring(&s) +} + +fn is_escape(chars: &[char], i: usize) -> bool { + chars.get(i) == Some(&'_') + && chars.get(i + 1) == Some(&'x') + && chars.get(i + 6) == Some(&'_') + && (i + 2..i + 6).all(|k| chars.get(k).is_some_and(char::is_ascii_hexdigit)) +} + +fn escape_unit(chars: &[char], i: usize) -> Option { + let hex: String = chars.get(i + 2..i + 6)?.iter().collect(); + u32::from_str_radix(&hex, 16).ok() +} + +/// Text for a `` or ``: characters XML cannot carry and control characters other than tab +/// and line feed become `_xHHHH_`, and a literal `_xHHHH_` is protected with `_x005F_`. The +/// result still needs XML escaping. +pub fn encode_xstring(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut o = String::with_capacity(s.len()); + for (i, &c) in chars.iter().enumerate() { + if is_escape(&chars, i) { + o.push_str("_x005F_"); + } else if !craft_xml::is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n')) { + o.push_str(&format!("_x{:04X}_", c as u32 & 0xFFFF)); + } else { + o.push(c); + } + } + o +} + +/// The inverse of [`encode_xstring`]. Two escapes that form a UTF-16 surrogate pair are one +/// character; an escape that is no character on its own stays as written. +pub fn decode_xstring(s: &str) -> String { + if !s.contains("_x") { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut o = String::with_capacity(s.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if is_escape(&chars, i) { + let unit = escape_unit(&chars, i); + if let Some(ch) = unit.and_then(char::from_u32) { + o.push(ch); + i += 7; + continue; + } + let pair = unit.filter(|h| (0xD800..=0xDBFF).contains(h) && is_escape(&chars, i + 7)).and_then(|high| { + let low = escape_unit(&chars, i + 7).filter(|l| (0xDC00..=0xDFFF).contains(l))?; + char::from_u32(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)) + }); + if let Some(ch) = pair { + o.push(ch); + i += 14; + continue; + } + } + o.push(c); + i += 1; + } + o +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xstring() { + assert_eq!(decode_xstring("a_x000D_b"), "a\rb"); + assert_eq!(encode_xstring("a\rb"), "a_x000D_b"); + assert_eq!(encode_xstring("_x000D_"), "_x005F_x000D_"); + assert_eq!(decode_xstring("_x005F_x000D_"), "_x000D_"); + assert_eq!(decode_xstring("_x12"), "_x12"); + assert_eq!(decode_xstring("_xD83E__xDD80_"), "\u{1F980}"); + // A lone surrogate is no character: kept as written. + assert_eq!(decode_xstring("_xD83E_!"), "_xD83E_!"); + for s in ["plain", "tab\tline\n", "\u{1}\u{1F}", "_x0041_ _xZZZZ_ é"] { + assert_eq!(decode_xstring(&encode_xstring(s)), s); + } + } + + fn item(xml: &str) -> StringItem { + StringItem::read(&craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default()) + } + + #[test] + fn runs_and_phonetics() { + let si = item( + r#"東京 トウキョウxx"#, + ); + assert_eq!(&*si.text, "東京 "); + assert_eq!(si.runs.len(), 2); + assert_eq!(si.runs[1].font.as_ref().map(|f| (f.bold, f.name.as_deref(), f.size)), Some((Some(true), Some("Arial"), Some(9.0)))); + assert_eq!(si.phonetic_runs, [PhoneticRun { start: 0, end: 2, text: "トウキョウ".into() }]); + assert_eq!(si.dropped_phonetic_runs, 3); + assert_eq!(si.phonetic, Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None })); + let plain = item("a_x000A_b"); + assert_eq!((&*plain.text, plain.runs.len()), ("a\nb", 0)); + } +} diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs new file mode 100644 index 000000000..ceb9547f0 --- /dev/null +++ b/crates/xlsx/src/styles.rs @@ -0,0 +1,555 @@ +//! The styles part (`xl/styles.xml`, ECMA-376 Part 1, 18.8) as the file has it: tables that +//! cells refer to by index. Attributes a file may leave out are `Option`s, because a +//! differential format (``) states only what it changes. + +use craft_sheet::{BorderStyle, DEFAULT_PALETTE, HAlign, Pattern, Script, Underline, VAlign, builtin_format}; +use craft_xml::El; + +/// Characters of a font name kept (Excel allows 31; damaged files have far more). +const MAX_FONT_NAME_CHARS: usize = 255; + +/// Where a colour comes from (`CT_Color`, 18.8.9). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorSource { + /// `auto="1"`, or no attribute that names a colour. + #[default] + Auto, + /// `rgb`: `0xAARRGGBB`. Six hex digits read as opaque. + Rgb(u32), + /// `theme`: an index into the theme's colour scheme. + Theme(u32), + /// `indexed`: an index into [`Styles::palette`]. + Indexed(u32), +} + +/// A colour with its tint (`-1.0` darkest to `1.0` lightest, 0 for none). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Color { + pub source: ColorSource, + pub tint: f64, +} + +impl Color { + pub fn read(e: &El) -> Color { + let source = if e.flag("auto", false) { + ColorSource::Auto + } else if let Some(rgb) = e.attr("rgb") { + parse_argb(rgb).map_or(ColorSource::Auto, ColorSource::Rgb) + } else if let Some(t) = e.attr_u32("theme") { + ColorSource::Theme(t) + } else if let Some(i) = e.attr_u32("indexed") { + ColorSource::Indexed(i) + } else { + ColorSource::Auto + }; + Color { source, tint: e.attr_f64("tint").map_or(0.0, |t| t.clamp(-1.0, 1.0)) } + } + + /// `0xRRGGBB` for an RGB colour, or an indexed one in `palette`; `None` for automatic and + /// theme colours. The tint is not applied. + pub fn rgb(&self, palette: &[u32]) -> Option { + match self.source { + ColorSource::Rgb(v) => Some(v & 0xFF_FFFF), + ColorSource::Indexed(i) => palette.get(i as usize).copied(), + ColorSource::Auto | ColorSource::Theme(_) => None, + } + } +} + +fn parse_argb(s: &str) -> Option { + let h = s.trim().trim_start_matches('#'); + let v = u32::from_str_radix(h, 16).ok().filter(|_| h.bytes().all(|b| b.is_ascii_hexdigit()))?; + match h.len() { + 8 => Some(v), + 6 => Some(0xFF00_0000 | v), + _ => None, + } +} + +/// `scheme` of a font: which theme font it follows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontScheme { + None, + Major, + Minor, +} + +/// A font (``, or a run's ``, 18.8.22 and 18.4.7). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Font { + pub name: Option, + /// Points. + pub size: Option, + pub bold: Option, + pub italic: Option, + pub strike: Option, + pub outline: Option, + pub shadow: Option, + pub condense: Option, + pub extend: Option, + pub underline: Option, + pub vert_align: Option