Skip to main content

ravenrs_sheet/xlsx/
mod.rs

1//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
2//! and legacy Excel 97-2003 `.xls` (BIFF8) import, and password-encrypted workbooks (Agile and Standard encryption).
3//!
4//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
5//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
6//!
7//! Reading is lenient: unsupported or broken optional parts are skipped and reported in
8//! [`ReadReport::warnings`]; only a missing/invalid package or workbook part is an error. Hostile
9//! input is bounded: decompressed parts are capped at 512 MB (1 GB per package), cells outside
10//! Excel's grid are dropped and XML nesting is limited.
11#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
12#![forbid(unsafe_code)]
13
14mod array_formula;
15mod cfb;
16mod chart;
17mod chartex;
18mod controls;
19mod crypto;
20mod csv;
21mod drawing;
22mod feature_bags;
23mod fmla;
24mod ods;
25mod omml;
26mod package;
27mod passthrough;
28mod pivot;
29mod python_part;
30mod read;
31mod richdata;
32mod sheet_read;
33mod sheet_write;
34mod styles;
35mod tables;
36#[cfg(any(test, feature = "test-support"))]
37pub mod testing;
38mod theme;
39mod write;
40mod xls;
41mod xlsb;
42mod xml;
43
44#[cfg(test)]
45mod tests;
46
47use crate::base::DateSystem;
48use crate::model::Workbook;
49
50pub use crypto::{DecryptError, check_encryption, decrypt};
51pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited};
52pub use ods::read_ods;
53pub use package::MAX_PART;
54pub use passthrough::rebase_links;
55pub use xls::read_xls;
56pub use xlsb::read_xlsb;
57
58/// Errors from reading or writing files.
59#[derive(Debug, thiserror::Error)]
60pub enum IoError {
61    #[error("the file is not a valid zip package: {0}")]
62    Zip(String),
63    #[error("the file contains invalid XML: {0}")]
64    Xml(String),
65    #[error("the file is not a valid spreadsheet: {0}")]
66    Format(String),
67    #[error("the file is too large: {0}")]
68    TooLarge(String),
69}
70
71/// What a lenient read skipped or repaired.
72#[derive(Clone, Debug, Default, PartialEq)]
73pub struct ReadReport {
74    pub warnings: Vec<String>,
75}
76
77impl ReadReport {
78    pub(crate) fn warn(&mut self, msg: impl Into<String>) {
79        let m = msg.into();
80        if self.warnings.len() < 1000 && !self.warnings.contains(&m) {
81            self.warnings.push(m);
82        }
83    }
84}
85
86/// Reads an `.xlsx` / `.xlsm` / `.xltx` / `.xltm` package (the workbook part is found through the
87/// package relationships, so every main-part content type is accepted).
88pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
89    read::read_xlsx(bytes, None)
90}
91
92/// [`read_xlsx`], and opens formulas from Google Sheets' XLSX export as the formulas written in
93/// Google Sheets (#2871).
94///
95/// Google writes a formula that uses a function it thinks Excel lacks as
96/// `IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"),<cached value>)`; Excel shows the
97/// cached value. A cell formula holding that wrapper, whole or inside a larger formula, is read
98/// as the formula inside when it parses and `known` says GridCraft calculates every function it
99/// calls. Otherwise (a Google-only function such as `GOOGLETRANSLATE`, inner text that doesn't
100/// parse, or nesting past the parser's limit) the wrapper stays, so the cell keeps showing the
101/// cached value as in Excel. `known` is passed in because this crate doesn't depend on the
102/// function library.
103pub fn read_xlsx_with(bytes: &[u8], known: &dyn Fn(&str) -> bool) -> Result<(Workbook, ReadReport), IoError> {
104    read::read_xlsx(bytes, Some(known))
105}
106
107/// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size:
108/// what an [`IoError::TooLarge`] from reading the package is about when one part is too large.
109pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
110    package::oversized_part(bytes)
111}
112
113/// Which member of the SpreadsheetML family a package is saved as. Only the content type of the
114/// workbook part differs (ECMA-376 Part 1 Workbook part; MS-OI29500 adds the macro-enabled types).
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
116pub enum XlsxKind {
117    /// `.xlsx`
118    #[default]
119    Workbook,
120    /// `.xlsm`
121    MacroWorkbook,
122    /// `.xltx`
123    Template,
124    /// `.xltm`
125    MacroTemplate,
126}
127
128impl XlsxKind {
129    /// The kind a file name's extension asks for; anything else is a plain workbook.
130    pub fn from_path(path: &str) -> XlsxKind {
131        let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
132        match ext.as_str() {
133            "xlsm" => XlsxKind::MacroWorkbook,
134            "xltx" => XlsxKind::Template,
135            "xltm" => XlsxKind::MacroTemplate,
136            _ => XlsxKind::Workbook,
137        }
138    }
139
140    /// Content type of `/xl/workbook.xml`.
141    pub fn main_content_type(self) -> &'static str {
142        match self {
143            XlsxKind::Workbook => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
144            XlsxKind::MacroWorkbook => "application/vnd.ms-excel.sheet.macroEnabled.main+xml",
145            XlsxKind::Template => "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml",
146            XlsxKind::MacroTemplate => "application/vnd.ms-excel.template.macroEnabled.main+xml",
147        }
148    }
149
150    /// Whether this kind may carry a VBA project (`.xlsm`, `.xltm`).
151    pub fn is_macro_enabled(self) -> bool {
152        matches!(self, XlsxKind::MacroWorkbook | XlsxKind::MacroTemplate)
153    }
154}
155
156/// Writes a workbook as an `.xlsx` package, or as an `.xlsm` one when it holds a VBA project.
157pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
158    write::write_xlsx(wb, if wb.vba_project.is_some() { XlsxKind::MacroWorkbook } else { XlsxKind::Workbook })
159}
160
161/// Writes a workbook as the given member of the `.xlsx` family.
162pub fn write_xlsx_as(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
163    write::write_xlsx(wb, kind)
164}
165
166/// Writes a workbook as the given member of the `.xlsx` family for the file `book_name` (a bare
167/// file name, used for the workbook in a PivotChart's link to its PivotTable).
168pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: &str) -> Result<Vec<u8>, IoError> {
169    write::write_xlsx_named(wb, kind, Some(book_name))
170}
171
172/// CSV import options.
173#[derive(Clone, Debug, PartialEq)]
174pub struct CsvOptions {
175    /// Field separator; `0` auto-detects comma, semicolon or tab.
176    pub delimiter: u8,
177    pub quote: u8,
178    pub date_system: DateSystem,
179    /// Interpret fields like typed input (numbers, dates, booleans…); otherwise keep text.
180    pub parse_values: bool,
181}
182
183impl Default for CsvOptions {
184    fn default() -> Self {
185        CsvOptions { delimiter: b',', quote: b'"', date_system: DateSystem::D1900, parse_values: true }
186    }
187}
188
189/// A file format guessed from content.
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub enum Format {
192    Xlsx,
193    Xlsb,
194    Ods,
195    Csv,
196    /// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`.
197    Encrypted,
198    /// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream.
199    Xls,
200    /// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook.
201    LegacyBinary,
202    Unknown,
203}
204
205/// Guesses the format from content: ODS mimetype, XLSB package content types, other ZIP packages as XLSX, text as CSV.
206pub fn sniff(bytes: &[u8]) -> Format {
207    if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
208        return if ods::has_mimetype(bytes) {
209            Format::Ods
210        } else if xlsb::is_xlsb(bytes) {
211            Format::Xlsb
212        } else {
213            Format::Xlsx
214        };
215    }
216    // CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
217    // stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
218    // legacy binary (.doc…).
219    if bytes.starts_with(&cfb::SIGNATURE) {
220        return if cfb::is_encrypted(bytes) {
221            Format::Encrypted
222        } else if xls::is_xls(bytes) {
223            Format::Xls
224        } else {
225            Format::LegacyBinary
226        };
227    }
228    if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
229        return Format::Csv;
230    }
231    if bytes.is_empty() {
232        return Format::Unknown;
233    }
234    let head = bytes.get(..bytes.len().min(4096)).unwrap_or(bytes);
235    // Binary files (old .xls, images…) contain NULs and many control characters.
236    let control = head.iter().filter(|&&b| b < 0x09 || (0x0E..0x20).contains(&b) || b == 0x7F).count();
237    if head.contains(&0) || control * 20 > head.len() {
238        return Format::Unknown;
239    }
240    Format::Csv
241}