mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 05:37:00 -06:00
XLSX: keep unmodelled parts on save (#330) # Conflicts: # ROADMAP.md # crates/model/src/lib.rs # crates/model/src/sheet.rs # docs/file-format-parity.md # docs/gaps.md
157 lines
4.8 KiB
Rust
157 lines
4.8 KiB
Rust
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
|
|
//! and legacy Excel 97-2003 `.xls` (BIFF8) import.
|
|
//!
|
|
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
|
|
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
|
|
//!
|
|
//! Reading is lenient: unsupported or broken optional parts are skipped and reported in
|
|
//! [`ReadReport::warnings`]; only a missing/invalid package or workbook part is an error. Hostile
|
|
//! input is bounded: decompressed parts are capped at 512 MB (1 GB per package), cells outside
|
|
//! Excel's grid are dropped and XML nesting is limited.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
mod array_formula;
|
|
mod cfb;
|
|
mod chart;
|
|
mod chartex;
|
|
mod csv;
|
|
mod drawing;
|
|
mod feature_bags;
|
|
mod fmla;
|
|
mod ods;
|
|
mod package;
|
|
mod passthrough;
|
|
mod pivot;
|
|
mod read;
|
|
mod richdata;
|
|
mod sheet_read;
|
|
mod sheet_write;
|
|
mod styles;
|
|
mod tables;
|
|
mod theme;
|
|
mod write;
|
|
mod xls;
|
|
mod xlsb;
|
|
mod xml;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use gridcraft_core::DateSystem;
|
|
use gridcraft_model::Workbook;
|
|
|
|
pub use csv::{read_csv, write_csv};
|
|
pub use ods::read_ods;
|
|
pub use xls::read_xls;
|
|
pub use xlsb::read_xlsb;
|
|
|
|
/// Errors from reading or writing files.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum IoError {
|
|
#[error("the file is not a valid zip package: {0}")]
|
|
Zip(String),
|
|
#[error("the file contains invalid XML: {0}")]
|
|
Xml(String),
|
|
#[error("the file is not a valid spreadsheet: {0}")]
|
|
Format(String),
|
|
#[error("the file is too large: {0}")]
|
|
TooLarge(String),
|
|
}
|
|
|
|
/// What a lenient read skipped or repaired.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ReadReport {
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
impl ReadReport {
|
|
pub(crate) fn warn(&mut self, msg: impl Into<String>) {
|
|
let m = msg.into();
|
|
if self.warnings.len() < 1000 && !self.warnings.contains(&m) {
|
|
self.warnings.push(m);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads an `.xlsx` / `.xlsm` package.
|
|
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|
read::read_xlsx(bytes)
|
|
}
|
|
|
|
/// Writes a workbook as an `.xlsx` package.
|
|
pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
|
write::write_xlsx(wb)
|
|
}
|
|
|
|
/// CSV import options.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct CsvOptions {
|
|
/// Field separator; `0` auto-detects comma, semicolon or tab.
|
|
pub delimiter: u8,
|
|
pub quote: u8,
|
|
pub date_system: DateSystem,
|
|
/// Interpret fields like typed input (numbers, dates, booleans…); otherwise keep text.
|
|
pub parse_values: bool,
|
|
}
|
|
|
|
impl Default for CsvOptions {
|
|
fn default() -> Self {
|
|
CsvOptions { delimiter: b',', quote: b'"', date_system: DateSystem::D1900, parse_values: true }
|
|
}
|
|
}
|
|
|
|
/// A file format guessed from content.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Format {
|
|
Xlsx,
|
|
Xlsb,
|
|
Ods,
|
|
Csv,
|
|
/// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`.
|
|
Encrypted,
|
|
/// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream.
|
|
Xls,
|
|
/// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook.
|
|
LegacyBinary,
|
|
Unknown,
|
|
}
|
|
|
|
/// Guesses the format from content: ODS mimetype, XLSB package content types, other ZIP packages as XLSX, text as CSV.
|
|
pub fn sniff(bytes: &[u8]) -> Format {
|
|
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
|
|
return if ods::has_mimetype(bytes) {
|
|
Format::Ods
|
|
} else if xlsb::is_xlsb(bytes) {
|
|
Format::Xlsb
|
|
} else {
|
|
Format::Xlsx
|
|
};
|
|
}
|
|
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
|
|
// stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
|
|
// legacy binary (.doc…).
|
|
if bytes.starts_with(&cfb::SIGNATURE) {
|
|
return if cfb::is_encrypted(bytes) {
|
|
Format::Encrypted
|
|
} else if xls::is_xls(bytes) {
|
|
Format::Xls
|
|
} else {
|
|
Format::LegacyBinary
|
|
};
|
|
}
|
|
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
|
return Format::Csv;
|
|
}
|
|
if bytes.is_empty() {
|
|
return Format::Unknown;
|
|
}
|
|
let head = bytes.get(..bytes.len().min(4096)).unwrap_or(bytes);
|
|
// Binary files (old .xls, images…) contain NULs and many control characters.
|
|
let control = head.iter().filter(|&&b| b < 0x09 || (0x0E..0x20).contains(&b) || b == 0x7F).count();
|
|
if head.contains(&0) || control * 20 > head.len() {
|
|
return Format::Unknown;
|
|
}
|
|
Format::Csv
|
|
}
|