diff --git a/Cargo.lock b/Cargo.lock index 346ca29be..54af0f168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1430,6 +1430,16 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-ods" +version = "0.1.0" +dependencies = [ + "craft-odf", + "craft-sheet", + "craft-xml", + "thiserror 2.0.21", +] + [[package]] name = "craft-offcrypto" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e37fcb7f1..fc49aaaa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ craft-media-testkit = { path = "crates/media-testkit" } craft-midi = { path = "crates/midi" } craft-mime = { path = "crates/mime" } craft-odf = { path = "crates/odf" } +craft-ods = { path = "crates/ods" } craft-prc = { path = "crates/prc" } craft-lottie = { path = "crates/lottie" } craft-menu = { path = "crates/menu" } diff --git a/README.md b/README.md index 08c98e556..56b77e1dc 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Error { + Error::Xml(format!("ODS: {e}")) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tag { + Document, + Body, + Spreadsheet, + Table, + Rows, + Row, + Cell, + Covered, + Paragraph, + Inline, + Space, + Tab, + Break, + Other, +} + +fn tag(name: &str) -> Tag { + match name { + "office:document-content" => Tag::Document, + "office:body" => Tag::Body, + "office:spreadsheet" => Tag::Spreadsheet, + "table:table" => Tag::Table, + "table:table-row-group" | "table:table-header-rows" | "table:table-rows" => Tag::Rows, + "table:table-row" => Tag::Row, + "table:table-cell" => Tag::Cell, + "table:covered-table-cell" => Tag::Covered, + "text:p" | "text:h" => Tag::Paragraph, + "text:span" | "text:a" | "text:meta" | "text:meta-field" | "text:ruby" | "text:ruby-base" => Tag::Inline, + "text:s" => Tag::Space, + "text:tab" => Tag::Tab, + "text:line-break" => Tag::Break, + _ => Tag::Other, + } +} + +struct SheetDraft { + sheet: Sheet, + depth: usize, + row: u32, + keep: bool, +} + +struct RowDraft { + depth: usize, + repeat: u32, + col: u32, + cells: Vec, +} + +struct RepeatedCell { + col: u32, + repeat: u32, + width: u32, + height: u32, + cell: Cell, +} + +struct CellDraft { + depth: usize, + repeat: u32, + width: u32, + height: u32, + cell: Cell, + paragraph: Option, + paragraph_started: bool, + pending_space: bool, +} + +struct Import<'o> { + options: &'o Options, + wb: Workbook, + stack: Vec, + sheet: Option, + row: Option, + cell: Option, + cells: u64, + merges: u64, + text: u64, + spreadsheet_seen: bool, +} + +/// Reads the tables of a `content.xml` (the XML alone, for a caller with its own package). +pub fn read_content(bytes: &[u8], options: &Options) -> Result { + let canonical = Canonical::new(NAMESPACES); + let xml = XmlOptions { doctype: Doctype::Reject, ..XmlOptions::canonical(&canonical).strict() }; + let text = craft_xml::to_utf8(bytes); + let mut events = xml.events(&text); + let mut import = Import { + options, + wb: Workbook::default(), + stack: vec![], + sheet: None, + row: None, + cell: None, + cells: 0, + merges: 0, + text: 0, + spreadsheet_seen: false, + }; + let mut root_seen = false; + while let Some(event) = events.next().map_err(xml_error)? { + match event { + Event::Start { name, attrs } => { + if import.stack.is_empty() { + if root_seen || tag(&name) != Tag::Document { + return Err(xml_error("expected one office:document-content root")); + } + root_seen = true; + } + import.start(&name, &attrs)?; + } + Event::End { .. } => import.end()?, + Event::Text(t) | Event::CData(t) => import.append(&t)?, + Event::Pi { .. } | Event::Comment(_) => {} + } + } + if !root_seen || !import.stack.is_empty() || !import.spreadsheet_seen { + return Err(xml_error("incomplete ODS document")); + } + if import.wb.sheets.is_empty() { + return Err(Error::Invalid("the spreadsheet has no tables".into())); + } + Ok(import.wb) +} + +impl Import<'_> { + fn attr(&self, attrs: &[(String, String)], name: &str) -> Result, Error> { + match attrs.iter().find(|(k, _)| k == name) { + Some((_, v)) if v.len() > self.options.max_cell_text => { + Err(Error::TooLarge(format!("an attribute is longer than {} bytes", self.options.max_cell_text))) + } + Some((_, v)) => Ok(Some(v.clone())), + None => Ok(None), + } + } + + /// A repeat or span count: 1 when absent, refused when 0, unreadable or over `limit`. + fn count(&self, attrs: &[(String, String)], name: &str, limit: u32) -> Result { + let n = match self.attr(attrs, name)? { + Some(s) => s.parse::().map_err(|_| Error::Invalid("invalid ODS repeat or span count".into()))?, + None => 1, + }; + if n == 0 || n > limit { + return Err(Error::TooLarge("ODS repeat or span exceeds worksheet bounds".into())); + } + Ok(n) + } + + fn start(&mut self, name: &str, attrs: &[(String, String)]) -> Result<(), Error> { + let t = tag(name); + let depth = self.stack.len(); + match t { + Tag::Spreadsheet if self.stack == [Tag::Document, Tag::Body] => { + if self.spreadsheet_seen { + return Err(xml_error("multiple spreadsheets in one document")); + } + self.spreadsheet_seen = true; + } + Tag::Table if self.stack == [Tag::Document, Tag::Body, Tag::Spreadsheet] => { + if self.wb.sheets.len() >= MAX_SHEETS { + return Err(Error::TooLarge(format!("more than {MAX_SHEETS} sheets"))); + } + let keep = self.options.only_sheet.is_none_or(|i| i == self.wb.sheets.len()); + let name = self.attr(attrs, "table:name")?.unwrap_or_default(); + self.sheet = Some(SheetDraft { sheet: Sheet { name, ..Sheet::default() }, depth, row: 0, keep }); + } + Tag::Row + if self + .sheet + .as_ref() + .is_some_and(|s| s.keep && self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => + { + self.row = Some(RowDraft { depth, repeat: self.count(attrs, "table:number-rows-repeated", u32::MAX)?, col: 0, cells: vec![] }); + } + Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => { + let value_type = if self.attr(attrs, "calcext:value-type")?.as_deref() == Some("error") { + Some(ValueType::Error) + } else { + self.attr(attrs, "office:value-type")?.as_deref().and_then(ValueType::parse) + }; + let key = match value_type { + Some(ValueType::Boolean) => "office:boolean-value", + Some(ValueType::Date) => "office:date-value", + Some(ValueType::Time) => "office:time-value", + Some(ValueType::String) => "office:string-value", + _ => "office:value", + }; + self.cell = Some(CellDraft { + depth, + repeat: self.count(attrs, "table:number-columns-repeated", u32::MAX)?, + width: self.count(attrs, "table:number-columns-spanned", MAX_COLS)?, + height: self.count(attrs, "table:number-rows-spanned", MAX_ROWS)?, + cell: Cell { + value_type, + raw: self.attr(attrs, key)?, + currency: self.attr(attrs, "office:currency")?, + formula: self.attr(attrs, "table:formula")?, + style: self.attr(attrs, "table:style-name")?, + covered: t == Tag::Covered, + ..Cell::default() + }, + paragraph: None, + paragraph_started: false, + pending_space: false, + }); + } + Tag::Paragraph if self.cell.as_ref().is_some_and(|c| depth == c.depth + 1) => { + let max = self.options.max_cell_text; + if let Some(cell) = self.cell.as_mut() { + if cell.cell.paragraphs > 0 { + push_text(&mut cell.cell.text, "\n", max)?; + } + cell.paragraph = Some(depth); + cell.cell.paragraphs += 1; + cell.paragraph_started = false; + cell.pending_space = false; + } + } + Tag::Space | Tag::Tab | Tag::Break if self.captures_text() => { + let max = self.options.max_cell_text; + let count = match t { + Tag::Space => self.attr(attrs, "text:c")?.map(|s| s.parse::()).transpose().map_err(xml_error)?.unwrap_or(1), + _ => 1, + }; + let unit = match t { + Tag::Space => " ", + Tag::Tab => "\t", + _ => "\n", + }; + if let Some(cell) = self.cell.as_mut() { + if cell.pending_space { + push_text(&mut cell.cell.text, " ", max)?; + cell.pending_space = false; + } + cell.paragraph_started = true; + if cell.cell.text.len().saturating_add(count) > max { + return Err(Error::TooLarge(format!("a cell's text is longer than {max} bytes"))); + } + cell.cell.text.push_str(&unit.repeat(count)); + } + } + _ => {} + } + self.stack.push(t); + Ok(()) + } + + /// Inside a cell's paragraph, through inline elements only. + fn captures_text(&self) -> bool { + self.cell + .as_ref() + .and_then(|c| c.paragraph) + .is_some_and(|depth| self.stack.get(depth..).is_some_and(|p| p.iter().all(|t| matches!(t, Tag::Paragraph | Tag::Inline)))) + } + + /// Text, with ODF's white-space rule: runs collapse to one space, none at a paragraph's start. + fn append(&mut self, text: &str) -> Result<(), Error> { + if !self.captures_text() { + return Ok(()); + } + let max = self.options.max_cell_text; + let Some(cell) = self.cell.as_mut() else { return Ok(()) }; + for ch in text.chars() { + if matches!(ch, ' ' | '\t' | '\n' | '\r') { + cell.pending_space = cell.paragraph_started; + } else { + if cell.pending_space { + push_text(&mut cell.cell.text, " ", max)?; + cell.pending_space = false; + } + push_text(&mut cell.cell.text, ch.encode_utf8(&mut [0; 4]), max)?; + cell.paragraph_started = true; + } + } + Ok(()) + } + + fn end(&mut self) -> Result<(), Error> { + self.stack.pop().ok_or_else(|| xml_error("unexpected closing element"))?; + let depth = self.stack.len(); + if let Some(cell) = self.cell.as_mut() + && cell.paragraph == Some(depth) + { + cell.paragraph = None; + } + if self.cell.as_ref().is_some_and(|c| c.depth == depth) { + self.finish_cell()?; + } + if self.row.as_ref().is_some_and(|r| r.depth == depth) { + self.finish_row()?; + } + if self.sheet.as_ref().is_some_and(|s| s.depth == depth) + && let Some(sheet) = self.sheet.take() + { + self.wb.sheets.push(sheet.sheet); + } + Ok(()) + } + + fn charge_text(&mut self, bytes: u64) -> Result<(), Error> { + self.text = self.text.saturating_add(bytes); + if self.text > self.options.max_text { + return Err(Error::TooLarge(format!("more than {} MiB of text", self.options.max_text >> 20))); + } + Ok(()) + } + + fn finish_cell(&mut self) -> Result<(), Error> { + let Some(draft) = self.cell.take() else { return Ok(()) }; + let (width, height) = if draft.cell.covered { (1, 1) } else { (draft.width, draft.height) }; + let Some(row) = self.row.as_mut() else { return Ok(()) }; + let kept = !draft.cell.is_blank() || width > 1 || height > 1; + let end = match row.col.checked_add(draft.repeat).filter(|n| *n <= MAX_COLS) { + Some(end) => end, + None if !kept => MAX_COLS, + None => return Err(Error::TooLarge("a row is wider than the grid".into())), + }; + let start = row.col; + row.col = end; + if kept { + let len = draft.cell.text.len() + draft.cell.raw.as_ref().map_or(0, String::len); + self.charge_text(u64::from(draft.repeat).saturating_mul(len as u64))?; + if let Some(row) = self.row.as_mut() { + row.cells.push(RepeatedCell { col: start, repeat: draft.repeat, width, height, cell: draft.cell }); + } + } + Ok(()) + } + + fn finish_row(&mut self) -> Result<(), Error> { + let Some(row) = self.row.take() else { return Ok(()) }; + let Some(sheet) = self.sheet.as_mut() else { return Ok(()) }; + let end = match sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS) { + Some(end) => end, + None if row.cells.is_empty() => MAX_ROWS, + None => return Err(Error::TooLarge("the sheet is taller than the grid".into())), + }; + let first_row = sheet.row; + sheet.row = end; + for entry in row.cells { + let count = u64::from(entry.repeat) * u64::from(row.repeat); + if !entry.cell.is_blank() { + self.cells = self.cells.saturating_add(count); + let len = entry.cell.text.len() + entry.cell.raw.as_ref().map_or(0, String::len); + // The first row's copies were charged with the cell. + self.text = self.text.saturating_add(count.saturating_sub(u64::from(entry.repeat)).saturating_mul(len as u64)); + } + let merged = entry.width > 1 || entry.height > 1; + if merged { + self.merges = self.merges.saturating_add(count); + } + if self.cells > self.options.max_cells || self.merges > self.options.max_merges || self.text > self.options.max_text { + return Err(Error::TooLarge(format!( + "more than {} cells, {} merged ranges or {} MiB of text", + self.options.max_cells, + self.options.max_merges, + self.options.max_text >> 20 + ))); + } + let last_col = entry.col.checked_add(entry.repeat - 1).and_then(|c| c.checked_add(entry.width - 1)); + let last_row = end.checked_add(entry.height - 1); + if last_col.is_none_or(|c| c >= MAX_COLS) || last_row.is_none_or(|r| r > MAX_ROWS) { + return Err(Error::TooLarge("a merged cell reaches outside the grid".into())); + } + if merged && ((entry.repeat > 1 && entry.width > 1) || (row.repeat > 1 && entry.height > 1)) { + return Err(Error::Invalid("repeated cells have overlapping merges".into())); + } + let Some(sheet) = self.sheet.as_mut() else { return Ok(()) }; + for r in first_row..end { + for c in entry.col..entry.col + entry.repeat { + let at = CellRef::new(r, c); + if !entry.cell.is_blank() { + sheet.sheet.cells.insert(at, entry.cell.clone()); + } + if merged { + sheet.sheet.merges.push(Range::new(at, CellRef::new(r + entry.height - 1, c + entry.width - 1))); + } + } + } + } + Ok(()) + } +} + +fn push_text(out: &mut String, text: &str, max: usize) -> Result<(), Error> { + if out.len().saturating_add(text.len()) > max { + return Err(Error::TooLarge(format!("a cell's text is longer than {max} bytes"))); + } + out.push_str(text); + Ok(()) +} diff --git a/crates/ods/src/lib.rs b/crates/ods/src/lib.rs new file mode 100644 index 000000000..e96e22df2 --- /dev/null +++ b/crates/ods/src/lib.rs @@ -0,0 +1,115 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! OpenDocument spreadsheets (`.ods`, ODF 1.3 Part 3, `table:` elements of Part 3 section 9), +//! on `craft-odf` for the package. Clean-room, from the specification and observed files. +//! +//! [`read`] gives a [`Workbook`]: each `table:table` of the document body as a [`Sheet`] of +//! [`Cell`]s with their value type, value, displayed text, formula and style name as the file +//! has them, repeated rows and columns expanded, and merged cells (`number-columns-spanned`, +//! `number-rows-spanned`) as ranges. [`Cell::value`] gives the value a cell shows, with dates +//! and durations as serial numbers. +//! +//! Formulas are kept as text (`of:=SUM([.A1:.A3])`), not translated. Styles, charts, pictures, +//! annotations and settings are not read. An encrypted package is [`Error::Encrypted`]: decrypt +//! it with `craft_odf::crypt::decrypt` first. +//! +//! The XML is read strictly, as the source reader did: unbalanced markup, a document type +//! declaration or an undefined entity fails the read. Input is hostile: [`Options`] bounds the +//! package, the cells, the merged ranges and the text, repeats are checked against the grid +//! before anything is expanded, and nothing indexes by a number from the file. +//! +//! ``` +//! # fn main() -> Result<(), craft_ods::Error> { +//! # let bytes = craft_ods::testing::spreadsheet(r#"Tea"#); +//! let book = craft_ods::read(&bytes, &craft_ods::Options::default())?; +//! let sheet = &book.sheets[0]; +//! assert_eq!(sheet.name, "Prices"); +//! assert_eq!(sheet.cells[&craft_ods::CellRef::new(0, 2)].value(), Some(craft_ods::Value::Number(2.5))); +//! # Ok(()) +//! # } +//! ``` + +mod content; +mod model; +#[doc(hidden)] +pub mod testing; + +pub use content::read_content; +pub use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value}; +pub use model::{Cell, Sheet, ValueType, Workbook, date_serial, duration_days}; + +/// Why a spreadsheet could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Not a ZIP package, a damaged entry, or no spreadsheet mimetype. + #[error("not a readable OpenDocument spreadsheet: {0}")] + Invalid(String), + /// `content.xml` cannot be read or is not a spreadsheet document. + #[error("the spreadsheet contains invalid XML: {0}")] + Xml(String), + /// The package is password-protected. + #[error("the spreadsheet is password-protected (encrypted)")] + Encrypted, + /// More than [`Options`] or a fixed cap allows. + #[error("the spreadsheet is too large: {0}")] + TooLarge(String), +} + +impl From for Error { + fn from(e: craft_odf::Error) -> Self { + if e.is_limit() { Error::TooLarge(e.to_string()) } else { Error::Invalid(e.to_string()) } + } +} + +/// What a read refuses to exceed, and which sheets it keeps. 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 Options { + /// The ZIP package. + pub package: craft_odf::Limits, + /// Cells with content (a value, text or a formula), repeats expanded, over all sheets. + pub max_cells: u64, + /// Merged ranges, repeats expanded, over all sheets. + pub max_merges: u64, + /// Bytes of cell text, repeats expanded, over all sheets. + pub max_text: u64, + /// Bytes of one cell's text, and of one attribute value. + pub max_cell_text: usize, + /// Keep the cells of this sheet only (by position); the others are listed by name, empty. + pub only_sheet: Option, +} + +impl Default for Options { + fn default() -> Self { + Options { + package: craft_odf::Limits::DEFAULT, + max_cells: 1_000_000, + max_merges: 100_000, + max_text: 64 << 20, + max_cell_text: 1 << 20, + only_sheet: None, + } + } +} + +/// Is this a package whose mimetype is an OpenDocument spreadsheet (or spreadsheet template)? +pub fn is_ods(bytes: &[u8]) -> bool { + craft_odf::Package::open(bytes, &craft_odf::Limits::DEFAULT) + .ok() + .and_then(|mut p| p.mimetype()) + .is_some_and(|m| m == craft_odf::mime::SPREADSHEET || m == craft_odf::mime::SPREADSHEET_TEMPLATE) +} + +/// Reads a spreadsheet package (`.ods`, `.ots`). +pub fn read(bytes: &[u8], options: &Options) -> Result { + let mut package = craft_odf::Package::open(bytes, &options.package)?; + match package.mimetype().as_deref() { + Some(craft_odf::mime::SPREADSHEET | craft_odf::mime::SPREADSHEET_TEMPLATE) => {} + _ => return Err(Error::Invalid("missing or invalid ODS mimetype".into())), + } + if package.is_encrypted() { + return Err(Error::Encrypted); + } + let content = package.read("content.xml")?.ok_or_else(|| Error::Invalid("content.xml is missing".into()))?; + read_content(&content, options) +} diff --git a/crates/ods/src/model.rs b/crates/ods/src/model.rs new file mode 100644 index 000000000..5c0fb14d1 --- /dev/null +++ b/crates/ods/src/model.rs @@ -0,0 +1,253 @@ +//! What a spreadsheet reads into: the file's own cells, nothing interpreted beyond the format. + +use std::collections::BTreeMap; + +use craft_sheet::{CellRef, Range, Value}; + +/// `office:value-type` (ODF 1.3 Part 3, 19.389), and `calcext:value-type="error"`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueType { + Float, + Percentage, + Currency, + Date, + Time, + Boolean, + String, + Void, + /// LibreOffice's error result of a formula; the error text is the cell's text. + Error, +} + +impl ValueType { + pub(crate) fn parse(s: &str) -> Option { + Some(match s { + "float" => ValueType::Float, + "percentage" => ValueType::Percentage, + "currency" => ValueType::Currency, + "date" => ValueType::Date, + "time" => ValueType::Time, + "boolean" => ValueType::Boolean, + "string" => ValueType::String, + "void" => ValueType::Void, + "error" => ValueType::Error, + _ => return None, + }) + } +} + +/// One `table:table-cell` or `table:covered-table-cell`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Cell { + /// `None` when the cell has no value type (or one ODF does not define). + pub value_type: Option, + /// The attribute that holds the value for the type, as written: `office:value`, + /// `office:date-value`, `office:time-value`, `office:boolean-value` or `office:string-value`. + pub raw: Option, + /// `office:currency`. + pub currency: Option, + /// The displayed text: the cell's paragraphs joined with line feeds, white space collapsed + /// as ODF says, `text:s`, `text:tab` and `text:line-break` expanded. Annotations are left out. + pub text: String, + /// Paragraphs (`text:p`, `text:h`) the cell has. + pub paragraphs: usize, + /// `table:formula` as written (`of:=…`). + pub formula: Option, + /// `table:style-name`. + pub style: Option, + /// A `table:covered-table-cell`: hidden under a merged cell. + pub covered: bool, +} + +impl Cell { + /// Nothing to keep: no value, no text, no formula. + pub fn is_blank(&self) -> bool { + self.formula.is_none() && self.text.is_empty() && matches!(self.value_type, None | Some(ValueType::Void)) && self.raw.is_none() + } + + /// The value the cell shows: a number for the numeric types, a serial number (1900 date + /// system) for a date, days for a duration, the string value or else the text for a string. + /// `None` when the type's value is missing or unreadable, or the cell is a formula without a + /// cached result: the caller decides what to show (the text, [`Cell::raw`]). + pub fn value(&self) -> Option { + let raw = self.raw.as_deref(); + match self.value_type { + Some(ValueType::Float | ValueType::Percentage | ValueType::Currency) => { + raw.and_then(|s| s.trim().parse::().ok()).filter(|n| n.is_finite()).map(Value::Number) + } + Some(ValueType::Boolean) => match raw { + Some("true" | "1") => Some(Value::Bool(true)), + Some("false" | "0") => Some(Value::Bool(false)), + _ => None, + }, + Some(ValueType::String) if raw.is_some() || self.paragraphs > 0 || self.formula.is_none() => Some(Value::text(raw.unwrap_or(&self.text))), + Some(ValueType::Void) if self.formula.is_none() => Some(Value::Empty), + Some(ValueType::Date) => raw.and_then(date_serial).map(|(n, _)| Value::Number(n)), + Some(ValueType::Time) => raw.and_then(duration_days).map(Value::Number), + None if self.formula.is_none() => Some(if self.text.is_empty() { Value::Empty } else { Value::text(self.text.as_str()) }), + _ => None, + } + } +} + +/// One `table:table`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Sheet { + /// `table:name` as written. + pub name: String, + /// Cells that are not blank ([`Cell::is_blank`]), repeats expanded. + pub cells: BTreeMap, + /// Merged ranges, from their top left cell. + pub merges: Vec, +} + +/// The tables of a spreadsheet document, in order. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Workbook { + pub sheets: Vec, +} + +fn digits(s: &str) -> Option<&str> { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then_some(s) +} + +fn decimal(s: &str) -> Option { + match s.split_once('.') { + Some((a, b)) => { + digits(a)?; + digits(b)?; + } + None => { + digits(s)?; + } + } + s.parse::().ok().filter(|n| n.is_finite()) +} + +/// Days from 1970-01-01 to a proleptic Gregorian date. +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 doy = (153 * ((m + 9) % 12) + 2) / 5 + d - 1; + era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468 +} + +fn days_in_month(y: i64, m: i64) -> i64 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +/// An `office:date-value` (`2024-02-29` or `2024-02-29T13:45:30.5`) as a serial number in the +/// 1900 date system, and whether it had a time. Years 1900 to 9999; dates before 1 March 1900 +/// are one lower than the day count, for the 29 February 1900 that spreadsheets count. +pub fn date_serial(s: &str) -> Option<(f64, bool)> { + let (date, time) = s.split_once('T').map_or((s, None), |(d, t)| (d, Some(t))); + if date.len() != 10 || date.get(4..5)? != "-" || date.get(7..8)? != "-" { + return None; + } + let year: i64 = digits(date.get(..4)?)?.parse().ok()?; + let month: i64 = digits(date.get(5..7)?)?.parse().ok()?; + let day: i64 = digits(date.get(8..10)?)?.parse().ok()?; + if !(1900..=9999).contains(&year) || !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return None; + } + let serial = (days_from_civil(year, month, day) - days_from_civil(1899, 12, 30) - i64::from(year == 1900 && month <= 2)) as f64; + let Some(time) = time else { return Some((serial, false)) }; + let mut parts = time.split(':'); + let h = parts.next()?; + let m = parts.next()?; + let sec = parts.next()?; + if parts.next().is_some() || h.len() != 2 || m.len() != 2 || sec.split('.').next()?.len() != 2 { + return None; + } + let h: u32 = digits(h)?.parse().ok()?; + let m: u32 = digits(m)?.parse().ok()?; + let sec = decimal(sec)?; + if h >= 24 || m >= 60 || sec >= 60.0 { + return None; + } + Some((serial + (f64::from(h) * 3600.0 + f64::from(m) * 60.0 + sec) / 86400.0, true)) +} + +/// An `office:time-value`, an ISO 8601 duration (`PT36H05M10.5S`, `P2DT1H`, `-PT1H`), in days. +/// At most 2,958,465 days (the last day spreadsheets count). +pub fn duration_days(s: &str) -> Option { + let (negative, s) = s.strip_prefix('-').map_or((false, s), |s| (true, s)); + let mut rest = s.strip_prefix('P')?; + let mut total = 0.0; + let mut any = false; + if let Some((days, tail)) = rest.split_once('D') { + total += digits(days)?.parse::().ok()? * 86400.0; + rest = tail; + any = true; + } + if let Some(mut time) = rest.strip_prefix('T') { + let mut time_any = false; + for (unit, factor) in [('H', 3600.0), ('M', 60.0), ('S', 1.0)] { + if let Some((n, tail)) = time.split_once(unit) { + let value = if unit == 'S' { decimal(n)? } else { digits(n)?.parse::().ok()? }; + total += value * factor; + time = tail; + time_any = true; + } + } + if !time.is_empty() || !time_any { + return None; + } + any = true; + } else if !rest.is_empty() { + return None; + } + let days = total / 86400.0; + (any && days.is_finite() && days <= 2_958_465.0).then_some(if negative { -days } else { days }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_and_durations() { + assert_eq!(date_serial("1900-01-01"), Some((1.0, false))); + assert_eq!(date_serial("1900-03-01"), Some((61.0, false))); + assert_eq!(date_serial("2024-02-29T12:00:00.5"), Some((45351.0 + 43200.5 / 86400.0, true))); + for bad in ["2023-02-29", "1899-12-31", "2024-1-01", "2024-01-01T1:00:00", "2024-01-01T24:00:00", "+024-01-01", "2024-01-01T12:00"] { + assert_eq!(date_serial(bad), None, "{bad}"); + } + assert_eq!(duration_days("PT36H"), Some(1.5)); + assert_eq!(duration_days("-P1DT12H"), Some(-1.5)); + assert_eq!(duration_days("PT1.5S"), Some(1.5 / 86400.0)); + for bad in ["P", "PT", "1H", "PT1X", "P1", "PT-1H", "P9999999D"] { + assert_eq!(duration_days(bad), None, "{bad}"); + } + } + + #[test] + fn values() { + let cell = |t: Option, raw: Option<&str>, text: &str, formula: bool| Cell { + value_type: t, + raw: raw.map(str::to_string), + text: text.into(), + paragraphs: usize::from(!text.is_empty()), + formula: formula.then(|| "of:=1".to_string()), + ..Cell::default() + }; + assert_eq!(cell(Some(ValueType::Percentage), Some(" 0.25"), "25%", false).value(), Some(Value::Number(0.25))); + assert_eq!(cell(Some(ValueType::Float), Some("inf"), "", false).value(), None); + assert_eq!(cell(Some(ValueType::Boolean), Some("1"), "", false).value(), Some(Value::Bool(true))); + assert_eq!(cell(Some(ValueType::String), None, "shown", true).value(), Some(Value::text("shown"))); + assert_eq!(cell(Some(ValueType::String), None, "", true).value(), None); + assert_eq!(cell(Some(ValueType::String), Some("raw"), "shown", false).value(), Some(Value::text("raw"))); + assert_eq!(cell(Some(ValueType::Void), None, "", true).value(), None); + assert_eq!(cell(Some(ValueType::Error), None, "#DIV/0!", true).value(), None); + assert_eq!(cell(Some(ValueType::Date), Some("1900-01-02"), "", false).value(), Some(Value::Number(2.0))); + assert_eq!(cell(None, None, "plain", false).value(), Some(Value::text("plain"))); + assert!(cell(Some(ValueType::Void), None, "", false).is_blank()); + assert!(!cell(None, None, "", true).is_blank()); + } +} diff --git a/crates/ods/src/testing.rs b/crates/ods/src/testing.rs new file mode 100644 index 000000000..a8a663d77 --- /dev/null +++ b/crates/ods/src/testing.rs @@ -0,0 +1,23 @@ +//! Spreadsheets built in code, for tests and examples. + +/// The namespace declarations `content.xml` uses, for a test's own documents. +pub const NAMESPACES: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0""#; + +/// `content.xml` with `tables` (the `table:table` elements) as the spreadsheet body. +pub fn content(tables: &str) -> String { + format!( + r#"{tables}"# + ) +} + +/// An `.ods` package around [`content`]. +pub fn spreadsheet(tables: &str) -> Vec { + package(content(tables).as_bytes()) +} + +/// An `.ods` package with this `content.xml`. +pub fn package(content: &[u8]) -> Vec { + let mut w = craft_odf::PackageWriter::new(craft_odf::mime::SPREADSHEET); + w.file("content.xml", "text/xml", content); + w.finish().unwrap_or_default() +} diff --git a/crates/ods/tests/read.rs b/crates/ods/tests/read.rs new file mode 100644 index 000000000..e53b19e6b --- /dev/null +++ b/crates/ods/tests/read.rs @@ -0,0 +1,269 @@ +//! ODS packages assembled here, not files from another application. Adapted from GridCraft's +//! `crates/xlsx/src/tests/ods_tests.rs`. + +use craft_ods::testing::{content, package, spreadsheet}; +use craft_ods::{CellRef, Error, Options, Range, Sheet, Value, ValueType, Workbook, read}; + +fn ods(tables: &str) -> Vec { + spreadsheet(tables) +} +fn row(cells: &str) -> Vec { + ods(&format!(r#"{cells}"#)) +} +fn text(s: &str) -> Value { + Value::text(s) +} +fn open(bytes: &[u8]) -> Result { + read(bytes, &Options::default()) +} +/// What an application shows: the value, else the text, else the raw value. +fn shown(s: &Sheet, at: CellRef) -> Value { + let Some(c) = s.cells.get(&at) else { return Value::Empty }; + c.value().unwrap_or_else(|| if !c.text.is_empty() { text(&c.text) } else { c.raw.as_deref().map_or(Value::Empty, text) }) +} +#[test] +fn a_namespace_declaration_bomb_is_refused() { + let bomb = |n: usize| -> String { (0..n).map(|i| format!(" xmlns:p{i}=\"urn:p{i}\"")).collect() }; + let doc = |decls: &str| { + content(r#""#) + .replacen(" + + + + + 85 + #DIV/0! + + unavailable + + "#)) + .unwrap(); + let s = &wb.sheets[0]; + let expected = [ + Value::Number(42.5), + Value::Bool(true), + Value::Number(0.25), + Value::Number(19.99), + text("literal =1+1"), + Value::Number(85.0), + text("#DIV/0!"), + Value::Empty, + text("unavailable"), + Value::Bool(false), + ]; + for (c, value) in expected.iter().enumerate() { + assert_eq!(&shown(s, CellRef::new(0, c as u32)), value, "{c}"); + } + let at = |c| &s.cells[&CellRef::new(0, c)]; + assert_eq!((at(3).value_type, at(3).currency.as_deref()), (Some(ValueType::Currency), Some("USD"))); + assert_eq!(at(5).formula.as_deref(), Some("of:=A1*2")); + assert_eq!((at(7).formula.as_deref(), at(7).value()), (Some("of:=UNKNOWN()"), None)); + assert_eq!(at(8).value(), None); + assert_eq!(at(9).style.as_deref(), Some("ce1")); +} + +#[test] +fn text_keeps_paragraphs_runs_entities_spaces_tabs_and_breaks() { + let wb = open(&row( + r#"one two & three fourfivesixseven!]]>ninenot cell text"#, + )) + .unwrap(); + assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!\nnine")); + let wb = open(&row(r#" one + two three four five "#)) + .unwrap(); + assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 0)), text("one two three four \n five")); +} + +#[test] +fn repeats_merges_names_and_an_empty_tail_stay_sparse() { + let wb = open(&ods(r#" + + merged + + + + + + "#)) + .unwrap(); + assert_eq!(wb.sheets.len(), 2); + assert_eq!((wb.sheets[0].name.as_str(), wb.sheets[1].name.as_str()), ("First & Second", "'History'")); + assert_eq!(wb.sheets[0].cells.len(), 8); + assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 1)), Value::Number(99.0)); + assert!(wb.sheets[0].cells[&CellRef::new(0, 1)].covered); + assert_eq!(wb.sheets[0].merges, vec![Range::new(CellRef::new(0, 0), CellRef::new(1, 1))]); + assert_eq!(shown(&wb.sheets[0], CellRef::new(3, 2)), Value::Number(7.0)); + assert_eq!(shown(&wb.sheets[1], CellRef::new(0, 0)), text("second sheet")); +} + +#[test] +fn one_sheet_can_be_read_alone() { + let bytes = ods( + r#""#, + ); + let wb = read(&bytes, &Options { only_sheet: Some(1), ..Options::default() }).unwrap(); + assert_eq!(wb.sheets.iter().map(|s| (s.name.as_str(), s.cells.len())).collect::>(), [("A", 0), ("B", 1)]); +} + +#[test] +fn dates_and_durations_are_serials_and_unreadable_ones_stay_raw() { + let wb = open(&row(r#" + + + + + + + + + "#)) + .unwrap(); + let s = &wb.sheets[0]; + assert_eq!(shown(s, CellRef::new(0, 0)), Value::Number(59.0)); + assert_eq!(shown(s, CellRef::new(0, 1)), Value::Number(46302.0 + 66600.125 / 86400.0)); + assert_eq!(shown(s, CellRef::new(0, 2)), Value::Number(1.0 + 7384.5 / 86400.0)); + assert_eq!(shown(s, CellRef::new(0, 3)), Value::Number(-2.0 / 24.0)); + for (c, value) in [(4, "1900-02-29"), (5, "2026-10-07T18:00:00Z"), (6, "P1M"), (7, "PT2M1H")] { + assert_eq!(shown(s, CellRef::new(0, c)), text(value)); + } +} + +#[test] +fn namespaces_are_resolved_not_inferred_from_prefixes() { + let xml = content(r#""#) + .replace("kept"#, + )) + .unwrap(); + assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 0)), text("kept")); +} + +#[test] +fn bad_packages_encryption_and_bad_xml_are_refused() { + assert!(matches!(open(b"not zip"), Err(Error::Invalid(_)))); + let xml = content(r#""#); + let mut w = craft_odf::PackageWriter::new("application/zip"); + w.file("content.xml", "text/xml", xml.as_bytes()); + assert!(matches!(open(&w.finish().unwrap()), Err(Error::Invalid(_)))); + assert!(craft_ods::is_ods(&package(xml.as_bytes()))); + let mut w = craft_odf::PackageWriter::new(craft_odf::mime::SPREADSHEET); + w.file("content.xml", "text/xml", xml.as_bytes()); + if let Some(e) = w.manifest_mut().entries.iter_mut().find(|e| e.path == "content.xml") { + e.encryption = Some(craft_odf::Encryption::default()); + } + assert_eq!(open(&w.finish().unwrap()), Err(Error::Encrypted)); + for bad in [ + xml.trim_end_matches("").to_owned(), + xml.replace("", ""), + "".to_owned(), + format!("{xml}{xml}"), + format!("{xml}"), + xml.replace("Data", "&custom;"), + content(""), + ] { + assert!(open(&package(bad.as_bytes())).is_err(), "{bad}"); + } + let deep = content(&format!("{}{}", "".repeat(300), "".repeat(300))); + assert!(open(&package(deep.as_bytes())).is_err()); +} + +#[test] +fn expansion_bounds_stop_small_repeat_bombs() { + for cells in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(open(&row(cells)).is_err(), "{cells}"); + } + let cells = ods( + r#""#, + ); + assert!(matches!(open(&cells), Err(Error::TooLarge(_)))); + let text = format!( + r#""#, + "x".repeat(1024) + ); + assert!(matches!(open(&ods(&text)), Err(Error::TooLarge(_)))); + // One row of cells each just under the 1 MiB cell limit, past 64 MiB in all: refused while + // the row is read. The row's end is never reached. + let long = format!(r#""#, "x".repeat(1024 * 1024 - 1)); + let cells = package(content(&format!(r#"{}"#, long.repeat(65))).as_bytes()); + assert!(matches!(open(&cells), Err(Error::TooLarge(_)))); + // A row repeated once more than its cells' text allows is refused, and one that fits reads. + let cell = format!(r#""#, "y".repeat(1024)); + let rows = |n: u32| { + ods(&format!(r#"{cell}"#)) + }; + assert!(open(&rows(65_536)).is_ok()); + assert!(matches!(open(&rows(65_537)), Err(Error::TooLarge(_)))); + // An attribute over the cell text limit. + let small = Options { max_cell_text: 10, ..Options::default() }; + assert!(matches!( + read(&row(r#""#), &small), + Err(Error::TooLarge(_)) + )); + let few = Options { max_cells: 3, max_merges: 1, ..Options::default() }; + assert!(matches!( + read(&row(r#""#), &few), + Err(Error::TooLarge(_)) + )); + assert!(matches!( + read(&row(r#""#), &few), + Err(Error::TooLarge(_)) + )); +} + +#[test] +fn an_error_result_never_reads_as_its_placeholder_zero() { + let wb = open(&row( + r#"#DIV/0!"#, + )) + .unwrap(); + let c = &wb.sheets[0].cells[&CellRef::new(0, 0)]; + assert_eq!((c.value_type, c.value(), c.text.as_str()), (Some(ValueType::Error), None, "#DIV/0!")); +} + +#[test] +fn blank_repeats_past_the_sheet_edge_are_clipped() { + // Writers with larger sheets pad to their own edge; blank padding past ours carries no data. + let wb = open(&ods(r#" + + + + "#)) + .unwrap(); + assert_eq!(wb.sheets[0].cells.len(), 1); + assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 0)), Value::Number(1.0)); + // Data after the clipped edge is an error, never silently dropped. + let late = ods( + r#""#, + ); + assert!(matches!(open(&late), Err(Error::TooLarge(_)))); +}