mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
1 parent
fdbb9c2471
commit
0d68632c9b
10 files changed
+1143
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 & three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven!<![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 & 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(_))));
|
||||
}
|
||||
Reference in new issue
Block a user