Add craft-ods: OpenDocument spreadsheet tables on craft-odf (#990)

* Add craft-sheet: the cell positions, values and tables the spreadsheet formats share

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.

* Add craft-ods: OpenDocument spreadsheet tables on craft-odf

read() checks the spreadsheet mimetype, refuses an encrypted package, and reads the
tables of content.xml into sheets of cells that keep the file's terms: value type,
the raw value, displayed text, formula text, style name, covered cells, with
repeats expanded and merged ranges. Cell::value() gives the shown value, dates and
durations as serial numbers.

From GridCraft crates/xlsx/src/ods.rs and its tests (7e0d96d7), split from
GridCraft's document model and moved onto craft-odf and craft-xml's pull reader.
Options::only_sheet covers WordCraft's reader of one chosen sheet.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 20:16:51 -05:00
1 parent fdbb9c2471
commit 0d68632c9b
10 files changed
+1143

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -233,6 +233,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
placed, coloured triangle meshes and a model tree. Bounded on hostile input. From PdfCraft;
rendering stays in the app.
- [`craft-offcrypto`](crates/offcrypto/README.md): clean-room Office document cryptography ([MS-OFFCRYPTO]): password-encrypted OOXML packages on `craft-cfb` (agile and standard encryption read, with the integrity check enforced; agile written), unsupported schemes named, and the password hashes of document and sheet protection.
- [`craft-ods`](crates/ods/README.md): OpenDocument spreadsheets (`.ods`, `.ots`) on `craft-odf`: the tables of `content.xml` with typed values, displayed text, formulas as text, style names, repeats expanded and merged cells, with dates and durations as serial numbers. Bounded cells, merges and text; strict XML.
- [`craft-opc`](crates/opc/README.md): Open Packaging Conventions, the ZIP container of DOCX, XLSX and PPTX
files: bounded readers (every part up front, or on demand), part names, relationships, content
types and a deterministic package writer. What the parts hold stays with each format's crate.
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-ods"
description = "OpenDocument spreadsheets (.ods): the tables of content.xml with typed cell values, displayed text, formulas, repeats and merged cells, on craft-odf."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-odf = { workspace = true }
craft-sheet = { workspace = true }
craft-xml = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
craft-odf = { workspace = true }
+37
View File
@@ -0,0 +1,37 @@
# craft-ods
OpenDocument spreadsheets (`.ods`, `.ots`): the tables of `content.xml` (ODF 1.3 Part 3,
section 9), on `craft-odf` for the package. Clean-room. No `unsafe`.
`read(bytes, &Options)` gives a `Workbook` of `Sheet`s in document order. Each sheet has its
`table:name` as written, its non-blank `Cell`s by position (repeated rows and columns expanded)
and its merged ranges. A cell keeps the file's terms:
| Field | From |
|---|---|
| `value_type` | `office:value-type`, or `calcext:value-type="error"` |
| `raw`, `currency` | the value attribute for the type, `office:currency` |
| `text`, `paragraphs` | the displayed paragraphs, white space collapsed as ODF says, annotations left out |
| `formula` | `table:formula` as written (`of:=…`), not translated |
| `style` | `table:style-name` |
| `covered` | a `table:covered-table-cell` |
`Cell::value()` gives the shown value: numbers, booleans, strings, dates as serial numbers in
the 1900 date system (`date_serial`) and durations in days (`duration_days`). It is `None` when
the type's value is missing or unreadable, or for a formula without a cached result, so the
caller chooses what to show.
`read_content` reads a `content.xml` on its own (a flat `.fods` goes through
`craft_odf::flat::to_package` first). An encrypted package is `Error::Encrypted`; decrypt it with
`craft_odf::crypt::decrypt` and read the result.
## Hostile input
`Options` bounds the package (`craft-odf`), cells (1,000,000), merged ranges (100,000), text
(64 MiB, repeats counted) and one cell's text or attribute (1 MiB), all over the whole
workbook. Repeats and spans are checked against the 1,048,576 × 16,384 grid before anything is
expanded; blank padding past the grid's edge is clipped, data past it is an error. The XML is read strictly
(unbalanced markup, a document type declaration or an undefined entity fails the read), with
`craft-xml`'s depth and attribute limits. At most 1024 tables.
`Options::only_sheet` keeps one sheet's cells and lists the others by name.
+414
View File
@@ -0,0 +1,414 @@
//! `content.xml`: the document body's tables, read as a stream of events.
use craft_sheet::{CellRef, MAX_COLS, MAX_ROWS, Range};
use craft_xml::{Canonical, Doctype, Event, Options as XmlOptions};
use crate::model::{Cell, Sheet, ValueType, Workbook};
use crate::{Error, Options};
const NAMESPACES: &[(&str, &str)] = &[
("office", "urn:oasis:names:tc:opendocument:xmlns:office:1.0"),
("table", "urn:oasis:names:tc:opendocument:xmlns:table:1.0"),
("text", "urn:oasis:names:tc:opendocument:xmlns:text:1.0"),
("calcext", "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"),
];
/// Most tables in one document.
const MAX_SHEETS: usize = 1024;
fn xml_error(e: impl std::fmt::Display) -> 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<RepeatedCell>,
}
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<usize>,
paragraph_started: bool,
pending_space: bool,
}
struct Import<'o> {
options: &'o Options,
wb: Workbook,
stack: Vec<Tag>,
sheet: Option<SheetDraft>,
row: Option<RowDraft>,
cell: Option<CellDraft>,
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<Workbook, Error> {
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<Option<String>, 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<u32, Error> {
let n = match self.attr(attrs, name)? {
Some(s) => s.parse::<u32>().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::<usize>()).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(())
}
+115
View File
@@ -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#"<table:table table:name="Prices"><table:table-row><table:table-cell office:value-type="string"><text:p>Tea</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="2.5" table:number-columns-repeated="2"/></table:table-row></table:table>"#);
//! 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<craft_odf::Error> 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<usize>,
}
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<Workbook, Error> {
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)
}
+253
View File
@@ -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<ValueType> {
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<ValueType>,
/// 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<String>,
/// `office:currency`.
pub currency: Option<String>,
/// 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<String>,
/// `table:style-name`.
pub style: Option<String>,
/// 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<Value> {
let raw = self.raw.as_deref();
match self.value_type {
Some(ValueType::Float | ValueType::Percentage | ValueType::Currency) => {
raw.and_then(|s| s.trim().parse::<f64>().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<CellRef, Cell>,
/// Merged ranges, from their top left cell.
pub merges: Vec<Range>,
}
/// The tables of a spreadsheet document, in order.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Workbook {
pub sheets: Vec<Sheet>,
}
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<f64> {
match s.split_once('.') {
Some((a, b)) => {
digits(a)?;
digits(b)?;
}
None => {
digits(s)?;
}
}
s.parse::<f64>().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<f64> {
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::<f64>().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::<f64>().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<ValueType>, 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());
}
}
+23
View File
@@ -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#"<office:document-content {NAMESPACES}><office:body><office:spreadsheet>{tables}</office:spreadsheet></office:body></office:document-content>"#
)
}
/// An `.ods` package around [`content`].
pub fn spreadsheet(tables: &str) -> Vec<u8> {
package(content(tables).as_bytes())
}
/// An `.ods` package with this `content.xml`.
pub fn package(content: &[u8]) -> Vec<u8> {
let mut w = craft_odf::PackageWriter::new(craft_odf::mime::SPREADSHEET);
w.file("content.xml", "text/xml", content);
w.finish().unwrap_or_default()
}
+269
View File
@@ -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<u8> {
spreadsheet(tables)
}
fn row(cells: &str) -> Vec<u8> {
ods(&format!(r#"<table:table table:name="Data"><table:table-row>{cells}</table:table-row></table:table>"#))
}
fn text(s: &str) -> Value {
Value::text(s)
}
fn open(bytes: &[u8]) -> Result<Workbook, Error> {
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#"<table:table table:name="Data"><table:table-row><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#)
.replacen("<office:document-content ", &format!("<office:document-content{decls} "), 1)
};
assert!(open(&package(doc(&bomb(8)).as_bytes())).is_ok());
assert!(open(&package(doc(&bomb(10_000)).as_bytes())).is_err());
}
#[test]
fn typed_values_and_formula_caches() {
let wb = open(&row(r#"
<table:table-cell office:value-type="float" office:value="42.5"/>
<table:table-cell office:value-type="boolean" office:boolean-value="true"/>
<table:table-cell office:value-type="percentage" office:value="0.25"/>
<table:table-cell office:value-type="currency" office:currency="USD" office:value="19.99"/>
<table:table-cell office:value-type="string" office:string-value="literal =1+1"/>
<table:table-cell table:formula="of:=A1*2" office:value-type="float" office:value="85"><text:p>85</text:p></table:table-cell>
<table:table-cell table:formula="of:=1/0" office:value-type="string"><text:p>#DIV/0!</text:p></table:table-cell>
<table:table-cell table:formula="of:=UNKNOWN()"/>
<table:table-cell office:value-type="float" office:value="NaN"><text:p>unavailable</text:p></table:table-cell>
<table:table-cell office:value-type="boolean" office:boolean-value="0" table:style-name="ce1"/>
"#))
.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#"<table:table-cell office:value-type="string"><text:p>one <text:span>two &amp; three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven&#33;<![CDATA[<eight>]]></text:p><text:p>nine</text:p><office:annotation><text:p>not cell text</text:p></office:annotation></table:table-cell>"#,
))
.unwrap();
assert_eq!(shown(&wb.sheets[0], CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!<eight>\nnine"));
let wb = open(&row(r#"<table:table-cell office:value-type="string"><text:p> one
<text:span> two </text:span> three <text:s text:c="0"/>four<text:s text:c="2"/> </text:p><text:p><text:s/> five </text:p></table:table-cell>"#))
.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#"
<table:table table:name="First &amp; Second">
<table:table-header-rows><table:table-row><table:table-cell table:number-columns-spanned="2" table:number-rows-spanned="2" office:value-type="string"><text:p>merged</text:p></table:table-cell><table:covered-table-cell office:value-type="float" office:value="99"/></table:table-row></table:table-header-rows>
<table:table-row><table:covered-table-cell table:number-columns-repeated="2"/></table:table-row>
<table:table-row-group><table:table-row table:number-rows-repeated="2"><table:table-cell table:number-columns-repeated="3" office:value-type="float" office:value="7"/></table:table-row></table:table-row-group>
<table:table-row table:number-rows-repeated="1048572"><table:table-cell table:number-columns-repeated="16384"/></table:table-row>
</table:table>
<table:table table:name="'History'"><table:table-row><table:table-cell office:value-type="string" office:string-value="second sheet"/></table:table-row></table:table>
"#))
.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#"<table:table table:name="A"><table:table-row><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table><table:table table:name="B"><table:table-row><table:table-cell office:value-type="float" office:value="2"/></table:table-row></table:table>"#,
);
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::<Vec<_>>(), [("A", 0), ("B", 1)]);
}
#[test]
fn dates_and_durations_are_serials_and_unreadable_ones_stay_raw() {
let wb = open(&row(r#"
<table:table-cell office:value-type="date" office:date-value="1900-02-28"/>
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:30:00.125"/>
<table:table-cell office:value-type="time" office:time-value="P1DT2H3M4.5S"/>
<table:table-cell office:value-type="time" office:time-value="-PT2H"/>
<table:table-cell office:value-type="date" office:date-value="1900-02-29"/>
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:00:00Z"/>
<table:table-cell office:value-type="time" office:time-value="P1M"/>
<table:table-cell office:value-type="time" office:time-value="PT2M1H"/>
"#))
.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#"<table:table table:name="Actual"><table:table-row><table:table-cell office:value-type="float" office:value="9"/></table:table-row></table:table>"#)
.replace("<office:", "<o:")
.replace("</office:", "</o:")
.replace(" office:", " o:")
.replace("xmlns:office=", "xmlns:o=")
.replace("<table:", "<t:")
.replace("</table:", "</t:")
.replace(" table:", " t:")
.replace("xmlns:table=", "xmlns:t=");
assert_eq!(shown(&open(&package(xml.as_bytes())).unwrap().sheets[0], CellRef::new(0, 0)), Value::Number(9.0));
let spoofed = xml.replace("urn:oasis:names:tc:opendocument:xmlns:table:1.0", "wrong-namespace");
assert!(open(&package(spoofed.as_bytes())).is_err());
let wb = open(&row(
r#"<table:table-cell xmlns:fake="wrong-namespace" fake:value-type="float" fake:value="123"><text:p>kept</text:p></table:table-cell>"#,
))
.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#"<table:table table:name="Data"/>"#);
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("</office:document-content>").to_owned(),
xml.replace("</office:spreadsheet>", "</office:wrong>"),
"<root/>".to_owned(),
format!("{xml}{xml}"),
format!("<!DOCTYPE x>{xml}"),
xml.replace("Data", "&custom;"),
content(""),
] {
assert!(open(&package(bad.as_bytes())).is_err(), "{bad}");
}
let deep = content(&format!("{}{}", "<x>".repeat(300), "</x>".repeat(300)));
assert!(open(&package(deep.as_bytes())).is_err());
}
#[test]
fn expansion_bounds_stop_small_repeat_bombs() {
for cells in [
r#"<table:table-cell table:number-columns-repeated="4294967296"/>"#,
r#"<table:table-cell table:number-columns-repeated="4294967295" office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="0"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell table:number-columns-spanned="2"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384" table:number-columns-spanned="2" office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell office:value-type="string"><text:p><text:s text:c="4294967295"/></text:p></table:table-cell>"#,
r#"<table:table-cell table:number-columns-repeated="2" table:number-columns-spanned="2"/>"#,
] {
assert!(open(&row(cells)).is_err(), "{cells}");
}
let cells = ods(
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
);
assert!(matches!(open(&cells), Err(Error::TooLarge(_))));
let text = format!(
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="100000"><table:table-cell office:value-type="string" office:string-value="{}"/></table:table-row></table:table>"#,
"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#"<table:table-cell office:value-type="string" office:string-value="{}"/>"#, "x".repeat(1024 * 1024 - 1));
let cells = package(content(&format!(r#"<table:table table:name="Data"><table:table-row>{}"#, 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#"<table:table-cell office:value-type="string" office:string-value="{}"/>"#, "y".repeat(1024));
let rows = |n: u32| {
ods(&format!(r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="{n}">{cell}</table:table-row></table:table>"#))
};
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#"<table:table-cell office:value-type="string" office:string-value="eleven char"/>"#), &small),
Err(Error::TooLarge(_))
));
let few = Options { max_cells: 3, max_merges: 1, ..Options::default() };
assert!(matches!(
read(&row(r#"<table:table-cell table:number-columns-repeated="4" office:value-type="float" office:value="1"/>"#), &few),
Err(Error::TooLarge(_))
));
assert!(matches!(
read(&row(r#"<table:table-cell table:number-columns-spanned="2"/><table:table-cell table:number-columns-spanned="2"/>"#), &few),
Err(Error::TooLarge(_))
));
}
#[test]
fn an_error_result_never_reads_as_its_placeholder_zero() {
let wb = open(&row(
r#"<table:table-cell calcext:value-type="error" office:value-type="float" office:value="0" table:formula="of:=1/0"><text:p>#DIV/0!</text:p></table:table-cell>"#,
))
.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#"<table:table table:name="Data">
<table:table-row><table:table-cell office:value-type="float" office:value="1"/><table:table-cell table:number-columns-repeated="4294967295"/><table:table-cell/></table:table-row>
<table:table-row table:number-rows-repeated="16777215"><table:table-cell table:number-columns-repeated="16777216"/></table:table-row>
<table:table-row table:number-rows-repeated="5"><table:table-cell/></table:table-row>
</table:table>"#))
.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#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell/></table:table-row><table:table-row><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
);
assert!(matches!(open(&late), Err(Error::TooLarge(_))));
}