mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Add craft-xlsx: XLSX and XLSB part layer (workbook, strings, styles, streamed rows) (#976)
* 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-xlsx: the part layer of XLSX and XLSB workbooks Package::open finds the workbook part through craft-opc and reads the workbook part, shared strings (runs and phonetic readings), the styles tables as indexed tables, and a worksheet's rows one at a time with the rest of the part as a tree. Binary workbooks give the same workbook, strings, rows and merged cells from their records. Every other part stays reachable through Package::opc. From GridCraft crates/xlsx (7e0d96d7): read.rs, sheet_read.rs, styles.rs, xml.rs (string items, xstring escapes), xlsb.rs and its tests, split from GridCraft's document model. * craft-xlsx: XLSB opens up to 4 million cells, records counted once over the workbook Binary workbooks over 1 million cells were refused, a quarter of what .xls allows, and an allowance of 5 million records per part refused ordinary files: Excel writes four records per non-empty row besides the cells, and one BrtCellBlank for every formatted empty cell. Cells are now 4 million over the workbook (also the most shared strings), text 256 MiB, and records 40 million for every part together, as in craft-xls. Ported from storytold/gridcraft#3807. --------- Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
This commit is contained in:
1 parent
712e6fa5e3
commit
119415f46f
16 files changed
+3039
No files matched your search
Generated
+10
@@ -1829,6 +1829,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xlsx"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-opc",
|
||||
"craft-sheet",
|
||||
"craft-xml",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -106,6 +106,7 @@ craft-vcard = { path = "crates/vcard" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
craft-vectordrawable = { path = "crates/vectordrawable" }
|
||||
craft-xls = { path = "crates/xls" }
|
||||
craft-xlsx = { path = "crates/xlsx" }
|
||||
craft-xcf = { path = "crates/xcf" }
|
||||
craft-xml = { path = "crates/xml" }
|
||||
craft-zip = { path = "crates/zip" }
|
||||
|
||||
@@ -290,6 +290,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
JSON control channel.
|
||||
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
|
||||
- [`craft-xls`](crates/xls/README.md): clean-room Excel 97-2003 workbook (`.xls`, BIFF8) reader: worksheets, cell values, formulas as A1 text (shared and array formulas included), cell formats with resolved palette colours, merged cells, column and row records, defined names, print areas and calculation settings, with limits on records, cells and text.
|
||||
- [`craft-xlsx`](crates/xlsx/README.md): Excel workbooks (`.xlsx`, `.xlsm`, SpreadsheetML, transitional and strict) and binary workbooks (`.xlsb`) on `craft-opc`: the workbook part, shared strings, the styles tables and worksheet rows streamed one at a time, with limits on the package, the XML and the binary records.
|
||||
- [`craft-ora`](crates/ora/README.md): OpenRaster documents (`.ora`): the ZIP archive and the `stack.xml` layer tree as written, read with bounded sizes and written. From PhotoCraft.
|
||||
- [`craft-y4m`](crates/y4m/src/lib.rs): YUV4MPEG2 (`.y4m`) reader, from FilmCraft: the stream
|
||||
header, colour space layouts at 8 to 16 bits, a frame index built from the `FRAME` lines without
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-xlsx"
|
||||
description = "Excel workbooks (.xlsx, .xlsm, SpreadsheetML in an OPC package) and binary workbooks (.xlsb): the workbook part, shared strings, the styles tables and a worksheet's rows and cells as a stream, with every other part reachable for the caller."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-opc = { workspace = true }
|
||||
craft-sheet = { workspace = true }
|
||||
craft-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,41 @@
|
||||
# craft-xlsx
|
||||
|
||||
Excel workbooks: `.xlsx`, `.xlsm`, `.xltx`, `.xltm` (SpreadsheetML in an OPC package,
|
||||
transitional and strict) and `.xlsb` (the same package with binary parts). Clean-room, from
|
||||
ECMA-376 Part 1 (SpreadsheetML), [MS-XLSB] and observed files. No `unsafe`.
|
||||
|
||||
This is the part layer. `Package::open` opens the container through `craft-opc` and reads:
|
||||
|
||||
| Call | Gives |
|
||||
|---|---|
|
||||
| `workbook()` | `Workbook`: sheets in tab order (name, id, visibility, kind, part), the 1904 date flag, defined names, the active tab, and the whole part as a tree |
|
||||
| `shared_strings()` | `StringItem`s: text, formatted runs, phonetic readings |
|
||||
| `styles()` | `Styles`: number formats, fonts, fills, borders, cell and cell-style formats, named styles, differential formats, the indexed palette, as indexed tables |
|
||||
| `sheet(entry, each)` | each `Row` of `Cell`s to a callback, then a `SheetPart`: the rest of the worksheet as a tree, its relationships, columns and merged cells |
|
||||
| `opc()`, `read_xml()`, `optional_xml()`, `rels()` | any other part |
|
||||
|
||||
A cell keeps the file's terms: a shared-string index, a format index, the formula text as the
|
||||
file spells it, value and cell metadata indexes. `CellValue::resolve` gives the value it shows.
|
||||
Shared formulas are not expanded and formulas are not parsed: that needs a formula grammar.
|
||||
|
||||
A binary workbook gives the same `Workbook`, strings, rows and merged cells, with saved values
|
||||
only: its formulas are token streams that are not decoded, and its styles are not read (each
|
||||
cell keeps its format index).
|
||||
|
||||
## Hostile input
|
||||
|
||||
- `Limits.package`: ZIP entries, bytes per part and in all (`craft-opc`).
|
||||
- `Limits.xml`: one XML part. Rows and shared strings are streamed, so this bounds one row, one
|
||||
string and what is left of a part, not a sheet's length.
|
||||
- `Limits.binary_records`, `binary_cells`, `binary_text`: a binary workbook, over all its parts
|
||||
(40 million records, 4 million cells and as many shared strings, 256 MiB of text, as
|
||||
`craft-xls` allows). Fixed caps: 1024 sheets, 100,000 merged ranges per sheet, 64 nested
|
||||
extension blocks.
|
||||
- Cells and rows outside the 1,048,576 × 16,384 grid are dropped and counted.
|
||||
|
||||
SpreadsheetML is read leniently: a damaged optional part is a warning, a damaged worksheet keeps
|
||||
the rows before the damage. A binary part that does not parse fails the read, since skipping a
|
||||
record would lose data without a trace.
|
||||
|
||||
Shared with `craft-xls` and `craft-ods` through `craft-sheet`: `CellRef`, `Range`, `Value`,
|
||||
`CellError`, `Visibility`, sheet-name quoting, built-in number formats and the palette.
|
||||
@@ -0,0 +1,148 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! Excel workbooks: `.xlsx`/`.xlsm` (SpreadsheetML, ECMA-376 Part 1, transitional and strict) and
|
||||
//! `.xlsb` (the same package with binary parts, [MS-XLSB]). Clean-room, from the specifications
|
||||
//! and observed files.
|
||||
//!
|
||||
//! This is the part layer: a [`Package`] opens the OPC container (`craft-opc`) and reads
|
||||
//!
|
||||
//! - the workbook part: sheets, the date system, defined names ([`Workbook`]);
|
||||
//! - shared strings with their rich-text runs and phonetic readings ([`StringItem`]);
|
||||
//! - the styles tables as the file has them ([`Styles`]): number formats, fonts, fills, borders,
|
||||
//! cell formats, named styles and differential formats, by index;
|
||||
//! - one worksheet at a time as a stream of [`Row`]s of [`Cell`]s, so a sheet of any length is
|
||||
//! read without holding it; what is left of the part (properties, columns, merged cells,
|
||||
//! validation, and so on) comes back as a tree ([`SheetPart`]).
|
||||
//!
|
||||
//! Nothing is interpreted beyond the format: a cell holds a shared-string index and a format
|
||||
//! index, a width is in the file's units, a formula is the file's text. Every other part
|
||||
//! (drawings, charts, pivot caches, macros) is reachable through [`Package::opc`], so a caller
|
||||
//! can read it or carry it through unchanged.
|
||||
//!
|
||||
//! A binary workbook gives the same [`Workbook`], [`StringItem`]s, [`Row`]s and merged cells:
|
||||
//! saved values only, since formulas there are token streams that are not decoded and styles are
|
||||
//! not read.
|
||||
//!
|
||||
//! Reading SpreadsheetML is lenient: a damaged optional part is skipped and named in
|
||||
//! [`Package::warnings`]; only an unreadable package or workbook part is an error. Reading a
|
||||
//! binary workbook is strict: a record that does not parse fails the read, because skipping it
|
||||
//! would lose data silently. Input is hostile: [`Limits`] bounds the package, the XML and the
|
||||
//! binary records, cells outside the grid are dropped, and nothing indexes by a number from the
|
||||
//! file.
|
||||
//!
|
||||
//! ```
|
||||
//! # fn main() -> Result<(), craft_xlsx::Error> {
|
||||
//! # let bytes = craft_xlsx::testing::workbook(&[("Prices", r#"<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>2.5</v></c></row>"#)], &["Tea"]);
|
||||
//! let mut package = craft_xlsx::Package::open(&bytes, &craft_xlsx::Limits::default())?;
|
||||
//! let workbook = package.workbook()?;
|
||||
//! let strings = package.shared_strings()?;
|
||||
//! let mut values = Vec::new();
|
||||
//! package.sheet(&workbook.sheets[0], |row| {
|
||||
//! values.extend(row.cells.iter().map(|c| c.value.resolve(&strings, workbook.date_1904)));
|
||||
//! Ok(())
|
||||
//! })?;
|
||||
//! assert_eq!(values, [craft_xlsx::Value::text("Tea"), craft_xlsx::Value::Number(2.5)]);
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
mod package;
|
||||
mod sheet;
|
||||
mod strings;
|
||||
mod styles;
|
||||
#[doc(hidden)]
|
||||
pub mod testing;
|
||||
mod workbook;
|
||||
mod xlsb;
|
||||
|
||||
pub use craft_sheet::{
|
||||
BorderStyle, CellError, CellRef, DEFAULT_PALETTE, HAlign, MAX_COLS, MAX_ROWS, Pattern, Range, Script, Underline, VAlign, Value, Visibility,
|
||||
builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes,
|
||||
};
|
||||
pub use package::{Package, is_xlsb};
|
||||
pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial};
|
||||
pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text};
|
||||
pub use styles::{
|
||||
Alignment, Border, BorderEdge, CellStyle, Color, ColorSource, Dxf, Fill, Font, FontScheme, GradientFill, PatternFill, Protection, Styles, Xf,
|
||||
};
|
||||
pub use workbook::{DefinedName, SheetEntry, SheetKind, Workbook};
|
||||
|
||||
/// Why a workbook could not be read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The bytes are not a ZIP package, or an entry is damaged.
|
||||
#[error("the file is not a valid zip package: {0}")]
|
||||
Zip(String),
|
||||
/// A part that must be read holds XML that cannot be.
|
||||
#[error("the file contains invalid XML: {0}")]
|
||||
Xml(String),
|
||||
/// The package is not a workbook, or a binary part is damaged.
|
||||
#[error("the file is not a valid spreadsheet: {0}")]
|
||||
Invalid(String),
|
||||
/// More than [`Limits`] or a fixed cap allows.
|
||||
#[error("the file is too large: {0}")]
|
||||
TooLarge(String),
|
||||
}
|
||||
|
||||
impl From<craft_opc::Error> for Error {
|
||||
fn from(e: craft_opc::Error) -> Self {
|
||||
match e {
|
||||
e if e.is_limit() => Error::TooLarge(e.to_string()),
|
||||
craft_opc::Error::Zip(m) => Error::Zip(m),
|
||||
craft_opc::Error::Xml { .. } => Error::Xml(e.to_string()),
|
||||
e => Error::Invalid(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<craft_xml::Error> for Error {
|
||||
fn from(e: craft_xml::Error) -> Self {
|
||||
if e.is_limit() { Error::TooLarge(e.to_string()) } else { Error::Xml(e.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a read refuses to exceed. The defaults suit a spreadsheet application; a caller that
|
||||
/// only wants a table of values can lower them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// The ZIP package: entries, bytes per part and bytes in all.
|
||||
pub package: craft_opc::Limits,
|
||||
/// One XML part. Rows and shared strings are streamed, so `max_nodes` bounds one row or one
|
||||
/// string and what is left of a part, not a sheet's length.
|
||||
pub xml: craft_xml::Limits,
|
||||
/// Records read from a binary workbook, every part together. Records are skipped or decoded
|
||||
/// without being kept, so this bounds parse time, not memory. Excel writes four records per
|
||||
/// row besides the cells, and one per formatted empty cell, so a sheet needs many more
|
||||
/// records than cells.
|
||||
pub binary_records: usize,
|
||||
/// Cells with a value read from a binary workbook, over all its sheets; also the most
|
||||
/// shared-string items.
|
||||
pub binary_cells: usize,
|
||||
/// Bytes of decoded text (shared and inline strings) in a binary workbook, over all its parts.
|
||||
pub binary_text: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
package: craft_opc::Limits::new(200_000, 512 << 20, 1 << 30),
|
||||
xml: craft_xml::Limits { max_input: 512 << 20, ..craft_xml::Limits::DEFAULT },
|
||||
// The limits of `craft-xls`.
|
||||
binary_records: 40_000_000,
|
||||
binary_cells: 4_000_000,
|
||||
binary_text: 256 << 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a read skipped or repaired, each distinct message once and at most 1000 of them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Warnings(pub(crate) Vec<String>);
|
||||
|
||||
impl Warnings {
|
||||
pub(crate) fn warn(&mut self, m: impl Into<String>) {
|
||||
let m = m.into();
|
||||
if self.0.len() < 1000 && !self.0.contains(&m) {
|
||||
self.0.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! The OPC package of a workbook and the parts this crate reads from it.
|
||||
|
||||
use craft_opc::{ContentTypes, LazyPackage, Rels};
|
||||
use craft_xml::{El, Options, UnknownEntity};
|
||||
|
||||
use crate::sheet::{Row, RowReader, SheetPart};
|
||||
use crate::strings::StringItem;
|
||||
use crate::styles::Styles;
|
||||
use crate::workbook::{SheetEntry, Workbook};
|
||||
use crate::{Error, Limits, Warnings, xlsb};
|
||||
|
||||
/// Most bytes of `[Content_Types].xml` read from a binary workbook.
|
||||
const MAX_CONTENT_TYPES: u64 = 1 << 20;
|
||||
|
||||
/// An open workbook package. Parts are inflated when they are asked for, against the
|
||||
/// package's [`Limits`].
|
||||
pub struct Package<'a> {
|
||||
opc: LazyPackage<'a>,
|
||||
limits: Limits,
|
||||
warnings: Warnings,
|
||||
workbook_part: String,
|
||||
/// Content types, for a binary workbook (which checks every part it reads against them).
|
||||
binary: Option<ContentTypes>,
|
||||
workbook_rels: Option<Rels>,
|
||||
budget: xlsb::Budget,
|
||||
/// Shared strings in a binary workbook, once counted: a cell's index is checked against it.
|
||||
binary_strings: Option<usize>,
|
||||
}
|
||||
|
||||
/// Is this a binary workbook (`.xlsb`): a package whose one workbook relationship points at a
|
||||
/// part of the XLSB workbook content type?
|
||||
pub fn is_xlsb(bytes: &[u8]) -> bool {
|
||||
Package::open(bytes, &Limits::default()).is_ok_and(|p| p.is_binary())
|
||||
}
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
/// Opens the package and finds the workbook part. Nothing else is read yet.
|
||||
pub fn open(bytes: &'a [u8], limits: &Limits) -> Result<Package<'a>, Error> {
|
||||
let mut opc = LazyPackage::open(bytes, &limits.package)?;
|
||||
let root = opc.rels("")?;
|
||||
let documents: Vec<_> = root.all("officeDocument").collect();
|
||||
let mut binary = None;
|
||||
let workbook_part = match documents.first() {
|
||||
Some(rel) if !rel.external && rel.target.to_ascii_lowercase().ends_with(".bin") => {
|
||||
let types = opc.read_limited("[Content_Types].xml", MAX_CONTENT_TYPES)?.map(|b| ContentTypes::parse(&b)).unwrap_or_default();
|
||||
if documents.len() != 1 {
|
||||
return Err(xlsb::invalid("expected one workbook relationship"));
|
||||
}
|
||||
xlsb::check_type(&types, &rel.target, xlsb::MAIN)?;
|
||||
binary = Some(types);
|
||||
rel.target.clone()
|
||||
}
|
||||
Some(rel) if !rel.external => rel.target.clone(),
|
||||
_ => "xl/workbook.xml".to_string(),
|
||||
};
|
||||
Ok(Package {
|
||||
opc,
|
||||
limits: *limits,
|
||||
warnings: Warnings::default(),
|
||||
workbook_part,
|
||||
binary,
|
||||
workbook_rels: None,
|
||||
budget: xlsb::Budget::default(),
|
||||
binary_strings: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A binary workbook (`.xlsb`).
|
||||
pub fn is_binary(&self) -> bool {
|
||||
self.binary.is_some()
|
||||
}
|
||||
|
||||
/// The workbook part's name.
|
||||
pub fn workbook_part(&self) -> &str {
|
||||
&self.workbook_part
|
||||
}
|
||||
|
||||
/// What reading skipped or repaired so far, each message once.
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.warnings.0
|
||||
}
|
||||
|
||||
/// Adds a warning (a caller reading further parts keeps one list).
|
||||
pub fn warn(&mut self, m: impl Into<String>) {
|
||||
self.warnings.warn(m);
|
||||
}
|
||||
|
||||
/// The package underneath, for the parts this crate does not read.
|
||||
pub fn opc(&mut self) -> &mut LazyPackage<'a> {
|
||||
&mut self.opc
|
||||
}
|
||||
|
||||
/// The XML options parts are read with: local names, an undefined entity kept as written,
|
||||
/// [`Limits::xml`].
|
||||
pub fn xml_options(&self) -> Options<'static> {
|
||||
Options { unknown_entity: UnknownEntity::Literal, ..Options::local() }.with_limits(self.limits.xml)
|
||||
}
|
||||
|
||||
/// Part `part` as XML; `Ok(None)` when there is no such part.
|
||||
pub fn read_xml(&mut self, part: &str) -> Result<Option<El>, Error> {
|
||||
let options = self.xml_options();
|
||||
Ok(self.opc.read_xml(part, &options)?)
|
||||
}
|
||||
|
||||
/// A part the workbook can do without: missing or unreadable is a warning and `None`;
|
||||
/// only a limit is an error.
|
||||
pub fn optional_xml(&mut self, part: &str) -> Result<Option<El>, Error> {
|
||||
match self.read_xml(part) {
|
||||
Ok(Some(e)) => Ok(Some(e)),
|
||||
Ok(None) => {
|
||||
self.warn(format!("missing part {part}"));
|
||||
Ok(None)
|
||||
}
|
||||
Err(Error::TooLarge(m)) => Err(Error::TooLarge(m)),
|
||||
Err(e) => {
|
||||
self.warn(format!("skipped {part}: {e}"));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationships of `part`.
|
||||
pub fn rels(&mut self, part: &str) -> Result<Rels, Error> {
|
||||
Ok(self.opc.rels(part)?)
|
||||
}
|
||||
|
||||
/// The workbook part's relationships.
|
||||
pub fn workbook_rels(&mut self) -> Result<Rels, Error> {
|
||||
if let Some(r) = &self.workbook_rels {
|
||||
return Ok(r.clone());
|
||||
}
|
||||
let rels = self.opc.rels(&self.workbook_part)?;
|
||||
self.workbook_rels = Some(rels.clone());
|
||||
Ok(rels)
|
||||
}
|
||||
|
||||
/// The workbook part.
|
||||
pub fn workbook(&mut self) -> Result<Workbook, Error> {
|
||||
if self.binary.is_some() {
|
||||
let bytes = self.required(&self.workbook_part.clone())?;
|
||||
let rels = self.workbook_rels()?;
|
||||
return xlsb::workbook(&bytes, &rels, &self.limits, &self.budget, &mut self.warnings);
|
||||
}
|
||||
let options = self.xml_options();
|
||||
let root = match self.opc.read_xml(&self.workbook_part, &options)? {
|
||||
Some(root) => root,
|
||||
None if self.workbook_part != "xl/workbook.xml" && self.opc.has("xl/workbook.xml") => {
|
||||
self.workbook_part = "xl/workbook.xml".into();
|
||||
self.workbook_rels = None;
|
||||
self.opc.read_xml("xl/workbook.xml", &options)?.ok_or_else(|| Error::Invalid("no workbook part".into()))?
|
||||
}
|
||||
None if self.opc.has("content.xml") => return Err(Error::Invalid("this is an OpenDocument file, not XLSX".into())),
|
||||
None => return Err(Error::Invalid("no workbook part".into())),
|
||||
};
|
||||
if root.name != "workbook" {
|
||||
return Err(Error::Invalid(format!("unexpected root element <{}> in workbook part", root.name)));
|
||||
}
|
||||
let rels = self.workbook_rels()?;
|
||||
Ok(Workbook::from_xml(root, &rels))
|
||||
}
|
||||
|
||||
/// The shared strings, empty when the workbook has none.
|
||||
pub fn shared_strings(&mut self) -> Result<Vec<StringItem>, Error> {
|
||||
let rels = self.workbook_rels()?;
|
||||
if self.binary.is_some() {
|
||||
let found: Vec<_> = rels.all("sharedStrings").collect();
|
||||
let Some(rel) = found.first() else {
|
||||
self.binary_strings = Some(0);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if found.len() > 1 {
|
||||
return Err(xlsb::invalid("multiple shared-string relationships"));
|
||||
}
|
||||
if rel.external {
|
||||
return Err(xlsb::invalid("external shared-string relationship"));
|
||||
}
|
||||
if let Some(types) = &self.binary {
|
||||
xlsb::check_type(types, &rel.target, xlsb::STRINGS)?;
|
||||
}
|
||||
let bytes = self.required(&rel.target)?;
|
||||
let strings = xlsb::shared_strings(&bytes, &self.limits, &self.budget, &mut self.warnings)?;
|
||||
self.binary_strings = Some(strings.len());
|
||||
return Ok(strings);
|
||||
}
|
||||
let Some(rel) = rels.first("sharedStrings").filter(|r| !r.external) else { return Ok(Vec::new()) };
|
||||
let Some(bytes) = self.opc.read(&rel.target)? else {
|
||||
self.warn(format!("missing part {}", rel.target));
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let (_, err) = self.xml_options().parse_streaming_partial(&bytes, &["si"], |si| {
|
||||
items.push(StringItem::read(&si));
|
||||
Ok::<(), craft_xml::Error>(())
|
||||
});
|
||||
if let Some(e) = err {
|
||||
self.warn(format!("shared strings are damaged ({e}); kept {} strings", items.len()));
|
||||
}
|
||||
let dropped: usize = items.iter().map(|s| s.dropped_phonetic_runs).sum();
|
||||
if dropped > 0 {
|
||||
self.warn(format!("shared strings: {dropped} phonetic runs were dropped (bad sb/eb or no reading)"));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// The styles part, `None` when the workbook has none (or is binary: its styles are not read).
|
||||
pub fn styles(&mut self) -> Result<Option<Styles>, Error> {
|
||||
if self.binary.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rels = self.workbook_rels()?;
|
||||
let Some(rel) = rels.first("styles").filter(|r| !r.external) else {
|
||||
self.warn("no styles part");
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.optional_xml(&rel.target.clone())?.map(Styles::read))
|
||||
}
|
||||
|
||||
/// Reads a worksheet, handing each row to `each` in file order; an error from `each` stops the
|
||||
/// read and is returned. A damaged worksheet gives the rows before the damage and says so
|
||||
/// in [`SheetPart::damaged`] and in the warnings.
|
||||
pub fn sheet(&mut self, entry: &SheetEntry, mut each: impl FnMut(Row) -> Result<(), Error>) -> Result<SheetPart, Error> {
|
||||
let part = entry.part.clone().ok_or_else(|| Error::Invalid(format!("sheet \"{}\" has no part", entry.name)))?;
|
||||
if self.binary.is_some() {
|
||||
if let Some(types) = &self.binary {
|
||||
xlsb::check_type(types, &part, xlsb::WORKSHEET)?;
|
||||
}
|
||||
let strings = match self.binary_strings {
|
||||
Some(n) => n,
|
||||
None => self.shared_strings()?.len(),
|
||||
};
|
||||
let bytes = self.required(&part)?;
|
||||
let mut sheet = xlsb::worksheet(&bytes, strings, &self.limits, &self.budget, &mut self.warnings, &mut each)?;
|
||||
sheet.part = part;
|
||||
return Ok(sheet);
|
||||
}
|
||||
let bytes = self.required(&part)?;
|
||||
let rels = self.opc.rels(&part)?;
|
||||
let mut rows = RowReader::default();
|
||||
let (root, err) = self.xml_options().parse_streaming_partial(&bytes, &["row"], |e| match rows.row(&e) {
|
||||
Some(row) => each(row).map_err(Stop::Caller),
|
||||
None => Ok(()),
|
||||
});
|
||||
let damaged = match err {
|
||||
Some(Stop::Caller(e)) => return Err(e),
|
||||
Some(Stop::Xml(e)) => {
|
||||
self.warn(format!("sheet \"{}\" is damaged ({e}); kept {} rows", entry.name, rows.rows));
|
||||
Some(e.to_string())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if rows.dropped > 0 {
|
||||
self.warn(format!("sheet \"{}\": {} cells or rows outside the 1,048,576 × 16,384 grid were dropped", entry.name, rows.dropped));
|
||||
}
|
||||
let mut sheet =
|
||||
SheetPart { part, root: root.unwrap_or_default(), rels, rows: rows.rows, dropped: rows.dropped, damaged, ..SheetPart::default() };
|
||||
sheet.lift();
|
||||
Ok(sheet)
|
||||
}
|
||||
|
||||
fn required(&mut self, part: &str) -> Result<Vec<u8>, Error> {
|
||||
self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a streamed worksheet stopped: the XML, or the caller.
|
||||
enum Stop {
|
||||
Xml(craft_xml::Error),
|
||||
Caller(Error),
|
||||
}
|
||||
|
||||
impl From<craft_xml::Error> for Stop {
|
||||
fn from(e: craft_xml::Error) -> Self {
|
||||
Stop::Xml(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! A worksheet part (`CT_Worksheet`, ECMA-376 Part 1, 18.3): rows and cells as a stream, the
|
||||
//! rest of the part as a tree.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_opc::Rels;
|
||||
use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value};
|
||||
use craft_xml::El;
|
||||
|
||||
use crate::strings::{StringItem, decode_xstring};
|
||||
|
||||
/// What a cell's `<v>` holds, by its type (`t`, 18.18.11).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum CellValue {
|
||||
#[default]
|
||||
Empty,
|
||||
Number(f64),
|
||||
/// `t="s"`: an index into the shared strings.
|
||||
Shared(u32),
|
||||
/// `t="str"` (a formula's text result), `_xHHHH_` escapes decoded.
|
||||
Text(Arc<str>),
|
||||
/// `t="inlineStr"`: the `<is>` item, or the `<v>` text when there is no `<is>`.
|
||||
Inline(StringItem),
|
||||
Bool(bool),
|
||||
/// `t="e"`. An error text no [`CellError`] has reads as `#VALUE!`.
|
||||
Error(CellError),
|
||||
/// `t="d"`: an ISO 8601 date, time or both, as written. See [`iso_date_serial`].
|
||||
Date(String),
|
||||
/// A `<v>` that does not read as its type says (a number that is not one, a shared-string
|
||||
/// index that is not a number). Kept for the caller to report.
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
/// The value a cell shows: shared strings looked up, dates as serial numbers in the
|
||||
/// workbook's date system. A missing shared string, an unreadable date or an invalid value
|
||||
/// is empty.
|
||||
pub fn resolve(&self, strings: &[StringItem], date_1904: bool) -> Value {
|
||||
match self {
|
||||
CellValue::Empty | CellValue::Invalid(_) => Value::Empty,
|
||||
CellValue::Number(n) => Value::number(*n),
|
||||
CellValue::Shared(i) => strings.get(*i as usize).map_or(Value::Empty, |s| Value::Text(s.text.clone())),
|
||||
CellValue::Text(t) => Value::Text(t.clone()),
|
||||
CellValue::Inline(s) => Value::Text(s.text.clone()),
|
||||
CellValue::Bool(b) => Value::Bool(*b),
|
||||
CellValue::Error(e) => Value::Error(*e),
|
||||
CellValue::Date(d) => iso_date_serial(d, date_1904).map_or(Value::Empty, Value::number),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<f>`'s `t` (18.18.6).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum FormulaKind {
|
||||
#[default]
|
||||
Normal,
|
||||
Shared,
|
||||
Array,
|
||||
/// A what-if data table: `r1`, `r2` and its flags are in [`CellFormula::attrs`].
|
||||
DataTable,
|
||||
}
|
||||
|
||||
/// A cell's `<f>`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CellFormula {
|
||||
pub kind: FormulaKind,
|
||||
/// The formula as the file spells it (`_xlfn.` prefixes and all), trimmed. Empty for a
|
||||
/// cell that shares the formula of its group's first cell.
|
||||
pub text: String,
|
||||
/// `si`: the shared formula group.
|
||||
pub shared_index: Option<u32>,
|
||||
/// `ref`: the range of an array formula or of a shared formula group.
|
||||
pub range: Option<Range>,
|
||||
/// Every attribute as written.
|
||||
pub attrs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One `<c>`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Cell {
|
||||
pub at: CellRef,
|
||||
/// `s`: an index into [`crate::Styles::cell_xfs`], 0 when absent.
|
||||
pub style: u32,
|
||||
pub value: CellValue,
|
||||
pub formula: Option<CellFormula>,
|
||||
/// `vm`: value metadata (a rich value such as a picture in the cell), 1-based.
|
||||
pub value_metadata: Option<u32>,
|
||||
/// `cm`: cell metadata (a dynamic array formula), 1-based.
|
||||
pub cell_metadata: Option<u32>,
|
||||
/// `ph`: show the phonetic reading.
|
||||
pub phonetic: bool,
|
||||
}
|
||||
|
||||
/// One `<row>` and its cells.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Row {
|
||||
/// Zero-based.
|
||||
pub index: u32,
|
||||
/// `ht`, points.
|
||||
pub height: Option<f64>,
|
||||
pub custom_height: bool,
|
||||
pub hidden: bool,
|
||||
pub outline_level: u8,
|
||||
pub collapsed: bool,
|
||||
/// `s` when `customFormat` is set: the format of the row's empty cells.
|
||||
pub style: Option<u32>,
|
||||
pub cells: Vec<Cell>,
|
||||
}
|
||||
|
||||
/// One `<col>`: columns `first..=last` (zero-based).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Column {
|
||||
pub first: u32,
|
||||
pub last: u32,
|
||||
/// Characters of the default font's maximum digit width, padding included.
|
||||
pub width: Option<f64>,
|
||||
pub custom_width: bool,
|
||||
pub best_fit: bool,
|
||||
pub hidden: bool,
|
||||
pub outline_level: u8,
|
||||
pub collapsed: bool,
|
||||
pub style: Option<u32>,
|
||||
}
|
||||
|
||||
/// A worksheet after its rows were handed out: the rest of the part and what was lifted out of it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SheetPart {
|
||||
pub part: String,
|
||||
/// The worksheet element without its rows: properties, views, columns, merged cells,
|
||||
/// conditional formats, validation, hyperlinks, page setup, extensions. Empty for a binary
|
||||
/// worksheet.
|
||||
pub root: El,
|
||||
pub rels: Rels,
|
||||
pub columns: Vec<Column>,
|
||||
pub merges: Vec<Range>,
|
||||
/// Rows read.
|
||||
pub rows: u64,
|
||||
/// Rows and cells dropped because their position was outside the grid or unreadable.
|
||||
pub dropped: u64,
|
||||
/// Why reading stopped early, when the part is damaged; the rows before the damage were
|
||||
/// handed out.
|
||||
pub damaged: Option<String>,
|
||||
}
|
||||
|
||||
impl SheetPart {
|
||||
pub(crate) fn lift(&mut self) {
|
||||
if let Some(cols) = self.root.child("cols") {
|
||||
for c in cols.children("col") {
|
||||
let (Some(min), Some(max)) = (c.attr_u32("min"), c.attr_u32("max")) else { continue };
|
||||
if min == 0 || min > MAX_COLS || max < min {
|
||||
continue;
|
||||
}
|
||||
self.columns.push(Column {
|
||||
first: min - 1,
|
||||
last: max.min(MAX_COLS) - 1,
|
||||
width: c.attr_f64("width"),
|
||||
custom_width: c.flag("customWidth", false),
|
||||
best_fit: c.flag("bestFit", false),
|
||||
hidden: c.flag("hidden", false),
|
||||
outline_level: c.attr_u32("outlineLevel").unwrap_or(0).min(7) as u8,
|
||||
collapsed: c.flag("collapsed", false),
|
||||
style: c.attr_u32("style"),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.root.child("mergeCells") {
|
||||
self.merges.extend(m.children("mergeCell").filter_map(|m| m.attr("ref").and_then(Range::parse)).filter(|r| !r.is_single()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads rows one at a time, remembering where the last one ended.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RowReader {
|
||||
next_row: u32,
|
||||
pub(crate) rows: u64,
|
||||
pub(crate) dropped: u64,
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
/// `None` for a row outside the grid (counted in `dropped`).
|
||||
pub(crate) fn row(&mut self, e: &El) -> Option<Row> {
|
||||
self.rows += 1;
|
||||
let index = match e.attr_u32("r") {
|
||||
Some(r) if (1..=MAX_ROWS).contains(&r) => r - 1,
|
||||
Some(_) => {
|
||||
self.dropped += 1;
|
||||
return None;
|
||||
}
|
||||
None if self.next_row < MAX_ROWS => self.next_row,
|
||||
None => {
|
||||
self.dropped += 1;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.next_row = index + 1;
|
||||
let mut row = Row {
|
||||
index,
|
||||
height: e.attr_f64("ht"),
|
||||
custom_height: e.flag("customHeight", false),
|
||||
hidden: e.flag("hidden", false),
|
||||
outline_level: e.attr_u32("outlineLevel").unwrap_or(0).min(7) as u8,
|
||||
collapsed: e.flag("collapsed", false),
|
||||
style: if e.flag("customFormat", false) { e.attr_u32("s") } else { None },
|
||||
cells: Vec::new(),
|
||||
};
|
||||
let mut next_col = 0u32;
|
||||
for c in e.children("c") {
|
||||
let at = match c.attr("r") {
|
||||
Some(a) => match CellRef::parse(a.trim()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None if next_col < MAX_COLS => CellRef::new(index, next_col),
|
||||
None => {
|
||||
self.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
next_col = at.col + 1;
|
||||
row.cells.push(cell(c, at));
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(c: &El, at: CellRef) -> Cell {
|
||||
let v = c.child("v").map(|v| v.text());
|
||||
let v = v.as_deref();
|
||||
let value = match c.attr("t").unwrap_or("n") {
|
||||
"s" => match v.map(str::trim) {
|
||||
Some(t) => t.parse().map_or_else(|_| CellValue::Invalid(t.to_string()), CellValue::Shared),
|
||||
None => CellValue::Empty,
|
||||
},
|
||||
"str" => v.map_or(CellValue::Empty, |v| CellValue::Text(decode_xstring(v).into())),
|
||||
"inlineStr" => match c.child("is") {
|
||||
Some(is) => CellValue::Inline(StringItem::read(is)),
|
||||
None => v.map_or(CellValue::Empty, |v| CellValue::Text(decode_xstring(v).into())),
|
||||
},
|
||||
"b" => v.map_or(CellValue::Empty, |v| CellValue::Bool(matches!(v.trim(), "1" | "true" | "TRUE"))),
|
||||
"e" => v.map_or(CellValue::Empty, |v| CellValue::Error(CellError::parse(v.trim()).unwrap_or(CellError::Value))),
|
||||
"d" => match v.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(d) => CellValue::Date(d.to_string()),
|
||||
None => CellValue::Empty,
|
||||
},
|
||||
_ => match v.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(t) => match t.parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => CellValue::Number(if n == 0.0 { 0.0 } else { n }),
|
||||
_ => CellValue::Invalid(t.to_string()),
|
||||
},
|
||||
None => CellValue::Empty,
|
||||
},
|
||||
};
|
||||
let formula = c.child("f").map(|f| CellFormula {
|
||||
kind: match f.attr("t") {
|
||||
Some("shared") => FormulaKind::Shared,
|
||||
Some("array") => FormulaKind::Array,
|
||||
Some("dataTable") => FormulaKind::DataTable,
|
||||
_ => FormulaKind::Normal,
|
||||
},
|
||||
text: f.text().trim().to_string(),
|
||||
shared_index: f.attr_u32("si"),
|
||||
range: f.attr("ref").and_then(Range::parse),
|
||||
attrs: f.attrs.clone(),
|
||||
});
|
||||
Cell {
|
||||
at,
|
||||
style: c.attr_u32("s").unwrap_or(0),
|
||||
value,
|
||||
formula,
|
||||
value_metadata: c.attr_u32("vm"),
|
||||
cell_metadata: c.attr_u32("cm"),
|
||||
phonetic: c.flag("ph", false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Days from 1970-01-01 to a proleptic Gregorian date (the civil-from-days inverse).
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// The serial number of a `t="d"` value: `2024-02-29`, `2024-02-29T13:45:30`, `13:45` (a time
|
||||
/// alone), a trailing `Z` or `+hh:mm` ignored. Years 1900 (1904 in the 1904 system) to 9999;
|
||||
/// a day past the month's end counts on into the next month, as Excel's `DATE` does. In the
|
||||
/// 1900 system, dates before 1 March 1900 are one lower than the day count, for the 29 February
|
||||
/// 1900 that Excel counts.
|
||||
pub fn iso_date_serial(s: &str, date_1904: bool) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches('Z');
|
||||
let (date, time) = match s.split_once('T') {
|
||||
Some((d, t)) => (d, Some(t)),
|
||||
None if s.contains(':') => ("", Some(s)),
|
||||
None => (s, None),
|
||||
};
|
||||
let mut serial = 0.0;
|
||||
if !date.is_empty() {
|
||||
let mut it = date.split('-');
|
||||
let y: i64 = it.next()?.parse().ok()?;
|
||||
let m: i64 = it.next()?.parse().ok()?;
|
||||
let d: i64 = it.next()?.parse().ok()?;
|
||||
if it.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) || y > 9999 || y < if date_1904 { 1904 } else { 1900 } {
|
||||
return None;
|
||||
}
|
||||
let first = days_from_civil(y, m, 1);
|
||||
let first =
|
||||
if date_1904 { first - days_from_civil(1904, 1, 1) } else { first - days_from_civil(1899, 12, 30) - i64::from(y == 1900 && m <= 2) };
|
||||
let n = first + d - 1;
|
||||
let max = days_from_civil(9999, 12, 31) - if date_1904 { days_from_civil(1904, 1, 1) } else { days_from_civil(1899, 12, 30) };
|
||||
if !(0..=max).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
serial = n as f64;
|
||||
}
|
||||
if let Some(t) = time {
|
||||
let t = t.split('+').next().unwrap_or(t);
|
||||
let mut it = t.split(':');
|
||||
let h: f64 = it.next()?.parse().ok()?;
|
||||
let mi: f64 = it.next().unwrap_or("0").parse().ok()?;
|
||||
let se: f64 = it.next().unwrap_or("0").parse().ok()?;
|
||||
if !(0.0..24.0).contains(&h) || !(0.0..60.0).contains(&mi) || !(0.0..61.0).contains(&se) {
|
||||
return None;
|
||||
}
|
||||
serial += (h * 3600.0 + mi * 60.0 + se) / 86400.0;
|
||||
}
|
||||
Some(serial)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(iso_date_serial("1900-01-01", false), Some(1.0));
|
||||
assert_eq!(iso_date_serial("1900-02-28", false), Some(59.0));
|
||||
assert_eq!(iso_date_serial("1900-03-01", false), Some(61.0));
|
||||
assert_eq!(iso_date_serial("2024-02-29T12:00:00Z", false), Some(45351.5));
|
||||
assert_eq!(iso_date_serial("1904-01-02", true), Some(1.0));
|
||||
assert_eq!(iso_date_serial("06:00", false), Some(0.25));
|
||||
assert_eq!(iso_date_serial("2023-02-31", false), iso_date_serial("2023-03-03", false));
|
||||
assert_eq!(iso_date_serial("9999-12-31", false), Some(2_958_465.0));
|
||||
for bad in ["1899-12-31", "10000-01-01", "2024-13-01", "2024-01-00", "x", "2024-01-01T25:00", "2024-01-01-01"] {
|
||||
assert_eq!(iso_date_serial(bad, false), None, "{bad}");
|
||||
}
|
||||
assert_eq!(iso_date_serial("1903-12-31", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_cells() {
|
||||
let xml = r#"<row r="3" ht="20" customHeight="1" s="4" customFormat="1" outlineLevel="9"><c r="B3" s="2" t="s"><v>7</v></c><c><f t="shared" ref="C3:C9" si="0">A1*2</f><v>4</v></c>
|
||||
<c r="XFE3"><v>1</v></c><c r="E3" t="e"><v>#SPILL!</v></c><c r="F3" t="e"><v>#WHAT</v></c><c t="b"><v>1</v></c><c t="inlineStr"><is><t>in_x000A_line</t></is></c><c t="n"><v>abc</v></c><c t="str"><v>a_x0009_</v></c><c t="d"><v>2024-01-01</v></c><c vm="1" cm="2" ph="1"/></row>"#;
|
||||
let mut r = RowReader::default();
|
||||
let row = r.row(&craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default()).unwrap_or_default();
|
||||
assert_eq!((row.index, row.height, row.custom_height, row.style, row.outline_level), (2, Some(20.0), true, Some(4), 7));
|
||||
assert_eq!(r.dropped, 1);
|
||||
let at: Vec<String> = row.cells.iter().map(|c| c.at.a1()).collect();
|
||||
assert_eq!(at, ["B3", "C3", "E3", "F3", "G3", "H3", "I3", "J3", "K3", "L3"]);
|
||||
let values: Vec<&CellValue> = row.cells.iter().map(|c| &c.value).collect();
|
||||
assert_eq!(values[0], &CellValue::Shared(7));
|
||||
assert_eq!(values[2], &CellValue::Error(CellError::Spill));
|
||||
assert_eq!(values[3], &CellValue::Error(CellError::Value));
|
||||
assert_eq!(values[4], &CellValue::Bool(true));
|
||||
assert!(matches!(values[5], CellValue::Inline(s) if &*s.text == "in\nline"));
|
||||
assert_eq!(values[6], &CellValue::Invalid("abc".into()));
|
||||
assert_eq!(values[7], &CellValue::Text("a\t".into()));
|
||||
assert_eq!(values[8].resolve(&[], false), Value::Number(45292.0));
|
||||
let f = row.cells[1].formula.clone().unwrap_or_default();
|
||||
assert_eq!((f.kind, f.text.as_str(), f.shared_index, f.range.map(|r| r.a1())), (FormulaKind::Shared, "A1*2", Some(0), Some("C3:C9".into())));
|
||||
let last = &row.cells[9];
|
||||
assert_eq!((last.value_metadata, last.cell_metadata, last.phonetic, last.style), (Some(1), Some(2), true, 0));
|
||||
assert_eq!(row.cells[0].value.resolve(&[], false), Value::Empty);
|
||||
// Without `r`, the next row follows the last one.
|
||||
let next = r.row(&craft_xml::El::new("row")).map(|r| r.index);
|
||||
assert_eq!(next, Some(3));
|
||||
assert!(r.row(&craft_xml::El::new("row").with_attr("r", "1048577")).is_none());
|
||||
assert!(r.row(&craft_xml::El::new("row").with_attr("r", "0")).is_none());
|
||||
assert_eq!((r.rows, r.dropped), (4, 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! String items (`CT_Rst`, ECMA-376 Part 1, 18.4): shared strings, inline strings and comment
|
||||
//! text, with the `_xHHHH_` escapes of `ST_Xstring` (22.9.2.19).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_xml::El;
|
||||
|
||||
use crate::styles::Font;
|
||||
|
||||
/// A formatted run of a string item (`<r>`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextRun {
|
||||
pub text: String,
|
||||
/// `<rPr>`, `None` when the run has none.
|
||||
pub font: Option<Font>,
|
||||
}
|
||||
|
||||
/// A phonetic reading over part of a string's text (`<rPh>`, 18.4.6).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PhoneticRun {
|
||||
/// Offsets into the base text in UTF-16 code units, `start < end`, both within the text.
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// `<phoneticPr>` (18.4.7). `kind` and `alignment` are kept only when they are values the
|
||||
/// schema defines (`ST_PhoneticType`, `ST_PhoneticAlignment`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PhoneticProps {
|
||||
pub font_id: u32,
|
||||
pub kind: Option<String>,
|
||||
pub alignment: Option<String>,
|
||||
}
|
||||
|
||||
/// One string item: a shared string (`<si>`), an inline string (`<is>`) or comment text.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StringItem {
|
||||
/// The whole text, runs joined, phonetic readings left out.
|
||||
pub text: Arc<str>,
|
||||
/// The formatted runs, empty for a plain string.
|
||||
pub runs: Vec<TextRun>,
|
||||
pub phonetic_runs: Vec<PhoneticRun>,
|
||||
pub phonetic: Option<PhoneticProps>,
|
||||
/// `<rPh>` elements that were dropped: no offsets, `sb >= eb`, past the end of the text, or
|
||||
/// no reading.
|
||||
pub dropped_phonetic_runs: usize,
|
||||
}
|
||||
|
||||
impl StringItem {
|
||||
pub(crate) fn read(e: &El) -> StringItem {
|
||||
let text = rich_text(e);
|
||||
let runs = e
|
||||
.children("r")
|
||||
.map(|r| TextRun { text: decode_xstring(&r.children("t").map(|t| t.text()).collect::<String>()), font: r.child("rPr").map(Font::read) })
|
||||
.collect();
|
||||
// The length in UTF-16 code units: the widest reading of "the total length of the base
|
||||
// text", so a run is only dropped when it cannot refer to the text at all.
|
||||
let len = u32::try_from(text.encode_utf16().count()).unwrap_or(u32::MAX);
|
||||
let mut phonetic_runs = Vec::new();
|
||||
let mut seen = 0usize;
|
||||
for r in e.children("rPh") {
|
||||
seen += 1;
|
||||
let (Some(sb), Some(eb)) = (r.attr_u32("sb"), r.attr_u32("eb")) else { continue };
|
||||
let (start, end) = (sb.min(len), eb.min(len));
|
||||
let reading = r.child("t").map(|t| decode_xstring(&t.text())).unwrap_or_default();
|
||||
if start < end && !reading.is_empty() {
|
||||
phonetic_runs.push(PhoneticRun { start, end, text: reading });
|
||||
}
|
||||
}
|
||||
const KINDS: &[&str] = &["halfwidthKatakana", "fullwidthKatakana", "Hiragana", "noConversion"];
|
||||
const ALIGNMENTS: &[&str] = &["noControl", "left", "center", "distributed"];
|
||||
let phonetic = e.child("phoneticPr").map(|p| PhoneticProps {
|
||||
font_id: p.attr_u32("fontId").unwrap_or(0),
|
||||
kind: p.attr("type").map(str::trim).filter(|v| KINDS.contains(v)).map(str::to_string),
|
||||
alignment: p.attr("alignment").map(str::trim).filter(|v| ALIGNMENTS.contains(v)).map(str::to_string),
|
||||
});
|
||||
StringItem { dropped_phonetic_runs: seen - phonetic_runs.len(), text: Arc::from(text), runs, phonetic_runs, phonetic }
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a string item: `<t>` and the `<t>` of each `<r>`, in order, with `_xHHHH_`
|
||||
/// escapes decoded. Phonetic runs are not part of the text.
|
||||
pub fn rich_text(e: &El) -> String {
|
||||
let mut s = String::new();
|
||||
for c in e.elements() {
|
||||
match c.name.as_str() {
|
||||
"t" => s.push_str(&c.text()),
|
||||
"r" => c.children("t").for_each(|t| s.push_str(&t.text())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
decode_xstring(&s)
|
||||
}
|
||||
|
||||
fn is_escape(chars: &[char], i: usize) -> bool {
|
||||
chars.get(i) == Some(&'_')
|
||||
&& chars.get(i + 1) == Some(&'x')
|
||||
&& chars.get(i + 6) == Some(&'_')
|
||||
&& (i + 2..i + 6).all(|k| chars.get(k).is_some_and(char::is_ascii_hexdigit))
|
||||
}
|
||||
|
||||
fn escape_unit(chars: &[char], i: usize) -> Option<u32> {
|
||||
let hex: String = chars.get(i + 2..i + 6)?.iter().collect();
|
||||
u32::from_str_radix(&hex, 16).ok()
|
||||
}
|
||||
|
||||
/// Text for a `<t>` or `<v>`: characters XML cannot carry and control characters other than tab
|
||||
/// and line feed become `_xHHHH_`, and a literal `_xHHHH_` is protected with `_x005F_`. The
|
||||
/// result still needs XML escaping.
|
||||
pub fn encode_xstring(s: &str) -> String {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if is_escape(&chars, i) {
|
||||
o.push_str("_x005F_");
|
||||
} else if !craft_xml::is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n')) {
|
||||
o.push_str(&format!("_x{:04X}_", c as u32 & 0xFFFF));
|
||||
} else {
|
||||
o.push(c);
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The inverse of [`encode_xstring`]. Two escapes that form a UTF-16 surrogate pair are one
|
||||
/// character; an escape that is no character on its own stays as written.
|
||||
pub fn decode_xstring(s: &str) -> String {
|
||||
if !s.contains("_x") {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut o = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if is_escape(&chars, i) {
|
||||
let unit = escape_unit(&chars, i);
|
||||
if let Some(ch) = unit.and_then(char::from_u32) {
|
||||
o.push(ch);
|
||||
i += 7;
|
||||
continue;
|
||||
}
|
||||
let pair = unit.filter(|h| (0xD800..=0xDBFF).contains(h) && is_escape(&chars, i + 7)).and_then(|high| {
|
||||
let low = escape_unit(&chars, i + 7).filter(|l| (0xDC00..=0xDFFF).contains(l))?;
|
||||
char::from_u32(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00))
|
||||
});
|
||||
if let Some(ch) = pair {
|
||||
o.push(ch);
|
||||
i += 14;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
o.push(c);
|
||||
i += 1;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xstring() {
|
||||
assert_eq!(decode_xstring("a_x000D_b"), "a\rb");
|
||||
assert_eq!(encode_xstring("a\rb"), "a_x000D_b");
|
||||
assert_eq!(encode_xstring("_x000D_"), "_x005F_x000D_");
|
||||
assert_eq!(decode_xstring("_x005F_x000D_"), "_x000D_");
|
||||
assert_eq!(decode_xstring("_x12"), "_x12");
|
||||
assert_eq!(decode_xstring("_xD83E__xDD80_"), "\u{1F980}");
|
||||
// A lone surrogate is no character: kept as written.
|
||||
assert_eq!(decode_xstring("_xD83E_!"), "_xD83E_!");
|
||||
for s in ["plain", "tab\tline\n", "\u{1}\u{1F}", "_x0041_ _xZZZZ_ é"] {
|
||||
assert_eq!(decode_xstring(&encode_xstring(s)), s);
|
||||
}
|
||||
}
|
||||
|
||||
fn item(xml: &str) -> StringItem {
|
||||
StringItem::read(&craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_and_phonetics() {
|
||||
let si = item(
|
||||
r#"<si><r><t>東</t></r><r><rPr><b/><rFont val="Arial"/><sz val="9"/></rPr><t xml:space="preserve">京 </t></r><rPh sb="0" eb="2"><t>トウキョウ</t></rPh><rPh sb="5" eb="9"><t>x</t></rPh><rPh sb="1" eb="1"><t>x</t></rPh><rPh sb="0" eb="1"/><phoneticPr fontId="3" type="Hiragana" alignment="sideways"/></si>"#,
|
||||
);
|
||||
assert_eq!(&*si.text, "東京 ");
|
||||
assert_eq!(si.runs.len(), 2);
|
||||
assert_eq!(si.runs[1].font.as_ref().map(|f| (f.bold, f.name.as_deref(), f.size)), Some((Some(true), Some("Arial"), Some(9.0))));
|
||||
assert_eq!(si.phonetic_runs, [PhoneticRun { start: 0, end: 2, text: "トウキョウ".into() }]);
|
||||
assert_eq!(si.dropped_phonetic_runs, 3);
|
||||
assert_eq!(si.phonetic, Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }));
|
||||
let plain = item("<si><t>a_x000A_b</t></si>");
|
||||
assert_eq!((&*plain.text, plain.runs.len()), ("a\nb", 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! The styles part (`xl/styles.xml`, ECMA-376 Part 1, 18.8) as the file has it: tables that
|
||||
//! cells refer to by index. Attributes a file may leave out are `Option`s, because a
|
||||
//! differential format (`<dxf>`) states only what it changes.
|
||||
|
||||
use craft_sheet::{BorderStyle, DEFAULT_PALETTE, HAlign, Pattern, Script, Underline, VAlign, builtin_format};
|
||||
use craft_xml::El;
|
||||
|
||||
/// Characters of a font name kept (Excel allows 31; damaged files have far more).
|
||||
const MAX_FONT_NAME_CHARS: usize = 255;
|
||||
|
||||
/// Where a colour comes from (`CT_Color`, 18.8.9).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ColorSource {
|
||||
/// `auto="1"`, or no attribute that names a colour.
|
||||
#[default]
|
||||
Auto,
|
||||
/// `rgb`: `0xAARRGGBB`. Six hex digits read as opaque.
|
||||
Rgb(u32),
|
||||
/// `theme`: an index into the theme's colour scheme.
|
||||
Theme(u32),
|
||||
/// `indexed`: an index into [`Styles::palette`].
|
||||
Indexed(u32),
|
||||
}
|
||||
|
||||
/// A colour with its tint (`-1.0` darkest to `1.0` lightest, 0 for none).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Color {
|
||||
pub source: ColorSource,
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub fn read(e: &El) -> Color {
|
||||
let source = if e.flag("auto", false) {
|
||||
ColorSource::Auto
|
||||
} else if let Some(rgb) = e.attr("rgb") {
|
||||
parse_argb(rgb).map_or(ColorSource::Auto, ColorSource::Rgb)
|
||||
} else if let Some(t) = e.attr_u32("theme") {
|
||||
ColorSource::Theme(t)
|
||||
} else if let Some(i) = e.attr_u32("indexed") {
|
||||
ColorSource::Indexed(i)
|
||||
} else {
|
||||
ColorSource::Auto
|
||||
};
|
||||
Color { source, tint: e.attr_f64("tint").map_or(0.0, |t| t.clamp(-1.0, 1.0)) }
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` for an RGB colour, or an indexed one in `palette`; `None` for automatic and
|
||||
/// theme colours. The tint is not applied.
|
||||
pub fn rgb(&self, palette: &[u32]) -> Option<u32> {
|
||||
match self.source {
|
||||
ColorSource::Rgb(v) => Some(v & 0xFF_FFFF),
|
||||
ColorSource::Indexed(i) => palette.get(i as usize).copied(),
|
||||
ColorSource::Auto | ColorSource::Theme(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_argb(s: &str) -> Option<u32> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
let v = u32::from_str_radix(h, 16).ok().filter(|_| h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
match h.len() {
|
||||
8 => Some(v),
|
||||
6 => Some(0xFF00_0000 | v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `scheme` of a font: which theme font it follows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontScheme {
|
||||
None,
|
||||
Major,
|
||||
Minor,
|
||||
}
|
||||
|
||||
/// A font (`<font>`, or a run's `<rPr>`, 18.8.22 and 18.4.7).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Font {
|
||||
pub name: Option<String>,
|
||||
/// Points.
|
||||
pub size: Option<f64>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub strike: Option<bool>,
|
||||
pub outline: Option<bool>,
|
||||
pub shadow: Option<bool>,
|
||||
pub condense: Option<bool>,
|
||||
pub extend: Option<bool>,
|
||||
pub underline: Option<Underline>,
|
||||
pub vert_align: Option<Script>,
|
||||
pub color: Option<Color>,
|
||||
pub family: Option<u32>,
|
||||
pub charset: Option<u32>,
|
||||
pub scheme: Option<FontScheme>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
pub fn read(e: &El) -> Font {
|
||||
let flag = |n: &str| e.child(n).map(|c| c.flag("val", true));
|
||||
let val = |n: &str| e.child_attr(n, "val");
|
||||
Font {
|
||||
name: val("name").or_else(|| val("rFont")).map(|n| n.chars().take(MAX_FONT_NAME_CHARS).collect()),
|
||||
size: e.child("sz").and_then(|s| s.attr_f64("val")),
|
||||
bold: flag("b"),
|
||||
italic: flag("i"),
|
||||
strike: flag("strike"),
|
||||
outline: flag("outline"),
|
||||
shadow: flag("shadow"),
|
||||
condense: flag("condense"),
|
||||
extend: flag("extend"),
|
||||
underline: e.child("u").map(|u| match u.attr("val").unwrap_or("single") {
|
||||
"double" => Underline::Double,
|
||||
"singleAccounting" => Underline::SingleAccounting,
|
||||
"doubleAccounting" => Underline::DoubleAccounting,
|
||||
"none" => Underline::None,
|
||||
_ => Underline::Single,
|
||||
}),
|
||||
vert_align: val("vertAlign").map(|v| match v {
|
||||
"superscript" => Script::Superscript,
|
||||
"subscript" => Script::Subscript,
|
||||
_ => Script::Baseline,
|
||||
}),
|
||||
color: e.child("color").map(Color::read),
|
||||
family: e.child("family").and_then(|c| c.attr_u32("val")),
|
||||
charset: e.child("charset").and_then(|c| c.attr_u32("val")),
|
||||
scheme: val("scheme").map(|s| match s {
|
||||
"major" => FontScheme::Major,
|
||||
"minor" => FontScheme::Minor,
|
||||
_ => FontScheme::None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<patternFill>` (18.8.32).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PatternFill {
|
||||
/// `None` when `patternType` is absent: in a cell format that is no fill, in a differential
|
||||
/// format it means solid.
|
||||
pub pattern: Option<Pattern>,
|
||||
pub fg: Option<Color>,
|
||||
pub bg: Option<Color>,
|
||||
}
|
||||
|
||||
/// `<gradientFill>` (18.8.24).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GradientFill {
|
||||
/// `type="path"`; linear otherwise.
|
||||
pub path: bool,
|
||||
pub degree: f64,
|
||||
pub left: f64,
|
||||
pub right: f64,
|
||||
pub top: f64,
|
||||
pub bottom: f64,
|
||||
/// `(position, colour)` in file order.
|
||||
pub stops: Vec<(f64, Color)>,
|
||||
}
|
||||
|
||||
/// `<fill>` (18.8.20).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Fill {
|
||||
Pattern(PatternFill),
|
||||
Gradient(GradientFill),
|
||||
}
|
||||
|
||||
impl Default for Fill {
|
||||
fn default() -> Self {
|
||||
Fill::Pattern(PatternFill::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern(s: &str) -> Pattern {
|
||||
match s {
|
||||
"solid" => Pattern::Solid,
|
||||
"mediumGray" => Pattern::Gray50,
|
||||
"darkGray" => Pattern::Gray75,
|
||||
"lightGray" => Pattern::Gray25,
|
||||
"gray125" => Pattern::Gray125,
|
||||
"gray0625" => Pattern::Gray0625,
|
||||
"darkHorizontal" => Pattern::DarkHorizontal,
|
||||
"darkVertical" => Pattern::DarkVertical,
|
||||
"darkDown" => Pattern::DarkDown,
|
||||
"darkUp" => Pattern::DarkUp,
|
||||
"darkGrid" => Pattern::DarkGrid,
|
||||
"darkTrellis" => Pattern::DarkTrellis,
|
||||
"lightHorizontal" => Pattern::LightHorizontal,
|
||||
"lightVertical" => Pattern::LightVertical,
|
||||
"lightDown" => Pattern::LightDown,
|
||||
"lightUp" => Pattern::LightUp,
|
||||
"lightGrid" => Pattern::LightGrid,
|
||||
"lightTrellis" => Pattern::LightTrellis,
|
||||
_ => Pattern::None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
pub fn read(e: &El) -> Fill {
|
||||
if let Some(g) = e.child("gradientFill") {
|
||||
let f = |n: &str| g.attr_f64(n).unwrap_or(0.0);
|
||||
return Fill::Gradient(GradientFill {
|
||||
path: g.attr("type") == Some("path"),
|
||||
degree: f("degree"),
|
||||
left: f("left"),
|
||||
right: f("right"),
|
||||
top: f("top"),
|
||||
bottom: f("bottom"),
|
||||
stops: g
|
||||
.children("stop")
|
||||
.map(|s| (s.attr_f64("position").unwrap_or(0.0), s.child("color").map(Color::read).unwrap_or_default()))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
let Some(p) = e.child("patternFill") else { return Fill::default() };
|
||||
Fill::Pattern(PatternFill {
|
||||
pattern: p.attr("patternType").map(pattern),
|
||||
fg: p.child("fgColor").map(Color::read),
|
||||
bg: p.child("bgColor").map(Color::read),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge of a border.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BorderEdge {
|
||||
pub style: BorderStyle,
|
||||
pub color: Option<Color>,
|
||||
}
|
||||
|
||||
impl BorderEdge {
|
||||
fn read(e: &El) -> BorderEdge {
|
||||
let style = match e.attr("style").unwrap_or("none") {
|
||||
"thin" => BorderStyle::Thin,
|
||||
"medium" => BorderStyle::Medium,
|
||||
"thick" => BorderStyle::Thick,
|
||||
"dashed" => BorderStyle::Dashed,
|
||||
"dotted" => BorderStyle::Dotted,
|
||||
"double" => BorderStyle::Double,
|
||||
"hair" => BorderStyle::Hair,
|
||||
"mediumDashed" => BorderStyle::MediumDashed,
|
||||
"dashDot" => BorderStyle::DashDot,
|
||||
"mediumDashDot" => BorderStyle::MediumDashDot,
|
||||
"dashDotDot" => BorderStyle::DashDotDot,
|
||||
"mediumDashDotDot" => BorderStyle::MediumDashDotDot,
|
||||
"slantDashDot" => BorderStyle::SlantDashDot,
|
||||
_ => BorderStyle::None,
|
||||
};
|
||||
BorderEdge { style, color: e.child("color").map(Color::read) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `<border>` (18.8.4). An edge is `None` when its element is absent. `start` and `end` (the
|
||||
/// strict spellings) read as left and right.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Border {
|
||||
pub left: Option<BorderEdge>,
|
||||
pub right: Option<BorderEdge>,
|
||||
pub top: Option<BorderEdge>,
|
||||
pub bottom: Option<BorderEdge>,
|
||||
pub diagonal: Option<BorderEdge>,
|
||||
pub vertical: Option<BorderEdge>,
|
||||
pub horizontal: Option<BorderEdge>,
|
||||
pub diagonal_up: bool,
|
||||
pub diagonal_down: bool,
|
||||
pub outline: bool,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn read(e: &El) -> Border {
|
||||
let edge = |a: &str, b: &str| e.child(a).or_else(|| e.child(b)).map(BorderEdge::read);
|
||||
Border {
|
||||
left: edge("left", "start"),
|
||||
right: edge("right", "end"),
|
||||
top: edge("top", "top"),
|
||||
bottom: edge("bottom", "bottom"),
|
||||
diagonal: edge("diagonal", "diagonal"),
|
||||
vertical: edge("vertical", "vertical"),
|
||||
horizontal: edge("horizontal", "horizontal"),
|
||||
diagonal_up: e.flag("diagonalUp", false),
|
||||
diagonal_down: e.flag("diagonalDown", false),
|
||||
outline: e.flag("outline", true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<alignment>` (18.8.1).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Alignment {
|
||||
pub horizontal: Option<HAlign>,
|
||||
pub vertical: Option<VAlign>,
|
||||
/// 0 to 90 counter-clockwise, 91 to 180 clockwise (as 90 + degrees), 255 stacked.
|
||||
pub text_rotation: Option<u32>,
|
||||
pub wrap_text: bool,
|
||||
pub shrink_to_fit: bool,
|
||||
pub indent: Option<u32>,
|
||||
/// 0 context, 1 left to right, 2 right to left.
|
||||
pub reading_order: Option<u32>,
|
||||
pub justify_last_line: bool,
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
pub fn read(a: &El) -> Alignment {
|
||||
Alignment {
|
||||
horizontal: a.attr("horizontal").map(|h| match h {
|
||||
"left" => HAlign::Left,
|
||||
"center" => HAlign::Center,
|
||||
"right" => HAlign::Right,
|
||||
"fill" => HAlign::Fill,
|
||||
"justify" => HAlign::Justify,
|
||||
"centerContinuous" => HAlign::CenterAcross,
|
||||
"distributed" => HAlign::Distributed,
|
||||
_ => HAlign::General,
|
||||
}),
|
||||
vertical: a.attr("vertical").map(|v| match v {
|
||||
"top" => VAlign::Top,
|
||||
"center" => VAlign::Center,
|
||||
"justify" => VAlign::Justify,
|
||||
"distributed" => VAlign::Distributed,
|
||||
_ => VAlign::Bottom,
|
||||
}),
|
||||
text_rotation: a.attr_u32("textRotation"),
|
||||
wrap_text: a.flag("wrapText", false),
|
||||
shrink_to_fit: a.flag("shrinkToFit", false),
|
||||
indent: a.attr_u32("indent"),
|
||||
reading_order: a.attr_u32("readingOrder"),
|
||||
justify_last_line: a.flag("justifyLastLine", false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<protection>` (18.8.33).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Protection {
|
||||
pub locked: Option<bool>,
|
||||
pub hidden: Option<bool>,
|
||||
}
|
||||
|
||||
impl Protection {
|
||||
fn read(p: &El) -> Protection {
|
||||
Protection { locked: p.attr_bool("locked"), hidden: p.attr_bool("hidden") }
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell format or a cell style format (`<xf>`, 18.8.45): indexes into the tables of
|
||||
/// [`Styles`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Xf {
|
||||
pub num_fmt_id: u32,
|
||||
pub font_id: u32,
|
||||
pub fill_id: u32,
|
||||
pub border_id: u32,
|
||||
/// The cell style format this cell format is based on (`cellXfs` only).
|
||||
pub xf_id: Option<u32>,
|
||||
pub alignment: Option<Alignment>,
|
||||
pub protection: Option<Protection>,
|
||||
pub quote_prefix: bool,
|
||||
/// The element as written, for its `apply*` flags and extensions.
|
||||
pub el: El,
|
||||
}
|
||||
|
||||
impl Xf {
|
||||
fn read(xf: &El) -> Xf {
|
||||
let id = |n: &str| xf.attr_u32(n).unwrap_or(0);
|
||||
Xf {
|
||||
num_fmt_id: id("numFmtId"),
|
||||
font_id: id("fontId"),
|
||||
fill_id: id("fillId"),
|
||||
border_id: id("borderId"),
|
||||
xf_id: xf.attr_u32("xfId"),
|
||||
alignment: xf.child("alignment").map(Alignment::read),
|
||||
protection: xf.child("protection").map(Protection::read),
|
||||
quote_prefix: xf.flag("quotePrefix", false),
|
||||
el: xf.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named cell style (`<cellStyle>`, 18.8.7).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CellStyle {
|
||||
pub name: String,
|
||||
/// Index into [`Styles::cell_style_xfs`].
|
||||
pub xf_id: u32,
|
||||
pub builtin_id: Option<u32>,
|
||||
pub hidden: bool,
|
||||
pub custom_builtin: bool,
|
||||
}
|
||||
|
||||
/// A number format as a differential format states it: an id, a code, or both.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NumFmt {
|
||||
pub id: Option<u32>,
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
/// A differential format (`<dxf>`, 18.8.14), used by conditional formats and table styles.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Dxf {
|
||||
pub font: Option<Font>,
|
||||
pub num_fmt: Option<NumFmt>,
|
||||
pub fill: Option<Fill>,
|
||||
pub border: Option<Border>,
|
||||
pub alignment: Option<Alignment>,
|
||||
pub protection: Option<Protection>,
|
||||
}
|
||||
|
||||
impl Dxf {
|
||||
fn read(x: &El) -> Dxf {
|
||||
Dxf {
|
||||
font: x.child("font").map(Font::read),
|
||||
num_fmt: x.child("numFmt").map(|n| NumFmt { id: n.attr_u32("numFmtId"), code: n.attr("formatCode").map(str::to_string) }),
|
||||
fill: x.child("fill").map(Fill::read),
|
||||
border: x.child("border").map(Border::read),
|
||||
alignment: x.child("alignment").map(Alignment::read),
|
||||
protection: x.child("protection").map(Protection::read),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The styles part.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Styles {
|
||||
/// Custom number formats `(numFmtId, formatCode)` in file order.
|
||||
pub num_fmts: Vec<(u32, String)>,
|
||||
pub fonts: Vec<Font>,
|
||||
pub fills: Vec<Fill>,
|
||||
pub borders: Vec<Border>,
|
||||
pub cell_style_xfs: Vec<Xf>,
|
||||
/// What a cell's `s` attribute indexes.
|
||||
pub cell_xfs: Vec<Xf>,
|
||||
pub cell_styles: Vec<CellStyle>,
|
||||
pub dxfs: Vec<Dxf>,
|
||||
/// `colors/indexedColors` as `0xRRGGBB`, empty when the file keeps the default palette.
|
||||
pub indexed_colors: Vec<u32>,
|
||||
/// The whole part, for what is not lifted out (table styles, recent colours, extensions).
|
||||
pub root: El,
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
pub fn read(root: El) -> Styles {
|
||||
fn list<'r>(root: &'r El, name: &str, item: &'static str) -> Vec<&'r El> {
|
||||
root.child(name).map(|l| l.children(item).collect()).unwrap_or_default()
|
||||
}
|
||||
let mut s = Styles {
|
||||
num_fmts: list(&root, "numFmts", "numFmt")
|
||||
.into_iter()
|
||||
.filter_map(|f| Some((f.attr_u32("numFmtId")?, f.attr("formatCode")?.to_string())))
|
||||
.collect(),
|
||||
fonts: list(&root, "fonts", "font").into_iter().map(Font::read).collect(),
|
||||
fills: list(&root, "fills", "fill").into_iter().map(Fill::read).collect(),
|
||||
borders: list(&root, "borders", "border").into_iter().map(Border::read).collect(),
|
||||
cell_style_xfs: list(&root, "cellStyleXfs", "xf").into_iter().map(Xf::read).collect(),
|
||||
cell_xfs: list(&root, "cellXfs", "xf").into_iter().map(Xf::read).collect(),
|
||||
cell_styles: list(&root, "cellStyles", "cellStyle")
|
||||
.into_iter()
|
||||
.map(|c| CellStyle {
|
||||
name: c.attr("name").unwrap_or_default().to_string(),
|
||||
xf_id: c.attr_u32("xfId").unwrap_or(0),
|
||||
builtin_id: c.attr_u32("builtinId"),
|
||||
hidden: c.flag("hidden", false),
|
||||
custom_builtin: c.flag("customBuiltin", false),
|
||||
})
|
||||
.collect(),
|
||||
dxfs: list(&root, "dxfs", "dxf").into_iter().map(Dxf::read).collect(),
|
||||
indexed_colors: root
|
||||
.path(&["colors", "indexedColors"])
|
||||
.map(|ix| ix.children("rgbColor").map(|c| Color::read(c).rgb(&DEFAULT_PALETTE).unwrap_or(0)).collect())
|
||||
.unwrap_or_default(),
|
||||
root: El::default(),
|
||||
};
|
||||
s.root = root;
|
||||
s
|
||||
}
|
||||
|
||||
/// The indexed colour table: the file's `indexedColors`, or [`DEFAULT_PALETTE`].
|
||||
pub fn palette(&self) -> &[u32] {
|
||||
if self.indexed_colors.is_empty() { &DEFAULT_PALETTE } else { &self.indexed_colors }
|
||||
}
|
||||
|
||||
/// The format code of number format `id`: the file's custom format, else the built-in one.
|
||||
/// `None` for an id that is neither (Excel shows General).
|
||||
pub fn number_format(&self, id: u32) -> Option<&str> {
|
||||
self.num_fmts.iter().find(|(i, _)| *i == id).map(|(_, c)| c.as_str()).or_else(|| builtin_format(id))
|
||||
}
|
||||
|
||||
/// The format code of cell format `xf` (a cell's `s`).
|
||||
pub fn cell_number_format(&self, xf: u32) -> Option<&str> {
|
||||
self.number_format(self.cell_xfs.get(xf as usize)?.num_fmt_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn styles(xml: &str) -> Styles {
|
||||
Styles::read(craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_by_index() {
|
||||
let s = styles(
|
||||
r##"<x:styleSheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<x:numFmts count="1"><x:numFmt numFmtId="164" formatCode="yyyy-mm-dd"/></x:numFmts>
|
||||
<x:fonts><x:font><x:b/><x:i val="0"/><x:sz val="11"/><x:color theme="1" tint="-0.25"/><x:name val="Calibri"/><x:scheme val="minor"/></x:font></x:fonts>
|
||||
<x:fills><x:fill><x:patternFill patternType="none"/></x:fill><x:fill><x:patternFill patternType="solid"><x:fgColor rgb="FFFF0000"/><x:bgColor indexed="64"/></x:patternFill></x:fill>
|
||||
<x:fill><x:gradientFill degree="90"><x:stop position="0"><x:color rgb="00FF00"/></x:stop><x:stop position="1"><x:color theme="4"/></x:stop></x:gradientFill></x:fill></x:fills>
|
||||
<x:borders><x:border diagonalUp="1"><x:start style="thin"><x:color indexed="10"/></x:start><x:bottom style="double"/><x:diagonal style="hair"/></x:border></x:borders>
|
||||
<x:cellStyleXfs><x:xf numFmtId="0" fontId="0"/></x:cellStyleXfs>
|
||||
<x:cellXfs><x:xf/><x:xf numFmtId="164" fontId="0" fillId="1" borderId="0" xfId="0" applyAlignment="1" quotePrefix="1"><x:alignment horizontal="centerContinuous" textRotation="255" wrapText="1"/><x:protection locked="0"/></x:xf><x:xf numFmtId="14"/><x:xf numFmtId="99"/></x:cellXfs>
|
||||
<x:cellStyles><x:cellStyle name="Normal" xfId="0" builtinId="0"/></x:cellStyles>
|
||||
<x:dxfs><x:dxf><x:font><x:b/></x:font><x:numFmt numFmtId="200" formatCode="0.0"/><x:fill><x:patternFill><x:bgColor rgb="FFFFFF00"/></x:patternFill></x:fill></x:dxf></x:dxfs>
|
||||
<x:colors><x:indexedColors><x:rgbColor rgb="FF010203"/><x:rgbColor rgb="bad"/></x:indexedColors></x:colors>
|
||||
</x:styleSheet>"##,
|
||||
);
|
||||
let f = &s.fonts[0];
|
||||
assert_eq!((f.bold, f.italic, f.strike, f.size, f.name.as_deref()), (Some(true), Some(false), None, Some(11.0), Some("Calibri")));
|
||||
assert_eq!(f.color, Some(Color { source: ColorSource::Theme(1), tint: -0.25 }));
|
||||
assert_eq!(f.scheme, Some(FontScheme::Minor));
|
||||
assert_eq!(s.fills[0], Fill::Pattern(PatternFill { pattern: Some(Pattern::None), fg: None, bg: None }));
|
||||
let Fill::Pattern(solid) = &s.fills[1] else { panic!("{:?}", s.fills[1]) };
|
||||
assert_eq!((solid.pattern, solid.fg.map(|c| c.source)), (Some(Pattern::Solid), Some(ColorSource::Rgb(0xFFFF0000))));
|
||||
let Fill::Gradient(g) = &s.fills[2] else { panic!("{:?}", s.fills[2]) };
|
||||
assert_eq!((g.degree, g.stops.len(), g.stops[0].1.rgb(&[])), (90.0, 2, Some(0x00FF00)));
|
||||
let b = &s.borders[0];
|
||||
assert_eq!(b.left.map(|e| (e.style, e.color.and_then(|c| c.rgb(s.palette())))), Some((BorderStyle::Thin, None)));
|
||||
assert_eq!((b.bottom.map(|e| e.style), b.right, b.diagonal_up, b.diagonal_down), (Some(BorderStyle::Double), None, true, false));
|
||||
let xf = &s.cell_xfs[1];
|
||||
assert_eq!((xf.num_fmt_id, xf.fill_id, xf.xf_id, xf.quote_prefix), (164, 1, Some(0), true));
|
||||
let a = xf.alignment.unwrap_or_default();
|
||||
assert_eq!((a.horizontal, a.text_rotation, a.wrap_text, a.vertical), (Some(HAlign::CenterAcross), Some(255), true, None));
|
||||
assert_eq!(xf.protection, Some(Protection { locked: Some(false), hidden: None }));
|
||||
assert_eq!(xf.el.attr("applyAlignment"), Some("1"));
|
||||
assert_eq!(
|
||||
(s.cell_number_format(1), s.cell_number_format(2), s.cell_number_format(3), s.cell_number_format(9)),
|
||||
(Some("yyyy-mm-dd"), Some("m/d/yyyy"), None, None)
|
||||
);
|
||||
assert_eq!(s.cell_styles[0].builtin_id, Some(0));
|
||||
let d = &s.dxfs[0];
|
||||
assert_eq!(d.num_fmt, Some(NumFmt { id: Some(200), code: Some("0.0".into()) }));
|
||||
assert_eq!(
|
||||
d.fill,
|
||||
Some(Fill::Pattern(PatternFill { pattern: None, fg: None, bg: Some(Color { source: ColorSource::Rgb(0xFFFFFF00), tint: 0.0 }) }))
|
||||
);
|
||||
assert_eq!(s.indexed_colors, [0x010203, 0]);
|
||||
assert_eq!(s.palette().len(), 2);
|
||||
assert_eq!(styles("<styleSheet/>").palette(), DEFAULT_PALETTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_font_names_are_cut() {
|
||||
let s = styles(&format!(r#"<styleSheet><fonts><font><name val="{}"/></font></fonts></styleSheet>"#, "é".repeat(300)));
|
||||
assert_eq!(s.fonts[0].name.as_ref().map(|n| n.chars().count()), Some(MAX_FONT_NAME_CHARS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Small workbooks built in code, for tests and examples.
|
||||
|
||||
use craft_opc::{PackageOut, RelsOut};
|
||||
|
||||
const MAIN_NS: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
/// An `.xlsx` with the given sheets, each `(name, the rows inside <sheetData>)`, and shared
|
||||
/// strings. Names and strings are written as given: escape them first if they need it.
|
||||
pub fn workbook(sheets: &[(&str, &str)], strings: &[&str]) -> Vec<u8> {
|
||||
let mut out = PackageOut::default();
|
||||
let mut root = RelsOut::default();
|
||||
root.add("officeDocument", "xl/workbook.xml", false);
|
||||
out.rels("", &root);
|
||||
let mut rels = RelsOut::default();
|
||||
let mut list = String::new();
|
||||
for (i, (name, rows)) in sheets.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let part = format!("xl/worksheets/sheet{n}.xml");
|
||||
let id = rels.add("worksheet", &format!("worksheets/sheet{n}.xml"), false);
|
||||
list.push_str(&format!(r#"<sheet name="{name}" sheetId="{n}" r:id="{id}"/>"#));
|
||||
out.xml_part(
|
||||
&part,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
|
||||
format!(r#"<worksheet xmlns="{MAIN_NS}"><sheetData>{rows}</sheetData></worksheet>"#).into_bytes(),
|
||||
);
|
||||
}
|
||||
if !strings.is_empty() {
|
||||
rels.add("sharedStrings", "sharedStrings.xml", false);
|
||||
let items: String = strings.iter().map(|s| format!("<si><t>{s}</t></si>")).collect();
|
||||
out.xml_part(
|
||||
"xl/sharedStrings.xml",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",
|
||||
format!(r#"<sst xmlns="{MAIN_NS}" count="{0}" uniqueCount="{0}">{items}</sst>"#, strings.len()).into_bytes(),
|
||||
);
|
||||
}
|
||||
out.rels("xl/workbook.xml", &rels);
|
||||
out.xml_part(
|
||||
"xl/workbook.xml",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
|
||||
format!(r#"<workbook xmlns="{MAIN_NS}" xmlns:r="{R_NS}"><sheets>{list}</sheets></workbook>"#).into_bytes(),
|
||||
);
|
||||
out.finish().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A ZIP archive of the given entries, stored as named (no content types are added).
|
||||
pub fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let entries: Vec<(String, Vec<u8>)> = entries.iter().map(|(n, b)| (n.to_string(), b.to_vec())).collect();
|
||||
craft_opc::zip_entries(&entries).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The workbook part (`xl/workbook.xml`, ECMA-376 Part 1, 18.2).
|
||||
|
||||
use craft_opc::Rels;
|
||||
use craft_sheet::Visibility;
|
||||
use craft_xml::El;
|
||||
|
||||
/// What kind of sheet a workbook entry points at, from its relationship type.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SheetKind {
|
||||
Worksheet,
|
||||
Chartsheet,
|
||||
Dialogsheet,
|
||||
/// An Excel 4.0 macro sheet (`xlMacrosheet`, `xlIntlMacrosheet`).
|
||||
Macrosheet,
|
||||
/// Another relationship type, by its last segment.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl SheetKind {
|
||||
pub(crate) fn of(kind: &str) -> SheetKind {
|
||||
match kind {
|
||||
"worksheet" => SheetKind::Worksheet,
|
||||
"chartsheet" => SheetKind::Chartsheet,
|
||||
"dialogsheet" => SheetKind::Dialogsheet,
|
||||
"xlMacrosheet" | "xlIntlMacrosheet" => SheetKind::Macrosheet,
|
||||
other => SheetKind::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `<sheet>` of the workbook, in tab order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SheetEntry {
|
||||
/// The name as the file has it: not checked against Excel's rules for sheet names and not
|
||||
/// made unique.
|
||||
pub name: String,
|
||||
/// `sheetId`, 0 when absent.
|
||||
pub sheet_id: u32,
|
||||
pub visibility: Visibility,
|
||||
/// The relationship id (`r:id`), empty when absent.
|
||||
pub rel_id: String,
|
||||
pub kind: SheetKind,
|
||||
/// The sheet's part, `None` when the relationship is missing or points outside the package.
|
||||
pub part: Option<String>,
|
||||
}
|
||||
|
||||
/// One `<definedName>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DefinedName {
|
||||
/// The name as written, built-in names included (`_xlnm.Print_Area`, `_xlnm._FilterDatabase`).
|
||||
pub name: String,
|
||||
/// `localSheetId`: an index into [`Workbook::sheets`] for a sheet-scoped name.
|
||||
pub local_sheet_id: Option<u32>,
|
||||
/// The formula text as the file spells it, trimmed, without a leading `=`.
|
||||
pub formula: String,
|
||||
pub comment: String,
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// The workbook part.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Workbook {
|
||||
pub sheets: Vec<SheetEntry>,
|
||||
/// Dates count from 1904-01-01 instead of 1900 (`workbookPr/@date1904`).
|
||||
pub date_1904: bool,
|
||||
pub defined_names: Vec<DefinedName>,
|
||||
/// `activeTab` of the first visible workbook view (of the first view when none is visible).
|
||||
pub active_tab: Option<u32>,
|
||||
/// The whole part, for everything not lifted out above (calculation settings, views,
|
||||
/// protection, pivot caches, external references). Empty for a binary workbook.
|
||||
pub root: El,
|
||||
}
|
||||
|
||||
impl Workbook {
|
||||
pub(crate) fn from_xml(root: El, rels: &Rels) -> Workbook {
|
||||
let mut wb = Workbook { date_1904: root.child("workbookPr").is_some_and(|p| p.flag("date1904", false)), ..Workbook::default() };
|
||||
if let Some(sheets) = root.child("sheets") {
|
||||
for s in sheets.children("sheet") {
|
||||
let rel_id = s.attr("id").unwrap_or_default().to_string();
|
||||
let rel = rels.by_id(&rel_id);
|
||||
wb.sheets.push(SheetEntry {
|
||||
name: s.attr("name").unwrap_or_default().to_string(),
|
||||
sheet_id: s.attr_u32("sheetId").unwrap_or(0),
|
||||
visibility: match s.attr("state") {
|
||||
Some("hidden") => Visibility::Hidden,
|
||||
Some("veryHidden") => Visibility::VeryHidden,
|
||||
_ => Visibility::Visible,
|
||||
},
|
||||
kind: rel.map_or(SheetKind::Other(String::new()), |r| SheetKind::of(r.kind())),
|
||||
part: rel.filter(|r| !r.external).map(|r| r.target.clone()),
|
||||
rel_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(names) = root.child("definedNames") {
|
||||
for d in names.children("definedName") {
|
||||
let Some(name) = d.attr("name") else { continue };
|
||||
wb.defined_names.push(DefinedName {
|
||||
name: name.to_string(),
|
||||
local_sheet_id: d.attr_u32("localSheetId"),
|
||||
formula: d.text().trim().to_string(),
|
||||
comment: d.attr("comment").unwrap_or_default().to_string(),
|
||||
hidden: d.flag("hidden", false),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(views) = root.child("bookViews") {
|
||||
let view = views
|
||||
.children("workbookView")
|
||||
.find(|v| v.attr("visibility").unwrap_or("visible") == "visible")
|
||||
.or_else(|| views.child("workbookView"));
|
||||
wb.active_tab = view.and_then(|v| v.attr_u32("activeTab"));
|
||||
}
|
||||
wb.root = root;
|
||||
wb
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Binary workbooks ([MS-XLSB]): the workbook part, shared strings and worksheet cells, from
|
||||
//! their records. Saved values only: formulas are token streams that are not decoded, and
|
||||
//! styles are not read. A record that does not parse fails the read.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_opc::{ContentTypes, Rels};
|
||||
use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Visibility};
|
||||
|
||||
use crate::sheet::{Cell, CellValue, Row, SheetPart};
|
||||
use crate::strings::StringItem;
|
||||
use crate::workbook::{SheetEntry, SheetKind, Workbook};
|
||||
use crate::{Error, Limits, Warnings};
|
||||
|
||||
pub(crate) const MAIN: &str = "application/vnd.ms-excel.main";
|
||||
const BINARY_MAIN: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.main";
|
||||
pub(crate) const WORKSHEET: &str = "application/vnd.ms-excel.worksheet";
|
||||
pub(crate) const STRINGS: &str = "application/vnd.ms-excel.sharedStrings";
|
||||
/// Most sheets in the workbook.
|
||||
const MAX_SHEETS: usize = 1024;
|
||||
/// Most merged ranges in one worksheet.
|
||||
const MAX_MERGES: usize = 100_000;
|
||||
|
||||
pub(crate) fn invalid(msg: impl Into<String>) -> Error {
|
||||
Error::Invalid(format!("XLSB: {}", msg.into()))
|
||||
}
|
||||
|
||||
/// Is `part`'s content type `expected`? The workbook part may also have the macro-enabled type.
|
||||
pub(crate) fn check_type(types: &ContentTypes, part: &str, expected: &str) -> Result<(), Error> {
|
||||
let actual = types.of(part);
|
||||
if actual != Some(expected) && !(expected == MAIN && actual == Some(BINARY_MAIN)) {
|
||||
return Err(invalid(format!("wrong or missing content type for {part}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the workbook has used of [`Limits`], over all its parts. Every part's [`Records`]
|
||||
/// charges the same record allowance while cells and text are charged, hence the `Cell`s.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Budget {
|
||||
cells: std::cell::Cell<usize>,
|
||||
text: std::cell::Cell<usize>,
|
||||
records: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn text(&self, len: usize, limits: &Limits) -> Result<(), Error> {
|
||||
self.text.set(self.text.get().saturating_add(len));
|
||||
if self.text.get() > limits.binary_text {
|
||||
return Err(Error::TooLarge(format!("XLSB decoded text exceeds {} MiB", limits.binary_text >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cell(&self, limits: &Limits) -> Result<(), Error> {
|
||||
self.cells.set(self.cells.get().saturating_add(1));
|
||||
if self.cells.get() > limits.binary_cells {
|
||||
return Err(Error::TooLarge(format!("XLSB exceeds {} imported cells", limits.binary_cells)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record(&self, limits: &Limits) -> Result<(), Error> {
|
||||
self.records.set(self.records.get().saturating_add(1));
|
||||
if self.records.get() > limits.binary_records {
|
||||
return Err(Error::TooLarge(format!("XLSB workbook contains too many records (more than {})", limits.binary_records)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Bytes<'a> {
|
||||
rest: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> Bytes<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { rest: bytes }
|
||||
}
|
||||
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
|
||||
let out = self.rest.get(..n).ok_or_else(|| invalid("truncated record or string"))?;
|
||||
self.rest = self.rest.get(n..).ok_or_else(|| invalid("truncated record"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn u8(&mut self) -> Result<u8, Error> {
|
||||
self.take(1)?.first().copied().ok_or_else(|| invalid("truncated byte"))
|
||||
}
|
||||
|
||||
fn u16(&mut self) -> Result<u16, Error> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().map_err(|_| invalid("truncated u16"))?))
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Result<u32, Error> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("truncated u32"))?))
|
||||
}
|
||||
|
||||
fn f64(&mut self) -> Result<f64, Error> {
|
||||
let n = f64::from_le_bytes(self.take(8)?.try_into().map_err(|_| invalid("truncated number"))?);
|
||||
if !n.is_finite() {
|
||||
return Err(invalid("nonfinite numeric value"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// An `XLWideString` of at most `max` UTF-16 units; `None` for the null string when
|
||||
/// `nullable`.
|
||||
fn string(&mut self, max: u32, nullable: bool) -> Result<Option<String>, Error> {
|
||||
let count = self.u32()?;
|
||||
if nullable && count == u32::MAX {
|
||||
return Ok(None);
|
||||
}
|
||||
if count > max {
|
||||
return Err(Error::TooLarge("XLSB string length exceeds its limit".into()));
|
||||
}
|
||||
let size = (count as usize).checked_mul(2).ok_or_else(|| invalid("string length overflow"))?;
|
||||
let bytes = self.take(size)?;
|
||||
let mut text = String::new();
|
||||
for ch in char::decode_utf16(bytes.as_chunks::<2>().0.iter().copied().map(u16::from_le_bytes)) {
|
||||
text.push(ch.map_err(|_| invalid("invalid UTF-16 string"))?);
|
||||
}
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
fn text(&mut self, max: u32) -> Result<String, Error> {
|
||||
self.string(max, false)?.ok_or_else(|| invalid("null string"))
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<(), Error> {
|
||||
if !self.rest.is_empty() {
|
||||
return Err(invalid("unexpected bytes after record payload"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The records of one part, with future-record and alternate-content blocks skipped.
|
||||
struct Records<'a, 'l> {
|
||||
bytes: Bytes<'a>,
|
||||
limits: &'l Limits,
|
||||
budget: &'l Budget,
|
||||
blocks: Vec<u16>,
|
||||
}
|
||||
|
||||
impl<'a, 'l> Records<'a, 'l> {
|
||||
fn new(bytes: &'a [u8], limits: &'l Limits, budget: &'l Budget) -> Self {
|
||||
Self { bytes: Bytes::new(bytes), limits, budget, blocks: vec![] }
|
||||
}
|
||||
|
||||
fn next(&mut self, warnings: &mut Warnings) -> Result<Option<(u16, &'a [u8])>, Error> {
|
||||
while !self.bytes.rest.is_empty() {
|
||||
self.budget.record(self.limits)?;
|
||||
let first = self.bytes.u8()?;
|
||||
let id = if first & 0x80 == 0 {
|
||||
u16::from(first)
|
||||
} else {
|
||||
let id = u16::from(first & 0x7f) | (u16::from(self.bytes.u8()? & 0x7f) << 7);
|
||||
if id < 128 {
|
||||
return Err(invalid("invalid two-byte record type"));
|
||||
}
|
||||
id
|
||||
};
|
||||
let mut size = 0usize;
|
||||
for shift in [0, 7, 14, 21] {
|
||||
let b = self.bytes.u8()?;
|
||||
size |= usize::from(b & 0x7f) << shift;
|
||||
if b & 0x80 == 0 || shift == 21 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let payload = self.bytes.take(size)?;
|
||||
match id {
|
||||
// BrtFRTBegin, BrtACBegin.
|
||||
35 | 37 => {
|
||||
let mut b = Bytes::new(payload);
|
||||
if id == 35 {
|
||||
b.take(4)?;
|
||||
} else {
|
||||
let count = b.u16()?;
|
||||
if count == 0 {
|
||||
return Err(invalid("empty alternate-content version list"));
|
||||
}
|
||||
b.take(usize::from(count) * 4)?;
|
||||
}
|
||||
b.finish()?;
|
||||
if self.blocks.len() >= 64 {
|
||||
return Err(invalid("extension blocks nested too deeply"));
|
||||
}
|
||||
self.blocks.push(id + 1);
|
||||
warnings.warn("unsupported XLSB future-record and alternate-content blocks were skipped");
|
||||
}
|
||||
// BrtFRTEnd, BrtACEnd.
|
||||
36 | 38 => {
|
||||
if self.blocks.pop() != Some(id) || !payload.is_empty() {
|
||||
return Err(invalid("mismatched extension block end"));
|
||||
}
|
||||
}
|
||||
_ if !self.blocks.is_empty() => {}
|
||||
_ => return Ok(Some((id, payload))),
|
||||
}
|
||||
}
|
||||
if !self.blocks.is_empty() {
|
||||
return Err(invalid("unterminated extension block"));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn first(&mut self, id: u16, warnings: &mut Warnings) -> Result<(), Error> {
|
||||
if self.next(warnings)? != Some((id, &[])) {
|
||||
return Err(invalid("missing part begin record"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end(&mut self, payload: &[u8], warnings: &mut Warnings) -> Result<(), Error> {
|
||||
if !payload.is_empty() || self.next(warnings)?.is_some() {
|
||||
return Err(invalid("records after part end"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The workbook part: BrtBeginBook, BrtWbProp, the BrtBundleSh list.
|
||||
pub(crate) fn workbook(bytes: &[u8], rels: &Rels, limits: &Limits, budget: &Budget, warnings: &mut Warnings) -> Result<Workbook, Error> {
|
||||
let mut records = Records::new(bytes, limits, budget);
|
||||
records.first(131, warnings)?;
|
||||
let mut wb = Workbook::default();
|
||||
let mut bundle = false;
|
||||
let mut bundle_seen = false;
|
||||
while let Some((id, payload)) = records.next(warnings)? {
|
||||
let mut b = Bytes::new(payload);
|
||||
match id {
|
||||
132 => {
|
||||
if bundle || !bundle_seen {
|
||||
return Err(invalid("incomplete worksheet list"));
|
||||
}
|
||||
records.end(payload, warnings)?;
|
||||
if wb.sheets.is_empty() {
|
||||
return Err(invalid("no supported worksheets"));
|
||||
}
|
||||
return Ok(wb);
|
||||
}
|
||||
143 => {
|
||||
if bundle_seen {
|
||||
return Err(invalid("duplicate worksheet list"));
|
||||
}
|
||||
bundle_seen = true;
|
||||
bundle = true;
|
||||
b.finish()?;
|
||||
}
|
||||
144 => {
|
||||
if !bundle {
|
||||
return Err(invalid("unexpected worksheet list end"));
|
||||
}
|
||||
bundle = false;
|
||||
b.finish()?;
|
||||
}
|
||||
153 => {
|
||||
wb.date_1904 = b.u32()? & 1 != 0;
|
||||
b.u32()?;
|
||||
b.text(31)?;
|
||||
b.finish()?;
|
||||
}
|
||||
156 => {
|
||||
if !bundle {
|
||||
return Err(invalid("worksheet outside the worksheet list"));
|
||||
}
|
||||
if wb.sheets.len() >= MAX_SHEETS {
|
||||
return Err(Error::TooLarge(format!("XLSB exceeds {MAX_SHEETS} sheets")));
|
||||
}
|
||||
let visibility = match b.u32()? {
|
||||
0 => Visibility::Visible,
|
||||
1 => Visibility::Hidden,
|
||||
2 => Visibility::VeryHidden,
|
||||
_ => return Err(invalid("invalid sheet visibility")),
|
||||
};
|
||||
let sheet_id = b.u32()?;
|
||||
if !(1..=65535).contains(&sheet_id) {
|
||||
return Err(invalid("invalid worksheet ID"));
|
||||
}
|
||||
let rel_id = b.string(255, true)?;
|
||||
let name = b.text(31)?;
|
||||
b.finish()?;
|
||||
let Some(rel_id) = rel_id else {
|
||||
if visibility != Visibility::VeryHidden {
|
||||
return Err(invalid("worksheet relationship is null"));
|
||||
}
|
||||
warnings.warn("XLSB module sheets were skipped");
|
||||
continue;
|
||||
};
|
||||
let rel = rels.by_id(&rel_id).ok_or_else(|| invalid("worksheet relationship is missing"))?;
|
||||
if rel.external {
|
||||
return Err(invalid("external sheet relationship"));
|
||||
}
|
||||
let kind = SheetKind::of(rel.kind());
|
||||
if let SheetKind::Other(_) = kind {
|
||||
return Err(invalid("unknown sheet relationship type"));
|
||||
}
|
||||
wb.sheets.push(SheetEntry { name, sheet_id, visibility, rel_id, kind, part: Some(rel.target.clone()) });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(invalid("missing workbook end record"))
|
||||
}
|
||||
|
||||
/// A `RichStr`: the text; formatting and phonetic runs are skipped.
|
||||
fn rich_string(b: &mut Bytes<'_>) -> Result<String, Error> {
|
||||
let flags = b.u8()?;
|
||||
let text = b.text(32767)?;
|
||||
if flags & 1 != 0 {
|
||||
let count = b.u32()?;
|
||||
if count > 32767 {
|
||||
return Err(invalid("too many rich-text runs"));
|
||||
}
|
||||
b.take(count as usize * 4)?;
|
||||
}
|
||||
if flags & 2 != 0 {
|
||||
b.text(32767)?;
|
||||
let count = b.u32()?;
|
||||
if count > 32767 {
|
||||
return Err(invalid("too many phonetic runs"));
|
||||
}
|
||||
b.take(count as usize * 10)?;
|
||||
// Writers pad the phonetic data; nothing after it is read.
|
||||
b.rest = &[];
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn item(text: String) -> StringItem {
|
||||
StringItem { text: Arc::from(text), ..StringItem::default() }
|
||||
}
|
||||
|
||||
/// The shared strings part: BrtBeginSst, BrtSSTItem, BrtEndSst.
|
||||
pub(crate) fn shared_strings(bytes: &[u8], limits: &Limits, budget: &Budget, warnings: &mut Warnings) -> Result<Vec<StringItem>, Error> {
|
||||
let mut records = Records::new(bytes, limits, budget);
|
||||
let Some((159, payload)) = records.next(warnings)? else {
|
||||
return Err(invalid("missing shared-string begin record"));
|
||||
};
|
||||
let mut header = Bytes::new(payload);
|
||||
let total = header.u32()?;
|
||||
let unique = header.u32()?;
|
||||
header.finish()?;
|
||||
// Each item is kept with its own allocation: no more than there are cells to show them.
|
||||
if unique > total || unique as usize > limits.binary_cells {
|
||||
return Err(Error::TooLarge("invalid or oversized shared-string count".into()));
|
||||
}
|
||||
let mut strings = vec![];
|
||||
while let Some((id, payload)) = records.next(warnings)? {
|
||||
match id {
|
||||
160 => {
|
||||
records.end(payload, warnings)?;
|
||||
if strings.len() != unique as usize {
|
||||
return Err(invalid("shared-string count does not match records"));
|
||||
}
|
||||
return Ok(strings);
|
||||
}
|
||||
19 => {
|
||||
if strings.len() >= unique as usize {
|
||||
return Err(invalid("too many shared-string items"));
|
||||
}
|
||||
let mut b = Bytes::new(payload);
|
||||
let text = rich_string(&mut b)?;
|
||||
b.finish()?;
|
||||
budget.text(text.len(), limits)?;
|
||||
strings.push(item(text));
|
||||
}
|
||||
_ => return Err(invalid("unexpected shared-string record")),
|
||||
}
|
||||
}
|
||||
Err(invalid("missing shared-string end record"))
|
||||
}
|
||||
|
||||
fn error(code: u8) -> Result<CellError, Error> {
|
||||
Ok(match code {
|
||||
0x00 => CellError::Null,
|
||||
0x07 => CellError::Div0,
|
||||
0x0f => CellError::Value,
|
||||
0x17 => CellError::Ref,
|
||||
0x1d => CellError::Name,
|
||||
0x24 => CellError::Num,
|
||||
0x2a => CellError::NA,
|
||||
0x2b => CellError::GettingData,
|
||||
_ => return Err(invalid("unknown cell error code")),
|
||||
})
|
||||
}
|
||||
|
||||
fn boolean(b: &mut Bytes<'_>) -> Result<bool, Error> {
|
||||
match b.u8()? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(invalid("invalid boolean value")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula after a cell's saved result: checked for framing, not decoded.
|
||||
fn skip_formula(b: &mut Bytes<'_>) -> Result<(), Error> {
|
||||
b.take(2)?;
|
||||
let cce = b.u32()?;
|
||||
if !(1..=16384).contains(&cce) {
|
||||
return Err(invalid("invalid formula token length"));
|
||||
}
|
||||
b.take(cce as usize)?;
|
||||
let extra = b.u32()?;
|
||||
b.take(extra as usize)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value(id: u16, b: &mut Bytes<'_>, strings: usize, limits: &Limits, budget: &Budget) -> Result<CellValue, Error> {
|
||||
let value = match id {
|
||||
1 => CellValue::Empty,
|
||||
2 => {
|
||||
let rk = b.u32()?;
|
||||
let n = if rk & 2 != 0 { f64::from((rk as i32) >> 2) } else { f64::from_bits(u64::from(rk & !3) << 32) };
|
||||
let n = if rk & 1 != 0 { n / 100.0 } else { n };
|
||||
if !n.is_finite() {
|
||||
return Err(invalid("nonfinite RK number"));
|
||||
}
|
||||
CellValue::Number(n)
|
||||
}
|
||||
3 | 11 => CellValue::Error(error(b.u8()?)?),
|
||||
4 | 10 => CellValue::Bool(boolean(b)?),
|
||||
5 | 9 => CellValue::Number(b.f64()?),
|
||||
6 | 8 => {
|
||||
let s = b.text(32767)?;
|
||||
budget.text(s.len(), limits)?;
|
||||
CellValue::Text(Arc::from(s))
|
||||
}
|
||||
62 => {
|
||||
let s = rich_string(b)?;
|
||||
budget.text(s.len(), limits)?;
|
||||
CellValue::Inline(item(s))
|
||||
}
|
||||
7 => {
|
||||
let i = b.u32()?;
|
||||
if i as usize >= strings {
|
||||
return Err(invalid("shared-string index is out of bounds"));
|
||||
}
|
||||
CellValue::Shared(i)
|
||||
}
|
||||
_ => return Err(invalid("unsupported cell record")),
|
||||
};
|
||||
if (8..=11).contains(&id) {
|
||||
skip_formula(b)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// A worksheet part: rows (BrtRowHdr) and cells inside BrtBeginSheetData, merged cells after.
|
||||
pub(crate) fn worksheet(
|
||||
bytes: &[u8],
|
||||
strings: usize,
|
||||
limits: &Limits,
|
||||
budget: &Budget,
|
||||
warnings: &mut Warnings,
|
||||
each: &mut dyn FnMut(Row) -> Result<(), Error>,
|
||||
) -> Result<SheetPart, Error> {
|
||||
let mut records = Records::new(bytes, limits, budget);
|
||||
records.first(129, warnings)?;
|
||||
let mut sheet = SheetPart::default();
|
||||
let mut data = false;
|
||||
let mut data_seen = false;
|
||||
let mut row: Option<Row> = None;
|
||||
let mut col = None;
|
||||
while let Some((id, payload)) = records.next(warnings)? {
|
||||
let mut b = Bytes::new(payload);
|
||||
match id {
|
||||
130 => {
|
||||
if data || !data_seen {
|
||||
return Err(invalid("incomplete worksheet data"));
|
||||
}
|
||||
records.end(payload, warnings)?;
|
||||
return Ok(sheet);
|
||||
}
|
||||
145 => {
|
||||
if data_seen {
|
||||
return Err(invalid("duplicate worksheet data"));
|
||||
}
|
||||
data_seen = true;
|
||||
data = true;
|
||||
b.finish()?;
|
||||
}
|
||||
146 => {
|
||||
if !data {
|
||||
return Err(invalid("unexpected worksheet data end"));
|
||||
}
|
||||
data = false;
|
||||
b.finish()?;
|
||||
if let Some(r) = row.take() {
|
||||
each(r)?;
|
||||
}
|
||||
}
|
||||
0 => {
|
||||
if !data {
|
||||
return Err(invalid("row outside worksheet data"));
|
||||
}
|
||||
let r = b.u32()?;
|
||||
if r >= MAX_ROWS || row.as_ref().is_some_and(|previous| r <= previous.index) {
|
||||
return Err(invalid("row is out of bounds or unordered"));
|
||||
}
|
||||
// ixfe, miyRw and the flags are not read.
|
||||
b.take(9)?;
|
||||
let spans = b.u32()?;
|
||||
if spans > 16 {
|
||||
return Err(invalid("too many row spans"));
|
||||
}
|
||||
b.take(spans as usize * 8)?;
|
||||
b.finish()?;
|
||||
if let Some(previous) = row.replace(Row { index: r, ..Row::default() }) {
|
||||
each(previous)?;
|
||||
}
|
||||
sheet.rows += 1;
|
||||
col = None;
|
||||
}
|
||||
1..=11 | 62 => {
|
||||
if !data {
|
||||
return Err(invalid("cell outside worksheet data"));
|
||||
}
|
||||
let current = row.as_mut().ok_or_else(|| invalid("cell before row header"))?;
|
||||
let c = b.u32()?;
|
||||
if c >= MAX_COLS || col.is_some_and(|previous| c <= previous) {
|
||||
return Err(invalid("column is out of bounds or unordered"));
|
||||
}
|
||||
let style = b.u32()?;
|
||||
let value = value(id, &mut b, strings, limits, budget)?;
|
||||
b.finish()?;
|
||||
if value != CellValue::Empty {
|
||||
budget.cell(limits)?;
|
||||
}
|
||||
current.cells.push(Cell {
|
||||
at: CellRef::new(current.index, c),
|
||||
style: style & 0x00FF_FFFF,
|
||||
phonetic: style & 0x0100_0000 != 0,
|
||||
value,
|
||||
..Cell::default()
|
||||
});
|
||||
col = Some(c);
|
||||
}
|
||||
176 => {
|
||||
if data {
|
||||
return Err(invalid("merge inside worksheet data"));
|
||||
}
|
||||
let r0 = b.u32()?;
|
||||
let r1 = b.u32()?;
|
||||
let c0 = b.u32()?;
|
||||
let c1 = b.u32()?;
|
||||
b.finish()?;
|
||||
if r0 > r1 || c0 > c1 || r1 >= MAX_ROWS || c1 >= MAX_COLS {
|
||||
warnings.warn("invalid XLSB merged ranges were skipped");
|
||||
continue;
|
||||
}
|
||||
if sheet.merges.len() >= MAX_MERGES {
|
||||
return Err(Error::TooLarge(format!("XLSB exceeds {MAX_MERGES} merges")));
|
||||
}
|
||||
sheet.merges.push(Range::new(CellRef::new(r0, c0), CellRef::new(r1, c1)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(invalid("missing worksheet end record"))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! A whole workbook read through the part layer, for assertions.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use craft_xlsx::{CellRef, Error, Limits, Package, Range, SheetKind, Value, Visibility};
|
||||
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
pub visibility: Visibility,
|
||||
pub cells: BTreeMap<CellRef, Value>,
|
||||
pub merges: Vec<Range>,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
pub fn value(&self, at: CellRef) -> Value {
|
||||
self.cells.get(&at).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Book {
|
||||
pub date_1904: bool,
|
||||
pub sheets: Vec<Sheet>,
|
||||
pub skipped: Vec<(String, SheetKind)>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Reads every worksheet; cells with no value are left out.
|
||||
pub fn read_with(bytes: &[u8], limits: &Limits) -> Result<Book, Error> {
|
||||
let mut p = Package::open(bytes, limits)?;
|
||||
let wb = p.workbook()?;
|
||||
let strings = p.shared_strings()?;
|
||||
let mut book = Book { date_1904: wb.date_1904, sheets: vec![], skipped: vec![], warnings: vec![] };
|
||||
for e in &wb.sheets {
|
||||
if e.kind != SheetKind::Worksheet {
|
||||
book.skipped.push((e.name.clone(), e.kind.clone()));
|
||||
continue;
|
||||
}
|
||||
let mut cells = BTreeMap::new();
|
||||
let part = p.sheet(e, |row| {
|
||||
for c in row.cells {
|
||||
let v = c.value.resolve(&strings, wb.date_1904);
|
||||
if !v.is_empty() {
|
||||
cells.insert(c.at, v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
book.sheets.push(Sheet { name: e.name.clone(), visibility: e.visibility, cells, merges: part.merges });
|
||||
}
|
||||
book.warnings = p.warnings().to_vec();
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
pub fn read(bytes: &[u8]) -> Result<Book, Error> {
|
||||
read_with(bytes, &Limits::default())
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Binary workbooks: BIFF12 records and packages assembled here from [MS-XLSB].
|
||||
//! Adapted from GridCraft's `crates/xlsx/src/tests/xlsb_tests.rs`.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{read, read_with};
|
||||
use craft_xlsx::testing::zip;
|
||||
use craft_xlsx::{CellError, CellRef, Error, Limits, Range, SheetKind, Value, Visibility, is_xlsb};
|
||||
|
||||
const ROOT: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="book" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="records/book.bin"/></Relationships>"#;
|
||||
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="one" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/one.bin"/><Relationship Id="two" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="../data/two.bin"/><Relationship Id="strings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="../text/strings.bin"/></Relationships>"#;
|
||||
const TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/records/book.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/><Override PartName="/data/one.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/data/two.bin" ContentType="application/vnd.ms-excel.worksheet"/><Override PartName="/text/strings.bin" ContentType="application/vnd.ms-excel.sharedStrings"/></Types>"#;
|
||||
|
||||
fn record(id: u16, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
if id < 128 {
|
||||
out.push(id as u8);
|
||||
} else {
|
||||
out.extend([(id as u8 & 0x7f) | 0x80, (id >> 7) as u8]);
|
||||
}
|
||||
let mut len = bytes.len();
|
||||
loop {
|
||||
let byte = (len & 0x7f) as u8;
|
||||
len >>= 7;
|
||||
out.push(if len == 0 { byte } else { byte | 0x80 });
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.extend(bytes);
|
||||
out
|
||||
}
|
||||
fn wide(text: &str) -> Vec<u8> {
|
||||
let units: Vec<_> = text.encode_utf16().collect();
|
||||
let mut out = (units.len() as u32).to_le_bytes().to_vec();
|
||||
for unit in units {
|
||||
out.extend(unit.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
fn rich(text: &str, extras: bool) -> Vec<u8> {
|
||||
let mut out = vec![if extras { 3 } else { 0 }];
|
||||
out.extend(wide(text));
|
||||
if extras {
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend([0, 0, 0, 0]); // One formatting run.
|
||||
out.extend(wide("sound"));
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend([0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); // One ten-byte phonetic run.
|
||||
}
|
||||
out
|
||||
}
|
||||
fn row(r: u32) -> Vec<u8> {
|
||||
let mut out = r.to_le_bytes().to_vec();
|
||||
out.extend([0; 13]);
|
||||
record(0, &out)
|
||||
}
|
||||
fn cell(id: u16, col: u32, value: &[u8]) -> Vec<u8> {
|
||||
let mut data = col.to_le_bytes().to_vec();
|
||||
data.extend([0; 4]);
|
||||
data.extend(value);
|
||||
if (8..=11).contains(&id) {
|
||||
data.extend([0; 2]); // Formula flags.
|
||||
data.extend(3u32.to_le_bytes());
|
||||
data.extend([0x1e, 1, 0]); // PtgInt(1): not decoded, the saved result is read.
|
||||
data.extend(0u32.to_le_bytes());
|
||||
}
|
||||
record(id, &data)
|
||||
}
|
||||
fn worksheet(data: &[u8], tail: &[u8]) -> Vec<u8> {
|
||||
let mut out = record(129, &[]);
|
||||
out.extend(record(148, &[0xff; 16])); // A bogus dimension must never allocate a dense grid.
|
||||
out.extend(record(145, &[]));
|
||||
out.extend(data);
|
||||
out.extend(record(146, &[]));
|
||||
out.extend(tail);
|
||||
out.extend(record(130, &[]));
|
||||
out
|
||||
}
|
||||
fn book(date1904: bool) -> Vec<u8> {
|
||||
let mut out = record(131, &[]);
|
||||
let mut props = u32::from(date1904).to_le_bytes().to_vec();
|
||||
props.extend(0u32.to_le_bytes());
|
||||
props.extend(wide(""));
|
||||
out.extend(record(153, &props));
|
||||
out.extend(record(143, &[]));
|
||||
for (id, rel, name, state) in [(1u32, "one", "Data", 0u32), (2, "two", "Hidden", 1)] {
|
||||
let mut data = state.to_le_bytes().to_vec();
|
||||
data.extend(id.to_le_bytes());
|
||||
data.extend(wide(rel));
|
||||
data.extend(wide(name));
|
||||
out.extend(record(156, &data));
|
||||
}
|
||||
out.extend(record(144, &[]));
|
||||
out.extend(record(132, &[]));
|
||||
out
|
||||
}
|
||||
fn sst() -> Vec<u8> {
|
||||
let mut out = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
out.extend(record(19, &rich("shared 🦀 café", true)));
|
||||
out.extend(record(160, &[]));
|
||||
out
|
||||
}
|
||||
fn package(book: &[u8], sheet: &[u8], strings: &[u8], roots: &str, rels: &str, types: &str) -> Vec<u8> {
|
||||
zip(&[
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", roots.as_bytes()),
|
||||
("records/book.bin", book),
|
||||
("records/_rels/book.bin.rels", rels.as_bytes()),
|
||||
("data/one.bin", sheet),
|
||||
("data/two.bin", &worksheet(&[], &[])),
|
||||
("text/strings.bin", strings),
|
||||
])
|
||||
}
|
||||
fn file(sheet: &[u8]) -> Vec<u8> {
|
||||
package(&book(false), sheet, &sst(), ROOT, RELS, TYPES)
|
||||
}
|
||||
fn text(s: &str) -> Value {
|
||||
Value::Text(s.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_caches_rk_unicode_visibility_and_epochs() {
|
||||
let mut data = row(2);
|
||||
let real_rk = (3.5f64.to_bits() >> 32) as u32;
|
||||
for c in [
|
||||
cell(1, 0, &[]),
|
||||
cell(2, 1, &((-123i32 << 2) | 2).to_le_bytes()),
|
||||
cell(2, 2, &((123i32 << 2) | 3).to_le_bytes()),
|
||||
cell(2, 3, &real_rk.to_le_bytes()),
|
||||
cell(2, 4, &(real_rk | 1).to_le_bytes()),
|
||||
cell(3, 5, &[0x17]),
|
||||
cell(4, 6, &[1]),
|
||||
cell(5, 7, &123.5f64.to_le_bytes()),
|
||||
cell(6, 8, &wide("inline λ")),
|
||||
cell(7, 9, &0u32.to_le_bytes()),
|
||||
cell(8, 10, &wide("cached text")),
|
||||
cell(9, 11, &2.0f64.to_le_bytes()),
|
||||
cell(10, 12, &[0]),
|
||||
cell(11, 13, &[7]),
|
||||
cell(62, 14, &rich("rich text", true)),
|
||||
] {
|
||||
data.extend(c);
|
||||
}
|
||||
let mut merge = vec![];
|
||||
for n in [5u32, 6, 0, 1] {
|
||||
merge.extend(n.to_le_bytes());
|
||||
}
|
||||
let sheet = worksheet(&data, &record(176, &merge));
|
||||
let expected = [
|
||||
Value::Empty,
|
||||
Value::Number(-123.0),
|
||||
Value::Number(1.23),
|
||||
Value::Number(3.5),
|
||||
Value::Number(0.035),
|
||||
Value::Error(CellError::Ref),
|
||||
Value::Bool(true),
|
||||
Value::Number(123.5),
|
||||
text("inline λ"),
|
||||
text("shared 🦀 café"),
|
||||
text("cached text"),
|
||||
Value::Number(2.0),
|
||||
Value::Bool(false),
|
||||
Value::Error(CellError::Div0),
|
||||
text("rich text"),
|
||||
];
|
||||
for epoch in [false, true] {
|
||||
let bytes = package(&book(epoch), &sheet, &sst(), ROOT, RELS, TYPES);
|
||||
assert!(is_xlsb(&bytes));
|
||||
let wb = read(&bytes).unwrap();
|
||||
assert_eq!(wb.date_1904, epoch);
|
||||
assert_eq!(wb.sheets.len(), 2);
|
||||
assert_eq!((wb.sheets[0].name.as_str(), wb.sheets[1].visibility), ("Data", Visibility::Hidden));
|
||||
assert_eq!(wb.sheets[0].cells.len(), 14);
|
||||
assert_eq!(wb.sheets[0].merges, vec![Range::new(CellRef::new(5, 0), CellRef::new(6, 1))]);
|
||||
for (col, expected) in expected.iter().enumerate() {
|
||||
assert_eq!(&wb.sheets[0].value(CellRef::new(2, col as u32)), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_carry_their_format_index_and_blank_cells_their_format() {
|
||||
let mut data = row(0);
|
||||
let mut blank = 0u32.to_le_bytes().to_vec();
|
||||
blank.extend((7u32 | 0x0100_0000).to_le_bytes());
|
||||
data.extend(record(1, &blank));
|
||||
let bytes = file(&worksheet(&data, &[]));
|
||||
let mut p = craft_xlsx::Package::open(&bytes, &craft_xlsx::Limits::default()).unwrap();
|
||||
assert!(p.styles().unwrap().is_none());
|
||||
let wb = p.workbook().unwrap();
|
||||
let mut cells = vec![];
|
||||
p.sheet(&wb.sheets[0], |r| {
|
||||
cells.extend(r.cells);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(cells.len(), 1);
|
||||
assert_eq!((cells[0].style, cells[0].phonetic, &cells[0].value), (7, true, &craft_xlsx::CellValue::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_blocks_are_skipped_with_strict_boundaries_and_record_framing() {
|
||||
let mut data = row(0);
|
||||
data.extend(cell(5, 0, &1.0f64.to_le_bytes()));
|
||||
data.extend(record(35, &[0; 4]));
|
||||
data.extend(record(37, &[1, 0, 0, 0, 0, 0]));
|
||||
data.extend(cell(5, 1, &99.0f64.to_le_bytes()));
|
||||
data.extend(record(130, &[])); // Looks like the sheet's end, but is inside a skipped block.
|
||||
data.extend(record(38, &[]));
|
||||
data.extend(record(36, &[]));
|
||||
data.extend([0xe8, 0x87, 0x80, 0x80, 0x80, 0x80]); // Record 1000: high bits of the second id and fourth length byte ignored.
|
||||
data.extend(cell(5, 1, &2.0f64.to_le_bytes()));
|
||||
let wb = read(&file(&worksheet(&data, &[]))).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.len(), 2);
|
||||
assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(2.0));
|
||||
assert!(wb.warnings.iter().any(|w| w.contains("blocks were skipped")));
|
||||
for bad in [
|
||||
vec![0x81],
|
||||
vec![0x81, 0],
|
||||
vec![1, 0x80],
|
||||
vec![1, 10, 0],
|
||||
record(36, &[]),
|
||||
[record(35, &[0; 4]), record(38, &[])].concat(),
|
||||
record(35, &[0; 4]),
|
||||
record(37, &[0, 0]),
|
||||
] {
|
||||
assert!(read(&file(&worksheet(&bad, &[]))).is_err(), "{bad:?}");
|
||||
}
|
||||
let nested = record(35, &[0; 4]).repeat(65);
|
||||
assert!(read(&file(&worksheet(&nested, &[]))).err().unwrap().to_string().contains("nested too deeply"));
|
||||
let mut missing_end = worksheet(&[], &[]);
|
||||
missing_end.truncate(missing_end.len() - record(130, &[]).len());
|
||||
assert!(read(&file(&missing_end)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_strings_and_coordinates_fail_without_silent_data_loss() {
|
||||
for bad in [
|
||||
cell(4, 0, &[2]),
|
||||
cell(3, 0, &[0xff]),
|
||||
cell(5, 0, &f64::INFINITY.to_le_bytes()),
|
||||
cell(2, 0, &0x7ff00000u32.to_le_bytes()),
|
||||
cell(7, 0, &1u32.to_le_bytes()),
|
||||
cell(6, 0, &[1, 0, 0, 0, 0, 0xd8]),
|
||||
cell(6, 0, &u32::MAX.to_le_bytes()),
|
||||
cell(5, 16384, &1.0f64.to_le_bytes()),
|
||||
cell(5, 0, &[0; 3]),
|
||||
cell(62, 0, &[1, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]),
|
||||
] {
|
||||
assert!(read(&file(&worksheet(&[row(0), bad].concat(), &[]))).is_err());
|
||||
}
|
||||
assert!(read(&file(&worksheet(&cell(5, 0, &1.0f64.to_le_bytes()), &[]))).is_err());
|
||||
assert!(read(&file(&worksheet(&row(1048576), &[]))).is_err());
|
||||
let repeated = [row(0), cell(5, 0, &1.0f64.to_le_bytes()), cell(5, 0, &2.0f64.to_le_bytes())].concat();
|
||||
assert!(read(&file(&worksheet(&repeated, &[]))).is_err());
|
||||
let mut formula = [0u8; 18].to_vec(); // Cell header, saved number and flags, but no formula.
|
||||
assert!(read(&file(&worksheet(&[row(0), record(9, &formula)].concat(), &[]))).is_err());
|
||||
formula.extend(0u32.to_le_bytes()); // A formula of no tokens.
|
||||
assert!(read(&file(&worksheet(&[row(0), record(9, &formula)].concat(), &[]))).is_err());
|
||||
let bad_sst = [record(159, &[1, 0, 0, 0, 2, 0, 0, 0]), record(160, &[])].concat();
|
||||
assert!(read(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_must_be_internal_and_typed() {
|
||||
let sheet = worksheet(&[], &[]);
|
||||
for (roots, rels, types) in [
|
||||
(ROOT.replace("officeDocument\"", "wrong\""), RELS.to_owned(), TYPES.to_owned()),
|
||||
(ROOT.replace("Target=", "TargetMode=\"External\" Target="), RELS.to_owned(), TYPES.to_owned()),
|
||||
(ROOT.to_owned(), RELS.replace("Target=\"../data/one.bin\"", "Target=\"missing.bin\""), TYPES.to_owned()),
|
||||
(
|
||||
ROOT.to_owned(),
|
||||
RELS.replace("Target=\"../data/one.bin\"", "TargetMode=\"External\" Target=\"https://example.com/data.bin\""),
|
||||
TYPES.to_owned(),
|
||||
),
|
||||
(
|
||||
ROOT.to_owned(),
|
||||
RELS.replace("Target=\"../text/strings.bin\"", "TargetMode=\"External\" Target=\"https://example.com/strings.bin\""),
|
||||
TYPES.to_owned(),
|
||||
),
|
||||
(ROOT.to_owned(), RELS.to_owned(), TYPES.replace("application/vnd.ms-excel.worksheet", "application/xml")),
|
||||
(ROOT.to_owned(), RELS.replace("Id=\"two\"", "Id=\"three\""), TYPES.to_owned()),
|
||||
] {
|
||||
assert!(read(&package(&book(false), &sheet, &sst(), &roots, &rels, &types)).is_err(), "{roots} {rels} {types}");
|
||||
}
|
||||
let chartsheet = RELS.replace(
|
||||
"Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
|
||||
"Id=\"two\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet",
|
||||
);
|
||||
let wb = read(&package(&book(false), &sheet, &sst(), ROOT, &chartsheet, TYPES)).unwrap();
|
||||
assert_eq!(wb.sheets.len(), 1);
|
||||
assert_eq!(wb.skipped, [("Hidden".to_string(), SheetKind::Chartsheet)]);
|
||||
// A binary part elsewhere does not make an XML workbook package binary.
|
||||
let elsewhere = TYPES.replace("/records/book.bin\"", "/elsewhere.bin\"");
|
||||
assert!(!is_xlsb(&package(&book(false), &sheet, &sst(), ROOT, RELS, &elsewhere)));
|
||||
let spec_type = TYPES.replace("application/vnd.ms-excel.sheet.binary.macroEnabled.main", "application/vnd.ms-excel.main");
|
||||
assert!(is_xlsb(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)));
|
||||
assert!(read(&package(&book(false), &sheet, &sst(), ROOT, RELS, &spec_type)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_counts_and_decoded_text_are_bounded() {
|
||||
let bad_sst = record(159, &[0xff; 8]);
|
||||
assert!(read(&package(&book(false), &worksheet(&[], &[]), &bad_sst, ROOT, RELS, TYPES)).is_err());
|
||||
let mut count_mismatch = record(159, &[1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
count_mismatch.extend(record(160, &[]));
|
||||
assert!(read(&package(&book(false), &worksheet(&[], &[]), &count_mismatch, ROOT, RELS, TYPES)).is_err());
|
||||
let mut data = row(0);
|
||||
let text = wide(&"x".repeat(32767));
|
||||
for col in 0..2050 {
|
||||
data.extend(cell(6, col, &text));
|
||||
}
|
||||
// Just over 64 MiB of text, the allowance before storytold/gridcraft#2126.
|
||||
assert_eq!(read(&file(&worksheet(&data, &[]))).unwrap().sheets[0].cells.len(), 2050);
|
||||
}
|
||||
|
||||
/// A row as Excel writes it: the row header, then `BrtRwDescent` (1024) in an alternate-content
|
||||
/// block. [MS-XLSB] 2.4.791: one per non-empty row whenever the workbook lists its fonts, which
|
||||
/// Excel always does. Four records per row besides the cells.
|
||||
fn excel_row(r: u32) -> Vec<u8> {
|
||||
[row(r), record(37, &[1, 0, 0, 0, 0, 0]), record(1024, &[0, 0]), record(38, &[])].concat()
|
||||
}
|
||||
/// `n` TRUE cells, `width` to a row from A1, in rows as Excel writes them.
|
||||
fn bool_cells(n: u32, width: u32) -> Vec<u8> {
|
||||
let mut data = vec![];
|
||||
for i in 0..n {
|
||||
if i % width == 0 {
|
||||
data.extend(excel_row(i / width));
|
||||
}
|
||||
data.extend(cell(4, i % width, &[1]));
|
||||
}
|
||||
data
|
||||
}
|
||||
/// A workbook whose two worksheets are the given parts.
|
||||
fn two_sheets(one: &[u8], two: &[u8]) -> Vec<u8> {
|
||||
zip(&[
|
||||
("[Content_Types].xml", TYPES.as_bytes()),
|
||||
("_rels/.rels", ROOT.as_bytes()),
|
||||
("records/book.bin", &book(false)),
|
||||
("records/_rels/book.bin.rels", RELS.as_bytes()),
|
||||
("data/one.bin", one),
|
||||
("data/two.bin", two),
|
||||
("text/strings.bin", &sst()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_a_million_cells_open() {
|
||||
// storytold/gridcraft#2126: a million cells over the workbook was a quarter of what `.xls` allows.
|
||||
let wb = read(&file(&worksheet(&bool_cells(1_000_001, 16), &[]))).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.len(), 1_000_001);
|
||||
assert_eq!(wb.sheets[0].value(CellRef::new(62_500, 0)), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_height_column_opens() {
|
||||
// 1,048,576 cells in Excel's rows are 5.2 million records in one part.
|
||||
let wb = read(&file(&worksheet(&bool_cells(1_048_576, 1), &[]))).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.len(), 1_048_576);
|
||||
assert_eq!(wb.sheets[0].value(CellRef::new(1_048_575, 0)), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_blank_cells_cost_records_not_cells() {
|
||||
// Filling A1:Z200000 and typing 30 values: Excel writes a BrtCellBlank for every formatted
|
||||
// empty cell, 6 million records in all, which an allowance of 5 million per part refused.
|
||||
let mut data = vec![];
|
||||
for r in 0..200_000u32 {
|
||||
data.extend(excel_row(r));
|
||||
for c in 0..26 {
|
||||
data.extend(if r < 10 && c < 3 { cell(4, c, &[1]) } else { cell(1, c, &[]) });
|
||||
}
|
||||
}
|
||||
let wb = read(&file(&worksheet(&data, &[]))).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.len(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_limited_to_four_million_over_the_workbook() {
|
||||
// The `.xls` limit, counted across sheets: 2M + 2M opens, 2M + 2M + 1 does not.
|
||||
let half = bool_cells(2_000_000, 40);
|
||||
let mut over = half.clone();
|
||||
over.extend(excel_row(50_000));
|
||||
over.extend(cell(4, 0, &[1]));
|
||||
let wb = read(&two_sheets(&worksheet(&half, &[]), &worksheet(&half, &[]))).unwrap();
|
||||
assert_eq!(wb.sheets.iter().map(|s| s.cells.len()).sum::<usize>(), 4_000_000);
|
||||
let err = read(&two_sheets(&worksheet(&half, &[]), &worksheet(&over, &[]))).err().unwrap();
|
||||
assert!(matches!(err, Error::TooLarge(_)), "{err}");
|
||||
assert!(err.to_string().contains("4000000"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_share_one_allowance_over_the_workbook() {
|
||||
// About 1,400 records a sheet: over a 2,000 shared allowance, though each sheet alone fits.
|
||||
let sheet = worksheet(&bool_cells(1000, 10), &[]);
|
||||
let bytes = two_sheets(&sheet, &sheet);
|
||||
let records = |n| Limits { binary_records: n, ..Limits::default() };
|
||||
assert!(matches!(read_with(&bytes, &records(2_000)), Err(Error::TooLarge(_))));
|
||||
assert!(read_with(&bytes, &records(4_000)).is_ok());
|
||||
// The default allowance: exactly 40 million records, then one more. The other parts hold 20
|
||||
// (7 in the workbook, 3 shared-string records, 5 + 5 sheet framing records).
|
||||
let filler = record(30, &[]); // Not a cell record: skipped inside the sheet data.
|
||||
let mut data = filler.repeat(40_000_000 - 20);
|
||||
assert!(read(&file(&worksheet(&data, &[]))).is_ok());
|
||||
data.extend(filler.repeat(1));
|
||||
let err = read(&file(&worksheet(&data, &[]))).err().unwrap();
|
||||
assert!(matches!(err, Error::TooLarge(_)) && err.to_string().contains("too many records (more than 40000000)"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_shared_strings_are_limited() {
|
||||
let bytes = file(&worksheet(&[row(0), cell(6, 0, &wide("eleven char"))].concat(), &[]));
|
||||
// The shared string ("shared 🦀 café", 17 bytes) and the cell's 11 are charged.
|
||||
let text = |n| Limits { binary_text: n, ..Limits::default() };
|
||||
assert!(read_with(&bytes, &text(28)).is_ok());
|
||||
assert!(matches!(read_with(&bytes, &text(27)), Err(Error::TooLarge(_))));
|
||||
// A declared count over 4 million shared strings is refused before any item is read.
|
||||
let sst_header = |unique: u32| [record(159, &[unique.to_le_bytes(), unique.to_le_bytes()].concat()), record(160, &[])].concat();
|
||||
let over = read(&package(&book(false), &worksheet(&[], &[]), &sst_header(4_000_001), ROOT, RELS, TYPES)).err().unwrap();
|
||||
assert!(matches!(over, Error::TooLarge(_)), "{over}");
|
||||
// At the limit the count passes, and the missing items are what is wrong.
|
||||
let at = read(&package(&book(false), &worksheet(&[], &[]), &sst_header(4_000_000), ROOT, RELS, TYPES)).err().unwrap();
|
||||
assert!(matches!(at, Error::Invalid(_)), "{at}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phonetic_runs_use_documented_size_and_tolerate_padding() {
|
||||
// [MS-XLSB] 2.5.103: a PhRun is 10 bytes. Padding after the runs is ignored; a run shorter
|
||||
// than documented is refused.
|
||||
let phonetic = |runs: u32, bytes: usize| {
|
||||
let mut out = vec![2];
|
||||
out.extend(wide("ruby"));
|
||||
out.extend(wide("ルビ"));
|
||||
out.extend(runs.to_le_bytes());
|
||||
out.extend(vec![0; bytes]);
|
||||
out
|
||||
};
|
||||
for padded in [phonetic(1, 10), phonetic(1, 12), phonetic(2, 24)] {
|
||||
let wb = read(&file(&worksheet(&[row(0), cell(62, 0, &padded)].concat(), &[]))).unwrap();
|
||||
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("ruby"));
|
||||
}
|
||||
assert!(read(&file(&worksheet(&[row(0), cell(62, 0, &phonetic(2, 12))].concat(), &[]))).is_err());
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! SpreadsheetML workbooks through the part layer.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{read, read_with};
|
||||
use craft_xlsx::testing::{workbook, zip};
|
||||
use craft_xlsx::{CellRef, CellValue, Error, FormulaKind, Limits, Package, Range, SheetKind, Value, Visibility};
|
||||
|
||||
/// A workbook in the strict namespaces with the `x:` prefix, as some writers save it.
|
||||
fn strict() -> Vec<u8> {
|
||||
const NS: &str = r#"xmlns:x="http://purl.oclc.org/ooxml/spreadsheetml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships""#;
|
||||
const RT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships";
|
||||
let book = format!(
|
||||
r#"<x:workbook {NS}><x:workbookPr date1904="1"/><x:bookViews><x:workbookView visibility="hidden" activeTab="0"/><x:workbookView activeTab="2"/></x:bookViews>
|
||||
<x:sheets><x:sheet name="One" sheetId="4" r:id="a"/><x:sheet name="Chart" sheetId="5" r:id="b"/><x:sheet name="Two" sheetId="6" state="veryHidden" r:id="c"/><x:sheet name="Lost" sheetId="7" r:id="zz"/></x:sheets>
|
||||
<x:definedNames><x:definedName name="_xlnm.Print_Area" localSheetId="0"> One!$A$1:$B$2 </x:definedName><x:definedName name="Rate" comment="c" hidden="1">0.2</x:definedName><x:definedName>orphan</x:definedName></x:definedNames></x:workbook>"#
|
||||
);
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="a" Type="{RT}/worksheet" Target="sheets/one.xml"/><Relationship Id="b" Type="{RT}/chartsheet" Target="charts/c.xml"/><Relationship Id="c" Type="{RT}/worksheet" Target="/xl/sheets/two.xml"/><Relationship Id="s" Type="{RT}/sharedStrings" Target="strings.xml"/><Relationship Id="t" Type="{RT}/styles" Target="styles.xml"/></Relationships>"#
|
||||
);
|
||||
let one = format!(
|
||||
r#"<x:worksheet {NS}><x:cols><x:col min="2" max="3" width="20.5" customWidth="1"/><x:col min="0" max="1"/></x:cols><x:sheetData>
|
||||
<x:row r="1"><x:c r="A1" t="s"><x:v>1</x:v></x:c><x:c r="B1" s="1"><x:v>1</x:v></x:c><x:c r="C1"><x:f>B1*2</x:f><x:v>2</x:v></x:c></x:row>
|
||||
<x:row r="2"><x:c r="A2" t="d"><x:v>1904-01-03</x:v></x:c><x:c r="B2" t="s"><x:v>9</x:v></x:c></x:row></x:sheetData>
|
||||
<x:mergeCells><x:mergeCell ref="A3:B4"/><x:mergeCell ref="C3"/></x:mergeCells><x:pageSetup orientation="landscape"/></x:worksheet>"#
|
||||
);
|
||||
zip(&[
|
||||
("[Content_Types].xml", b"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"/>"),
|
||||
(
|
||||
"_rels/.rels",
|
||||
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="w" Type="{RT}/officeDocument" Target="xl/book.xml"/></Relationships>"#).as_bytes(),
|
||||
),
|
||||
("xl/book.xml", book.as_bytes()),
|
||||
("xl/_rels/book.xml.rels", rels.as_bytes()),
|
||||
("xl/sheets/one.xml", one.as_bytes()),
|
||||
("xl/sheets/two.xml", format!("<x:worksheet {NS}><x:sheetData/></x:worksheet>").as_bytes()),
|
||||
("xl/strings.xml", format!(r#"<x:sst {NS}><x:si><x:t>zero</x:t></x:si><x:si><x:r><x:t>o</x:t></x:r><x:r><x:t>ne</x:t></x:r></x:si></x:sst>"#).as_bytes()),
|
||||
(
|
||||
"xl/styles.xml",
|
||||
format!(r#"<x:styleSheet {NS}><x:numFmts><x:numFmt numFmtId="170" formatCode="0.000"/></x:numFmts><x:cellXfs><x:xf/><x:xf numFmtId="170"/></x:cellXfs></x:styleSheet>"#)
|
||||
.as_bytes(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strict_workbook_with_prefixes() {
|
||||
let bytes = strict();
|
||||
let mut p = Package::open(&bytes, &Limits::default()).unwrap();
|
||||
assert!(!p.is_binary());
|
||||
assert_eq!(p.workbook_part(), "xl/book.xml");
|
||||
let wb = p.workbook().unwrap();
|
||||
assert!(wb.date_1904);
|
||||
assert_eq!(wb.active_tab, Some(2));
|
||||
let kinds: Vec<_> = wb.sheets.iter().map(|s| (s.name.as_str(), s.sheet_id, s.kind.clone(), s.part.as_deref(), s.visibility)).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
("One", 4, SheetKind::Worksheet, Some("xl/sheets/one.xml"), Visibility::Visible),
|
||||
("Chart", 5, SheetKind::Chartsheet, Some("xl/charts/c.xml"), Visibility::Visible),
|
||||
("Two", 6, SheetKind::Worksheet, Some("xl/sheets/two.xml"), Visibility::VeryHidden),
|
||||
("Lost", 7, SheetKind::Other(String::new()), None, Visibility::Visible),
|
||||
]
|
||||
);
|
||||
let names: Vec<_> = wb.defined_names.iter().map(|d| (d.name.as_str(), d.local_sheet_id, d.formula.as_str(), d.hidden)).collect();
|
||||
assert_eq!(names, [("_xlnm.Print_Area", Some(0), "One!$A$1:$B$2", false), ("Rate", None, "0.2", true)]);
|
||||
assert!(wb.root.child("bookViews").is_some());
|
||||
|
||||
let styles = p.styles().unwrap().unwrap();
|
||||
assert_eq!(styles.cell_number_format(1), Some("0.000"));
|
||||
let strings = p.shared_strings().unwrap();
|
||||
assert_eq!(strings.iter().map(|s| &*s.text).collect::<Vec<_>>(), ["zero", "one"]);
|
||||
let mut cells = vec![];
|
||||
let part = p
|
||||
.sheet(&wb.sheets[0], |row| {
|
||||
cells.extend(row.cells);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(part.merges, [Range::parse("A3:B4").unwrap()]);
|
||||
assert_eq!(part.columns.len(), 1);
|
||||
assert_eq!((part.columns[0].first, part.columns[0].last, part.columns[0].width), (1, 2, Some(20.5)));
|
||||
assert_eq!(part.root.child("pageSetup").and_then(|e| e.attr("orientation")), Some("landscape"));
|
||||
assert!(part.root.child("sheetData").is_some_and(|d| d.elements().next().is_none()));
|
||||
assert_eq!((part.rows, part.dropped, part.damaged.as_deref()), (2, 0, None));
|
||||
let values: Vec<_> = cells.iter().map(|c| c.value.resolve(&strings, true)).collect();
|
||||
assert_eq!(values, [Value::text("one"), Value::Number(1.0), Value::Number(2.0), Value::Number(2.0), Value::Empty]);
|
||||
assert_eq!(cells[1].style, 1);
|
||||
assert_eq!(cells[2].formula.as_ref().map(|f| (f.kind, f.text.as_str())), Some((FormulaKind::Normal, "B1*2")));
|
||||
assert_eq!(cells[4].value, CellValue::Shared(9));
|
||||
assert!(p.sheet(&wb.sheets[3], |_| Ok(())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_sheet_keeps_the_rows_before_the_damage() {
|
||||
let bytes = workbook(&[("S", r#"<row r="1"><c r="A1"><v>1</v></c></row><row r="2"><c r="A2"><v>2</v></c></row><!-- never closed"#)], &[]);
|
||||
let wb = read(&bytes).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.len(), 2);
|
||||
assert!(wb.warnings.iter().any(|w| w.contains("is damaged") && w.contains("kept 2 rows")), "{:?}", wb.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caller_can_stop_a_sheet() {
|
||||
let bytes = workbook(&[("S", r#"<row r="1"/><row r="2"/><row r="3"/>"#)], &[]);
|
||||
let mut p = Package::open(&bytes, &Limits::default()).unwrap();
|
||||
let wb = p.workbook().unwrap();
|
||||
let mut seen = 0;
|
||||
let stop = p.sheet(&wb.sheets[0], |_| {
|
||||
seen += 1;
|
||||
if seen == 2 { Err(Error::TooLarge("enough".into())) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(stop.err(), Some(Error::TooLarge("enough".into())));
|
||||
assert_eq!(seen, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_outside_the_grid_are_dropped_and_counted() {
|
||||
let bytes = workbook(
|
||||
&[("S", r#"<row r="1048577"><c><v>1</v></c></row><row r="2"><c r="XFE2"><v>1</v></c><c r="A0"><v>1</v></c><c r="XFD2"><v>3</v></c></row>"#)],
|
||||
&[],
|
||||
);
|
||||
let wb = read(&bytes).unwrap();
|
||||
assert_eq!(wb.sheets[0].cells.keys().copied().collect::<Vec<_>>(), [CellRef::new(1, 16383)]);
|
||||
assert!(wb.warnings.iter().any(|w| w.contains("3 cells or rows outside")), "{:?}", wb.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workbook_part_is_found_or_named() {
|
||||
// No package relationship: the usual name.
|
||||
let plain = zip(&[("xl/workbook.xml", br#"<workbook><sheets><sheet name="A" r:id="x"/></sheets></workbook>"#)]);
|
||||
assert_eq!(Package::open(&plain, &Limits::default()).unwrap().workbook().unwrap().sheets.len(), 1);
|
||||
// A relationship to a part that is not there, with the usual part present.
|
||||
let stray = zip(&[
|
||||
("_rels/.rels", br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="w" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="nowhere.xml"/></Relationships>"#),
|
||||
("xl/workbook.xml", b"<workbook/>"),
|
||||
]);
|
||||
let mut p = Package::open(&stray, &Limits::default()).unwrap();
|
||||
assert!(p.workbook().is_ok());
|
||||
assert_eq!(p.workbook_part(), "xl/workbook.xml");
|
||||
let odf = zip(&[("mimetype", b"application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", b"<x/>")]);
|
||||
assert!(Package::open(&odf, &Limits::default()).unwrap().workbook().unwrap_err().to_string().contains("OpenDocument"));
|
||||
let other = zip(&[("xl/workbook.xml", b"<document/>")]);
|
||||
assert!(matches!(Package::open(&other, &Limits::default()).unwrap().workbook(), Err(Error::Invalid(_))));
|
||||
assert!(matches!(Package::open(b"not a zip", &Limits::default()), Err(Error::Zip(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_shared_strings_part_is_a_warning() {
|
||||
let bytes = workbook(&[("S", r#"<row r="1"><c r="A1" t="s"><v>0</v></c></row>"#)], &["x"]);
|
||||
let mut entries = vec![];
|
||||
let mut archive = Package::open(&bytes, &Limits::default()).unwrap();
|
||||
for n in archive.opc().names().iter().map(|s| s.to_string()).collect::<Vec<_>>() {
|
||||
if n != "xl/sharedstrings.xml" {
|
||||
entries.push((n.clone(), archive.opc().read(&n).unwrap().unwrap()));
|
||||
}
|
||||
}
|
||||
let entries: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
|
||||
let wb = read(&zip(&entries)).unwrap();
|
||||
assert!(wb.sheets[0].cells.is_empty());
|
||||
assert!(wb.warnings.iter().any(|w| w.contains("missing part xl/sharedStrings.xml")), "{:?}", wb.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_errors() {
|
||||
let rows: String = (1..=2000).map(|r| format!(r#"<row r="{r}"><c r="A{r}"><v>{r}</v></c></row>"#)).collect();
|
||||
let bytes = workbook(&[("S", &rows)], &[]);
|
||||
let small = Limits { package: craft_opc::Limits::new(100, 4096, 1 << 20), ..Limits::default() };
|
||||
assert!(matches!(read_with(&bytes, &small), Err(Error::TooLarge(_))));
|
||||
let few = Limits { package: craft_opc::Limits::new(3, 1 << 20, 1 << 20), ..Limits::default() };
|
||||
assert!(matches!(Package::open(&bytes, &few), Err(Error::TooLarge(_))));
|
||||
// A row is held to the XML node limit on its own; a sheet of many rows is not.
|
||||
let narrow = Limits { xml: craft_xml::Limits { max_nodes: 50, ..craft_xml::Limits::DEFAULT }, ..Limits::default() };
|
||||
assert_eq!(read_with(&bytes, &narrow).unwrap().sheets[0].cells.len(), 2000);
|
||||
let wide: String = (0..40).map(|c| format!("<c><v>{c}</v></c>")).collect();
|
||||
let damaged = read_with(&workbook(&[("S", &format!("<row>{wide}</row>"))], &[]), &narrow).unwrap();
|
||||
assert!(damaged.sheets[0].cells.is_empty() && damaged.warnings.iter().any(|w| w.contains("is damaged")));
|
||||
}
|
||||
Reference in new issue
Block a user