From 97bb39ca15bc5a2e5e00f2109cc5e75a6cb14179 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:07:19 -0500 Subject: [PATCH] Add craft-xls: Excel 97-2003 (.xls, BIFF8) workbook reader, from GridCraft (#878) * Add craft-xls: Excel 97-2003 (.xls, BIFF8) workbook reader Moved from GridCraft's gridcraft-xlsx crate (crates/xlsx/src/xls.rs and xls/ at 5cf52c5f), where it read into GridCraft's own workbook model. Here it reads into a model of the file's own terms: cells, formulas as A1 text, one CellFormat per XF, ColInfo and Row records in file units, defined names, print areas as formula text. The container is read with craft-cfb. RString cells and the fixture writer in craft_xls::testing come from WordCraft's mail-merge reader (crates/formats/src/sheet/xls.rs). * craft-xls: report the sheet the workbook was saved on Window1's itabCur, counted over every BoundSheet8 record, as Workbook::active_sheet (an index into the worksheets that were read). Whether to open on a hidden sheet is the caller's choice. Ported from storytold/gridcraft#3551. --------- Co-authored-by: Daniele Mori <3956663+Morzilla@users.noreply.github.com> --- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 1 + crates/xls/Cargo.toml | 15 + crates/xls/README.md | 74 +++ crates/xls/src/expr.rs | 420 +++++++++++++++ crates/xls/src/formula.rs | 550 ++++++++++++++++++++ crates/xls/src/ftab.rs | 376 ++++++++++++++ crates/xls/src/lib.rs | 139 +++++ crates/xls/src/model.rs | 422 +++++++++++++++ crates/xls/src/read.rs | 918 +++++++++++++++++++++++++++++++++ crates/xls/src/records.rs | 227 ++++++++ crates/xls/src/styles.rs | 472 +++++++++++++++++ crates/xls/src/testing.rs | 130 +++++ crates/xls/tests/common/mod.rs | 241 +++++++++ crates/xls/tests/read.rs | 703 +++++++++++++++++++++++++ crates/xls/tests/styles.rs | 335 ++++++++++++ 17 files changed, 5032 insertions(+) create mode 100644 crates/xls/Cargo.toml create mode 100644 crates/xls/README.md create mode 100644 crates/xls/src/expr.rs create mode 100644 crates/xls/src/formula.rs create mode 100644 crates/xls/src/ftab.rs create mode 100644 crates/xls/src/lib.rs create mode 100644 crates/xls/src/model.rs create mode 100644 crates/xls/src/read.rs create mode 100644 crates/xls/src/records.rs create mode 100644 crates/xls/src/styles.rs create mode 100644 crates/xls/src/testing.rs create mode 100644 crates/xls/tests/common/mod.rs create mode 100644 crates/xls/tests/read.rs create mode 100644 crates/xls/tests/styles.rs diff --git a/Cargo.lock b/Cargo.lock index 6d3120ed..744156d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xls" +version = "0.1.0" +dependencies = [ + "craft-cfb", + "thiserror 2.0.21", +] + [[package]] name = "craft-xml" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 601a96e6..44c7957c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } +craft-xls = { path = "crates/xls" } craft-xml = { path = "crates/xml" } craft-epub = { path = "crates/epub" } craft-video = { path = "crates/video" } diff --git a/README.md b/README.md index 6bf26f3d..cd002e32 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's 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-xml`](crates/xml/README.md): bounded XML for hostile input: an owned element tree with limits on size, depth, nodes and attributes, streaming of repeated elements, namespace handling (as written, by local name, or canonical prefixes by URI), escaping and a writer. No DTDs and no entity expansion. - [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities and compatibility boundaries across the Craft suite. diff --git a/crates/xls/Cargo.toml b/crates/xls/Cargo.toml new file mode 100644 index 00000000..f742798c --- /dev/null +++ b/crates/xls/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "craft-xls" +description = "Clean-room Excel 97-2003 workbook (.xls, BIFF8) reader: sheets, cell values, formulas as text, cell formats, layout and defined names." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-cfb = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/xls/README.md b/crates/xls/README.md new file mode 100644 index 00000000..c0b6c748 --- /dev/null +++ b/crates/xls/README.md @@ -0,0 +1,74 @@ +# craft-xls + +A clean-room reader for Excel 97-2003 workbooks (`.xls`, BIFF8). It turns the bytes of a file +into a `Workbook` that keeps the file's own terms: cells, formulas as text, the format table, +column and row records. Mapping that onto an application's document is the caller's work. +No `unsafe`; the only dependencies are `craft-cfb` (the compound file container) and `thiserror`. +No third-party implementation was consulted. + +| Document | Used for | +|---|---| +| [MS-XLS] Excel Binary File Format (.xls) Structure (Microsoft Open Specifications) | the Workbook stream: record framing and Continue records, strings, the globals and worksheet substreams, cell records, parsed formulas (`Ptg` tokens, the `Ftab` function table), XF / Font / Palette / Style, defined names | +| [MS-CFB] Compound File Binary File Format | the container, through `craft-cfb` | + +## What is read + +- Worksheets in workbook order, with their visibility. Chart sheets, Excel 4.0 macro sheets, + VBA modules and dialog sheets are skipped with a warning. +- Cell values: numbers (Number, RK, MulRk), shared and inline strings (LabelSst, Label, RString), + booleans, errors, blanks that carry a format, and the saved results of formulas. +- Formulas as A1 text without the leading `=`, with English function names: cell formulas, + shared formulas expanded per cell, array formulas (on their top left cell, with their range), + 3-D references, defined names, add-in and newer functions called by name. +- The 1904 date flag, "precision as displayed", calculation mode and iteration settings. +- The sheet the workbook was saved on (Window1). +- Cell formats: one `CellFormat` per XF record with its font, number format code, alignment, + borders, fill and protection, and palette colours resolved to RGB; the Normal font; + user-defined cell styles. +- Merged cells, ColInfo and Row records (width in 1/256 character, height in twips, hidden, + outline, default format), the default row height, the Window2 display flags. +- Defined names with their scope, print areas and print titles. + +Not read: charts, pictures, comments, conditional formats, data validation, hyperlinks, +PivotTables, macros. Each is named once in `Read::warnings`. Encrypted workbooks +(`Error::Encrypted`) and BIFF5 and older (`Error::Biff5`) are refused. There is no writer +(`craft_xls::testing` builds small fixtures for tests). + +A formula that cannot be put back into text (a link to another workbook, a macro command, an +unknown token) leaves its cell with the saved result, and `Warning::FormulaKept` says why. +Formulas are legacy (pre-dynamic-array) ones and are returned as stored: a caller whose formula +language has dynamic arrays adds Excel's implicit intersection itself. + +## Use + +```rust +let read = craft_xls::read(&bytes)?; +for sheet in &read.workbook.sheets { + for (at, cell) in &sheet.cells { + let format = cell.format.map(|i| &read.workbook.formats[usize::from(i)]); + println!("{}!{} = {:?} {:?} {:?}", sheet.name, at.a1(), cell.value, cell.formula, format.map(|f| &f.number_format)); + } +} +for w in &read.warnings { + eprintln!("{w}"); +} +``` + +`read_with` takes `Limits` (records, cells, bytes of text); a caller that only wants a table of +values can lower them. `is_xls` tells whether bytes are a compound file with a workbook stream. + +## Hostile input + +Every count from the file is capped or clamped to what the record holds: records (one allowance +for the whole stream, so sheets pointing into one another do not multiply it), cells, text bytes, +shared strings, sheets, names, XFs, fonts, styles, merged ranges, Continue records per record, +characters per string, formula token bytes, array constant size and expression depth. Shared +formulas are expanded within the text allowance that is left after every sheet's own strings, +and past it cells keep their saved results. Nothing indexes by a number from the file. + +## Origin + +Moved from GridCraft's `gridcraft-xlsx` crate (`crates/xlsx/src/xls.rs` and `xls/`, at +`5cf52c5f`), where it read straight into GridCraft's workbook model. RString cells come from +WordCraft's mail-merge reader (`crates/formats/src/sheet/xls.rs`), as does the fixture writer +in `craft_xls::testing`. diff --git a/crates/xls/src/expr.rs b/crates/xls/src/expr.rs new file mode 100644 index 00000000..704e2a57 --- /dev/null +++ b/crates/xls/src/expr.rs @@ -0,0 +1,420 @@ +//! The expression tree a parsed formula decodes to, and its A1 formula text (without `=`). + +use std::fmt::Write; +use std::sync::Arc; + +use crate::CellError; + +/// Rows and columns of the grid a sheet name is tested against when deciding whether it reads +/// as a cell reference (the SpreadsheetML grid, which is the larger one). +const GRID_ROWS: u32 = 1_048_576; +const GRID_COLS: u32 = 16_384; + +/// One end of a reference, with `$` anchors. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Anchor { + pub row: u32, + pub col: u32, + pub row_abs: bool, + pub col_abs: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RefKind { + Cell(Anchor), + Range(Anchor, Anchor), + /// Whole rows `3:5` as (row, absolute) pairs. + Rows(u32, bool, u32, bool), + /// Whole columns `A:C`. + Cols(u32, bool, u32, bool), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SheetSel { + /// Unqualified: the formula's own sheet. + Current, + Named(String), + /// 3-D span `Sheet1:Sheet3`. + Span(String, String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Reference { + pub sheet: SheetSel, + pub kind: RefKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum UnOp { + Neg, + Plus, + Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BinOp { + Add, + Sub, + Mul, + Div, + Pow, + Concat, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + /// `:` between two references. + Range, + /// `,` inside parentheses. + Union, + /// A space between two references. + Intersect, +} + +impl BinOp { + fn symbol(self) -> &'static str { + match self { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Pow => "^", + BinOp::Concat => "&", + BinOp::Eq => "=", + BinOp::Ne => "<>", + BinOp::Lt => "<", + BinOp::Gt => ">", + BinOp::Le => "<=", + BinOp::Ge => ">=", + BinOp::Range => ":", + BinOp::Union => ",", + BinOp::Intersect => " ", + } + } +} + +/// A formula's syntax tree. The decoder bounds its depth, so printing and dropping it recurse +/// safely. +#[derive(Debug, PartialEq)] +pub(crate) enum Expr { + Number(f64), + Text(Arc), + Bool(bool), + Error(CellError), + /// Array constant `{1,2;3,4}` (rows of literals). + Array(Vec>), + Ref(Reference), + /// A defined name. + Name(String), + Unary(UnOp, Box), + Binary(BinOp, Box, Box), + Call(String, Vec), + /// An omitted argument. + Missing, + Paren(Box), +} + +/// 0 → `A`, 25 → `Z`, 26 → `AA`. +fn push_col(s: &mut String, col: u32) { + let mut letters = [0u8; 8]; + let mut i = letters.len(); + let mut n = u64::from(col) + 1; + while n > 0 && i > 0 { + i -= 1; + if let Some(l) = letters.get_mut(i) { + *l = b'A' + ((n - 1) % 26) as u8; + } + n = (n - 1) / 26; + } + for &l in letters.get(i..).unwrap_or_default() { + s.push(char::from(l)); + } +} + +/// The A1 text of a cell (`B3`), for messages and tests. +pub(crate) fn a1(row: u32, col: u32) -> String { + let mut s = String::new(); + push_col(&mut s, col); + let _ = write!(s, "{}", u64::from(row) + 1); + s +} + +fn push_anchor(s: &mut String, a: &Anchor) { + if a.col_abs { + s.push('$'); + } + push_col(s, a.col); + if a.row_abs { + s.push('$'); + } + let _ = write!(s, "{}", u64::from(a.row) + 1); +} + +/// Whether `name` is an A1 cell reference inside the grid (`A1`, `xfd1048576`). +fn is_a1(name: &str) -> bool { + let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count(); + let Some((cols, digits)) = name.split_at_checked(letters) else { return false }; + if cols.is_empty() || cols.len() > 3 || digits.is_empty() || digits.len() > 7 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + let col = cols.bytes().fold(0u32, |n, b| n * 26 + u32::from(b.to_ascii_uppercase() - b'A') + 1); + let row: u32 = digits.parse().unwrap_or(0); + (1..=GRID_COLS).contains(&col) && (1..=GRID_ROWS).contains(&row) +} + +/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1` +/// in any case. +fn is_r1c1_token(name: &str) -> bool { + let mut chars = name.chars().peekable(); + let mut letters = 0; + for letter in ['r', 'c'] { + if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { + chars.next(); + letters += 1; + while chars.peek().is_some_and(char::is_ascii_digit) { + chars.next(); + } + } + } + letters > 0 && chars.next().is_none() +} + +/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or +/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell or as +/// an R1C1 reference, or is a boolean. +pub fn sheet_needs_quotes(name: &str) -> bool { + let Some(first) = name.chars().next() else { + return true; + }; + if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) { + return true; + } + is_a1(name) || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE") +} + +/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it. +pub fn quote_sheet(name: &str) -> String { + if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() } +} + +fn push_reference(s: &mut String, r: &Reference) { + match &r.sheet { + SheetSel::Current => {} + SheetSel::Named(n) => { + s.push_str("e_sheet(n)); + s.push('!'); + } + SheetSel::Span(a, b) => { + let joined = format!("{a}:{b}"); + if sheet_needs_quotes(a) || sheet_needs_quotes(b) { + let _ = write!(s, "'{}'!", joined.replace('\'', "''")); + } else { + let _ = write!(s, "{joined}!"); + } + } + } + let dollar = |abs: bool| if abs { "$" } else { "" }; + match &r.kind { + RefKind::Cell(a) => push_anchor(s, a), + RefKind::Range(a, b) => { + push_anchor(s, a); + s.push(':'); + push_anchor(s, b); + } + RefKind::Rows(r0, a0, r1, a1) => { + let _ = write!(s, "{}{}:{}{}", dollar(*a0), u64::from(*r0) + 1, dollar(*a1), u64::from(*r1) + 1); + } + RefKind::Cols(c0, a0, c1, a1) => { + s.push_str(dollar(*a0)); + push_col(s, *c0); + s.push(':'); + s.push_str(dollar(*a1)); + push_col(s, *c1); + } + } +} + +/// Removes trailing zeros (and a trailing point) from a decimal string. +fn trim_decimal(s: &str) -> String { + if s.contains('.') { + let t = s.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" { "0".into() } else { t.to_string() } + } else { + s.to_string() + } +} + +/// Scientific text like `1.23456789012346E+15` with up to `sig` significant digits. +fn sci_text(n: f64, sig: usize) -> String { + let s = format!("{:.*e}", sig.saturating_sub(1), n); + let Some((mant, exp)) = s.split_once('e') else { return s }; + let mant = trim_decimal(mant); + let e: i32 = exp.parse().unwrap_or(0); + format!("{}E{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs()) +} + +/// A number as a formula spells it: up to 15 significant digits, scientific for very large or +/// very small magnitudes. +pub(crate) fn number_to_text(n: f64) -> String { + if n == 0.0 || !n.is_finite() { + return "0".into(); + } + let a = n.abs(); + if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 { + return sci_text(n, 15); + } + // A value just below 1e15 can round up to 1e15 at 15 significant digits. + if format!("{a:.14e}").parse::().is_ok_and(|r| r >= 1e15) { + return sci_text(n, 15); + } + let mag = a.log10().floor() as i32; + let decimals = (14 - mag).clamp(0, 30) as usize; + trim_decimal(&format!("{n:.decimals$}")) +} + +fn number(n: f64) -> String { + let text = number_to_text(n); + // 15 significant digits can round past f64::MAX, which no longer parses. + if text.parse::().is_ok_and(f64::is_finite) { + return text; + } + let exact = format!("{n:E}"); + match exact.split_once('E') { + Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"), + _ => exact, + } +} + +/// Splits a sheet-qualified name at its last `!` outside brackets. +fn sheet_split(n: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut escaped = false; + let mut at = None; + for (i, c) in n.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\'' if depth > 0 => escaped = true, + '[' => depth = depth.saturating_add(1), + ']' => depth = depth.saturating_sub(1), + '!' if depth == 0 => at = Some(i), + _ => {} + } + } + at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?))) +} + +/// Prints an expression in A1 notation. +pub(crate) fn print(e: &Expr) -> String { + let mut s = String::new(); + write_expr(&mut s, e); + s +} + +fn write_args(s: &mut String, args: &[Expr], sep: char) { + for (i, a) in args.iter().enumerate() { + if i > 0 { + s.push(sep); + } + write_expr(s, a); + } +} + +fn write_expr(s: &mut String, e: &Expr) { + match e { + Expr::Number(n) => s.push_str(&number(*n)), + Expr::Text(t) => { + s.push('"'); + s.push_str(&t.replace('"', "\"\"")); + s.push('"'); + } + Expr::Bool(b) => s.push_str(if *b { "TRUE" } else { "FALSE" }), + Expr::Error(err) => s.push_str(err.as_str()), + Expr::Array(rows) => { + s.push('{'); + for (ri, row) in rows.iter().enumerate() { + if ri > 0 { + s.push(';'); + } + write_args(s, row, ','); + } + s.push('}'); + } + Expr::Ref(r) => push_reference(s, r), + Expr::Name(n) => match sheet_split(n) { + // A sheet-qualified name holds the bare sheet name (`My Sheet!Total`): quote it so it parses back. + Some((sheet, name)) => { + s.push_str("e_sheet(sheet)); + s.push('!'); + s.push_str(name); + } + None => s.push_str(n), + }, + Expr::Unary(UnOp::Neg, x) => { + s.push('-'); + write_expr(s, x); + } + Expr::Unary(UnOp::Plus, x) => { + s.push('+'); + write_expr(s, x); + } + Expr::Unary(UnOp::Percent, x) => { + write_expr(s, x); + s.push('%'); + } + Expr::Binary(op, a, b) => { + write_expr(s, a); + s.push_str(op.symbol()); + write_expr(s, b); + } + Expr::Call(name, args) => { + s.push_str(name); + s.push('('); + write_args(s, args, ','); + s.push(')'); + } + Expr::Missing => {} + Expr::Paren(x) => { + s.push('('); + write_expr(s, x); + s.push(')'); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_print_with_fifteen_significant_digits() { + assert_eq!(number_to_text(1.0), "1"); + assert_eq!(number_to_text(-2.5), "-2.5"); + assert_eq!(number_to_text(0.1 + 0.2), "0.3"); + assert_eq!(number_to_text(123456789012345.0), "123456789012345"); + assert_eq!(number_to_text(1e15), "1E+15"); + assert_eq!(number_to_text(1234567890123456.0), "1.23456789012346E+15"); + assert_eq!(number_to_text(0.0001), "0.0001"); + assert_eq!(number_to_text(1e-10), "1E-10"); + assert!(number(f64::MAX).parse::().is_ok_and(f64::is_finite)); + } + + #[test] + fn sheet_names_are_quoted_when_excel_quotes_them() { + for plain in ["Sheet1", "Data_2", "Übersicht", "a.b"] { + assert!(!sheet_needs_quotes(plain), "{plain}"); + } + for quoted in ["", "My Data", "1st", ".x", "A1", "xfd1048576", "R1C1", "rc", "true", "It's"] { + assert!(sheet_needs_quotes(quoted), "{quoted}"); + } + // Outside the grid it is no cell reference. + assert!(!sheet_needs_quotes("XFE1")); + assert!(!sheet_needs_quotes("A1048577")); + assert_eq!(quote_sheet("It's"), "'It''s'"); + assert_eq!(a1(2, 27), "AB3"); + } +} diff --git a/crates/xls/src/formula.rs b/crates/xls/src/formula.rs new file mode 100644 index 00000000..51a4a960 --- /dev/null +++ b/crates/xls/src/formula.rs @@ -0,0 +1,550 @@ +//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an +//! expression tree. Anything outside the common token set is reported as unsupported, and the +//! caller keeps the cell's cached value instead. + +use std::sync::Arc; + +use crate::expr::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp}; +use crate::ftab::FTAB; +use crate::{CellError, CellRef}; + +/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula). +const MAX_RGCE: usize = 16 * 1024; +/// Values in one array constant at most. +const MAX_ARRAY: usize = 65_536; + +/// Why a formula could not be turned back into text. +#[derive(Debug, PartialEq)] +pub(crate) struct Unsupported(pub &'static str); + +/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store +/// row/column offsets for relative parts; names store offsets for 3-D refs too. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Mode { + Cell, + Shared, + Name, +} + +/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function +/// names) are resolved; external workbooks are never followed. +#[derive(Default)] +pub(crate) struct SupBook { + pub internal: bool, + pub addin: bool, + pub names: Vec, +} + +/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets. +#[derive(Clone, Copy)] +pub(crate) struct Xti { + pub book: u16, + pub first: i16, + pub last: i16, +} + +/// Workbook-level tables formulas refer to. +#[derive(Default)] +pub(crate) struct Links { + /// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets). + pub sheets: Vec, + /// Defined names (Lbl records), in order; `None` for names that can't be referenced. + pub names: Vec, + /// Per defined name: whether the import skips it (its formula can't be decoded). + pub skipped: Vec, + pub books: Vec, + pub xti: Vec, +} + +pub(crate) fn function(iftab: u16) -> Option<(&'static str, i8)> { + FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a)) +} + +fn error_code(code: u8) -> Option { + Some(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 None, + }) +} + +pub(crate) fn cell_error(code: u8) -> CellError { + error_code(code).unwrap_or(CellError::NA) +} + +struct Bytes<'a> { + b: &'a [u8], + pos: usize, +} + +const TRUNC: Unsupported = Unsupported("truncated formula"); + +impl Bytes<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> { + let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?; + self.pos += n; + Ok(s) + } + fn u8(&mut self) -> Result { + self.take(1)?.first().copied().ok_or(TRUNC) + } + fn u16(&mut self) -> Result { + let s = self.take(2)?; + Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?])) + } + fn u32(&mut self) -> Result { + Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16)) + } + fn f64(&mut self) -> Result { + let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?; + Ok(f64::from_le_bytes(a)) + } + fn chars(&mut self, cch: usize, high: bool) -> Result { + let units: Vec = if high { + self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() + } else { + self.take(cch)?.iter().map(|&b| u16::from(b)).collect() + }; + Ok(String::from_utf16_lossy(&units)) + } +} + +/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags. +fn col_field(v: u16) -> (u16, bool, bool) { + (v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0) +} + +/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets +/// from `base`, wrapped into the BIFF8 grid (65,536 × 256). +fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor { + let (c, col_rel, row_rel) = col_field(col); + let (r, c) = if relative { + let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) }; + // A 14-bit signed offset; adding modulo 256 gives the same column either way. + let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF }; + (r, c) + } else { + (u32::from(row), u32::from(c) & 0xFF) + }; + Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel } +} + +/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref. +fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind { + let a = anchor(r0, c0, base, relative); + let b = anchor(r1, c1, base, relative); + if a.row == 0 && b.row == 0xFFFF { + return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs); + } + if a.col == 0 && b.col == 0xFF { + return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs); + } + RefKind::Range(a, b) +} + +impl Links { + fn sheet_sel(&self, ixti: u16) -> Result, Unsupported> { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal { + return Err(Unsupported("references to other workbooks (external links)")); + } + let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned(); + Ok(match (name(x.first), name(x.last)) { + (Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)), + (Some(a), Some(b)) => Some(SheetSel::Span(a, b)), + // A deleted sheet: the reference is #REF!. + _ => None, + }) + } + + fn extern_name(&self, ixti: u16, index: u32) -> Result { + let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?; + let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; + if !book.internal && !book.addin { + return Err(Unsupported("names in other workbooks (external links)")); + } + let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?; + book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name")) + } +} + +/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`). +fn function_name(name: &str) -> String { + let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name); + n.to_ascii_uppercase() +} + +/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack +/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens). +const MAX_DEPTH: usize = 256; + +/// Operand stack entries: an expression and its nesting depth. +type Stack = Vec<(Expr, usize)>; + +/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed). +fn pop(stack: &mut Stack, kids: &mut usize) -> Result { + let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; + *kids = (*kids).max(d); + Ok(e) +} + +fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result, Unsupported> { + let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; + let popped = stack.split_off(at); + *kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max); + Ok(popped.into_iter().map(|(e, _)| e).collect()) +} + +/// Pushes the result of a token one level above the operands it consumed. +fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> { + let depth = kids.saturating_add(1); + if depth > MAX_DEPTH { + return Err(Unsupported("formula nested too deeply")); + } + stack.push((e, depth)); + Ok(()) +} + +/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at +/// `base`. Returns the expression or why it can't be represented. +pub(crate) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result { + if rgce.len() > MAX_RGCE { + return Err(Unsupported("formula too long")); + } + let mut t = Bytes { b: rgce, pos: 0 }; + let mut extra = Bytes { b: rgcb, pos: 0 }; + let mut stack: Stack = Vec::new(); + while t.pos < rgce.len() { + let ptg = t.u8()?; + let mut kids = 0usize; + // Operand classes (reference / value / array) share one token number. + let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; + match id { + 0x03..=0x11 => { + let op = match id { + 0x03 => BinOp::Add, + 0x04 => BinOp::Sub, + 0x05 => BinOp::Mul, + 0x06 => BinOp::Div, + 0x07 => BinOp::Pow, + 0x08 => BinOp::Concat, + 0x09 => BinOp::Lt, + 0x0A => BinOp::Le, + 0x0B => BinOp::Eq, + 0x0C => BinOp::Ge, + 0x0D => BinOp::Gt, + 0x0E => BinOp::Ne, + 0x0F => BinOp::Intersect, + 0x10 => BinOp::Union, + _ => BinOp::Range, + }; + let b = pop(&mut stack, &mut kids)?; + let a = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?; + } + 0x12..=0x14 => { + let op = match id { + 0x12 => UnOp::Plus, + 0x13 => UnOp::Neg, + _ => UnOp::Percent, + }; + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; + } + 0x15 => { + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Paren(Box::new(x)))?; + } + 0x16 => push(&mut stack, kids, Expr::Missing)?, + 0x17 => { + let cch = usize::from(t.u8()?); + let high = t.u8()? & 1 != 0; + push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?; + } + 0x19 => { + let kind = t.u8()?; + if kind & 0x04 != 0 { + // PtgAttrChoose: jump table of cOffset + 1 entries. + let n = usize::from(t.u16()?) + 1; + t.take(n * 2)?; + } else { + t.take(2)?; + } + if kind & 0x10 != 0 { + // PtgAttrSum: SUM of the single operand. + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?; + } + // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. + } + 0x1C => { + let e = t.u8()?; + push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; + } + 0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?, + 0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?, + 0x1F => { + let n = t.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + push(&mut stack, kids, Expr::Number(n))?; + } + 0x20 => { + t.take(7)?; + push(&mut stack, kids, array_constant(&mut extra)?)?; + } + 0x21 => { + let iftab = t.u16()?; + let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; + let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; + let args = pop_n(&mut stack, n, &mut kids)?; + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; + } + 0x22 => { + let n = usize::from(t.u8()?); + let tab = t.u16()?; + if tab & 0x8000 != 0 { + return Err(Unsupported("macro sheet commands")); + } + let mut args = pop_n(&mut stack, n, &mut kids)?; + if tab & 0x7FFF == 0x00FF { + // A function called by name (add-in or newer function): the name comes first. + if args.is_empty() { + return Err(Unsupported("malformed function call")); + } + let name = match args.remove(0) { + Expr::Name(n) => function_name(&n), + _ => return Err(Unsupported("function call through an expression")), + }; + push(&mut stack, kids, Expr::Call(name, args))?; + } else { + let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; + } + } + 0x23 => { + let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1)); + let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?; + // A cell using a name the import drops would calculate to #NAME?: keep its + // saved result instead, as for references to other workbooks. + if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) { + return Err(Unsupported("defined names that were skipped")); + } + push(&mut stack, kids, Expr::Name(name.clone()))?; + } + 0x24 | 0x2C => { + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, id == 0x2C); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?; + } + 0x25 | 0x2D => { + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, id == 0x2D); + push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?; + } + 0x26 => { + // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. + t.take(6)?; + let n = usize::from(extra.u16()?); + extra.take(n.checked_mul(8).ok_or(TRUNC)?)?; + } + 0x27 | 0x28 => { + t.take(6)?; + } + 0x29 => { + t.take(2)?; + } + 0x2A => { + t.take(4)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x2B => { + t.take(8)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x39 => { + let ixti = t.u16()?; + let i = t.u32()?; + push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?; + } + 0x3A => { + let ixti = t.u16()?; + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, mode == Mode::Name); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), + None => Expr::Error(CellError::Ref), + }, + )?; + } + 0x3B => { + let ixti = t.u16()?; + let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); + let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); + push( + &mut stack, + kids, + match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }, + )?; + } + 0x3C => { + t.take(6)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x3D => { + t.take(10)?; + push(&mut stack, kids, Expr::Error(CellError::Ref))?; + } + 0x01 => return Err(Unsupported("nested shared or array formula reference")), + 0x02 => return Err(Unsupported("what-if data tables")), + 0x18 => return Err(Unsupported("natural-language formulas")), + _ => return Err(Unsupported("unknown formula token")), + } + if stack.len() > MAX_RGCE { + return Err(Unsupported("formula too complex")); + } + } + let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; + if !stack.is_empty() { + return Err(Unsupported("malformed formula (unused operands)")); + } + Ok(e) +} + +/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order. +fn array_constant(b: &mut Bytes<'_>) -> Result { + let cols = usize::from(b.u8()?) + 1; + let rows = usize::from(b.u16()?) + 1; + if cols.saturating_mul(rows) > MAX_ARRAY { + return Err(Unsupported("array constant too large")); + } + let mut out = Vec::with_capacity(rows); + for _ in 0..rows { + let mut row = Vec::with_capacity(cols); + for _ in 0..cols { + let kind = b.u8()?; + row.push(match kind { + 0x00 => { + b.take(8)?; + Expr::Missing + } + 0x01 => { + let n = b.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + Expr::Number(n) + } + 0x02 => { + let cch = usize::from(b.u16()?); + let high = b.u8()? & 1 != 0; + Expr::Text(Arc::from(b.chars(cch, high)?)) + } + 0x04 => { + let v = b.u8()?; + b.take(7)?; + Expr::Bool(v != 0) + } + 0x10 => { + let e = b.u8()?; + b.take(7)?; + Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?) + } + _ => return Err(Unsupported("unknown array constant value")), + }); + } + out.push(row); + } + Ok(Expr::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn links() -> Links { + Links { + sheets: vec!["Sheet1".into(), "My Data".into()], + names: vec!["Rate".into()], + skipped: vec![false], + books: vec![ + SupBook { internal: true, addin: false, names: vec![] }, + SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, + SupBook::default(), + ], + xti: vec![ + Xti { book: 0, first: 0, last: 1 }, + Xti { book: 1, first: -2, last: -2 }, + Xti { book: 2, first: 0, last: 0 }, + Xti { book: 0, first: -1, last: -1 }, + ], + } + } + + fn text(rgce: &[u8], rgcb: &[u8]) -> Result { + decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| crate::expr::print(&e)) + } + + #[test] + fn decodes_common_tokens() { + // {1,"a";TRUE,#N/A} via PtgArray + PtgExtraArray. + let extra = + [&[1u8, 1, 0][..], &[1], &1f64.to_le_bytes(), &[2, 1, 0, 0, b'a'], &[4, 1, 0, 0, 0, 0, 0, 0, 0], &[0x10, 0x2A, 0, 0, 0, 0, 0, 0, 0]] + .concat(); + assert_eq!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &extra).unwrap(), "{1,\"a\";TRUE,#N/A}"); + // SUM via PtgAttrSum over a 3-D span, whole column. + let area3d = [&[0x3Bu8][..], &0u16.to_le_bytes(), &[0, 0, 0xFF, 0xFF, 1, 0, 1, 0]].concat(); + assert_eq!(text(&[area3d.as_slice(), &[0x19, 0x10, 0, 0]].concat(), &[]).unwrap(), "SUM('Sheet1:My Data'!$B:$B)"); + // IFERROR (a newer function) called by name through PtgNameX, with a missing argument. + let namex = [&[0x39u8][..], &1u16.to_le_bytes(), &1u32.to_le_bytes()].concat(); + let call = [namex.as_slice(), &[0x1E, 5, 0, 0x16, 0x22, 3, 0xFF, 0]].concat(); + assert_eq!(text(&call, &[]).unwrap(), "IFERROR(5,)"); + // A deleted sheet becomes #REF!; the CHOOSE jump table is skipped. + let ref_deleted = [&[0x3Au8][..], &3u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + let choose = [&[0x1Eu8, 1, 0][..], &[0x19, 0x04, 1, 0, 0, 0, 0, 0], ref_deleted.as_slice(), &[0x1E, 2, 0, 0x22, 3, 100, 0]].concat(); + assert_eq!(text(&choose, &[]).unwrap(), "CHOOSE(1,#REF!,2)"); + // External links, data tables and unbalanced stacks are refused, never guessed. + let external = [&[0x3Au8][..], &2u16.to_le_bytes(), &[0, 0, 0, 0]].concat(); + assert!(text(&external, &[]).is_err()); + assert!(text(&[0x02, 0, 0, 0, 0], &[]).is_err()); + assert!(text(&[0x1E, 1, 0, 0x1E, 2, 0], &[]).is_err()); + assert!(text(&[0x03], &[]).is_err()); + assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err()); + assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err()); + } + + #[test] + fn deep_nesting_is_refused_not_a_stack_overflow() { + // 1 followed by thousands of PtgParen / PtgUminus tokens: a hostile file used to build a + // 16K-deep tree that overflowed the stack when printed or dropped. + for op in [0x15u8, 0x13] { + let mut rgce = vec![0x1E, 1, 0]; + rgce.resize(MAX_RGCE, op); + assert_eq!(text(&rgce, &[]), Err(Unsupported("formula nested too deeply"))); + } + // Binary chains nest too: 1+1+1+... left-associated. + let mut chain = vec![0x1E, 1, 0]; + while chain.len() + 4 <= MAX_RGCE { + chain.extend([0x1E, 1, 0, 0x03]); + } + assert!(text(&chain, &[]).is_err()); + // Nesting up to the limit still decodes. + let mut ok = vec![0x1E, 1, 0]; + ok.resize(3 + MAX_DEPTH - 1, 0x15); + assert!(text(&ok, &[]).is_ok()); + } +} diff --git a/crates/xls/src/ftab.rs b/crates/xls/src/ftab.rs new file mode 100644 index 00000000..d796419f --- /dev/null +++ b/crates/xls/src/ftab.rs @@ -0,0 +1,376 @@ +// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument +// count or -1 when the function takes a variable number of arguments). +pub(super) const FTAB: &[(u16, &str, i8)] = &[ + (0, "COUNT", -1), + (1, "IF", -1), + (2, "ISNA", 1), + (3, "ISERROR", 1), + (4, "SUM", -1), + (5, "AVERAGE", -1), + (6, "MIN", -1), + (7, "MAX", -1), + (8, "ROW", -1), + (9, "COLUMN", -1), + (10, "NA", 0), + (11, "NPV", -1), + (12, "STDEV", -1), + (13, "DOLLAR", -1), + (14, "FIXED", -1), + (15, "SIN", 1), + (16, "COS", 1), + (17, "TAN", 1), + (18, "ATAN", 1), + (19, "PI", 0), + (20, "SQRT", 1), + (21, "EXP", 1), + (22, "LN", 1), + (23, "LOG10", 1), + (24, "ABS", 1), + (25, "INT", 1), + (26, "SIGN", 1), + (27, "ROUND", 2), + (28, "LOOKUP", -1), + (29, "INDEX", -1), + (30, "REPT", 2), + (31, "MID", 3), + (32, "LEN", 1), + (33, "VALUE", 1), + (34, "TRUE", 0), + (35, "FALSE", 0), + (36, "AND", -1), + (37, "OR", -1), + (38, "NOT", 1), + (39, "MOD", 2), + (40, "DCOUNT", 3), + (41, "DSUM", 3), + (42, "DAVERAGE", 3), + (43, "DMIN", 3), + (44, "DMAX", 3), + (45, "DSTDEV", 3), + (46, "VAR", -1), + (47, "DVAR", 3), + (48, "TEXT", 2), + (49, "LINEST", -1), + (50, "TREND", -1), + (51, "LOGEST", -1), + (52, "GROWTH", -1), + (53, "GOTO", 1), + (54, "HALT", -1), + (55, "RETURN", -1), + (56, "PV", -1), + (57, "FV", -1), + (58, "NPER", -1), + (59, "PMT", -1), + (60, "RATE", -1), + (61, "MIRR", 3), + (62, "IRR", -1), + (63, "RAND", 0), + (64, "MATCH", -1), + (65, "DATE", 3), + (66, "TIME", 3), + (67, "DAY", 1), + (68, "MONTH", 1), + (69, "YEAR", 1), + (70, "WEEKDAY", -1), + (71, "HOUR", 1), + (72, "MINUTE", 1), + (73, "SECOND", 1), + (74, "NOW", 0), + (75, "AREAS", 1), + (76, "ROWS", 1), + (77, "COLUMNS", 1), + (78, "OFFSET", -1), + (79, "ABSREF", 2), + (80, "RELREF", 2), + (81, "ARGUMENT", -1), + (82, "SEARCH", -1), + (83, "TRANSPOSE", 1), + (84, "ERROR", -1), + (85, "STEP", 0), + (86, "TYPE", 1), + (87, "ECHO", -1), + (88, "SET.NAME", -1), + (89, "CALLER", 0), + (90, "DEREF", 1), + (91, "WINDOWS", -1), + (92, "SERIES", -1), + (93, "DOCUMENTS", -1), + (94, "ACTIVE.CELL", 0), + (95, "SELECTION", 0), + (96, "RESULT", -1), + (97, "ATAN2", 2), + (98, "ASIN", 1), + (99, "ACOS", 1), + (100, "CHOOSE", -1), + (101, "HLOOKUP", -1), + (102, "VLOOKUP", -1), + (103, "LINKS", -1), + (104, "INPUT", -1), + (105, "ISREF", 1), + (106, "GET.FORMULA", 1), + (107, "GET.NAME", -1), + (108, "SET.VALUE", 2), + (109, "LOG", -1), + (110, "EXEC", -1), + (111, "CHAR", 1), + (112, "LOWER", 1), + (113, "UPPER", 1), + (114, "PROPER", 1), + (115, "LEFT", -1), + (116, "RIGHT", -1), + (117, "EXACT", 2), + (118, "TRIM", 1), + (119, "REPLACE", 4), + (120, "SUBSTITUTE", -1), + (121, "CODE", 1), + (122, "NAMES", -1), + (123, "DIRECTORY", -1), + (124, "FIND", -1), + (125, "CELL", -1), + (126, "ISERR", 1), + (127, "ISTEXT", 1), + (128, "ISNUMBER", 1), + (129, "ISBLANK", 1), + (130, "T", 1), + (131, "N", 1), + (132, "FOPEN", -1), + (133, "FCLOSE", 1), + (134, "FSIZE", 1), + (135, "FREADLN", 1), + (136, "FREAD", 2), + (137, "FWRITELN", 2), + (138, "FWRITE", 2), + (139, "FPOS", -1), + (140, "DATEVALUE", 1), + (141, "TIMEVALUE", 1), + (142, "SLN", 3), + (143, "SYD", 4), + (144, "DDB", -1), + (145, "GET.DEF", -1), + (146, "REFTEXT", -1), + (147, "TEXTREF", -1), + (148, "INDIRECT", -1), + (149, "REGISTER", -1), + (150, "CALL", -1), + (151, "ADD.BAR", -1), + (152, "ADD.MENU", -1), + (153, "ADD.COMMAND", -1), + (154, "ENABLE.COMMAND", -1), + (155, "CHECK.COMMAND", -1), + (156, "RENAME.COMMAND", -1), + (157, "SHOW.BAR", -1), + (158, "DELETE.MENU", -1), + (159, "DELETE.COMMAND", -1), + (160, "GET.CHART.ITEM", -1), + (161, "DIALOG.BOX", 1), + (162, "CLEAN", 1), + (163, "MDETERM", 1), + (164, "MINVERSE", 1), + (165, "MMULT", 2), + (166, "FILES", -1), + (167, "IPMT", -1), + (168, "PPMT", -1), + (169, "COUNTA", -1), + (170, "CANCEL.KEY", -1), + (171, "FOR", -1), + (172, "WHILE", 1), + (173, "BREAK", 0), + (174, "NEXT", 0), + (175, "INITIATE", 2), + (176, "REQUEST", 2), + (177, "POKE", 3), + (178, "EXECUTE", 2), + (179, "TERMINATE", 1), + (180, "RESTART", -1), + (181, "HELP", -1), + (182, "GET.BAR", -1), + (183, "PRODUCT", -1), + (184, "FACT", 1), + (185, "GET.CELL", -1), + (186, "GET.WORKSPACE", 1), + (187, "GET.WINDOW", -1), + (188, "GET.DOCUMENT", -1), + (189, "DPRODUCT", 3), + (190, "ISNONTEXT", 1), + (191, "GET.NOTE", -1), + (192, "NOTE", -1), + (193, "STDEVP", -1), + (194, "VARP", -1), + (195, "DSTDEVP", 3), + (196, "DVARP", 3), + (197, "TRUNC", -1), + (198, "ISLOGICAL", 1), + (199, "DCOUNTA", 3), + (200, "DELETE.BAR", 1), + (201, "UNREGISTER", 1), + (204, "USDOLLAR", -1), + (205, "FINDB", -1), + (206, "SEARCHB", -1), + (207, "REPLACEB", 4), + (208, "LEFTB", -1), + (209, "RIGHTB", -1), + (210, "MIDB", 3), + (211, "LENB", 1), + (212, "ROUNDUP", 2), + (213, "ROUNDDOWN", 2), + (214, "ASC", 1), + (215, "DBCS", 1), + (216, "RANK", -1), + (219, "ADDRESS", -1), + (220, "DAYS360", -1), + (221, "TODAY", 0), + (222, "VDB", -1), + (223, "ELSE", 0), + (224, "ELSE.IF", 1), + (225, "END.IF", 0), + (226, "FOR.CELL", -1), + (227, "MEDIAN", -1), + (228, "SUMPRODUCT", -1), + (229, "SINH", 1), + (230, "COSH", 1), + (231, "TANH", 1), + (232, "ASINH", 1), + (233, "ACOSH", 1), + (234, "ATANH", 1), + (235, "DGET", 3), + (236, "CREATE.OBJECT", -1), + (237, "VOLATILE", -1), + (238, "LAST.ERROR", 0), + (239, "CUSTOM.UNDO", -1), + (240, "CUSTOM.REPEAT", -1), + (241, "FORMULA.CONVERT", -1), + (242, "GET.LINK.INFO", -1), + (243, "TEXT.BOX", -1), + (244, "INFO", 1), + (245, "GROUP", 0), + (246, "GET.OBJECT", -1), + (247, "DB", -1), + (248, "PAUSE", -1), + (251, "RESUME", -1), + (252, "FREQUENCY", 2), + (253, "ADD.TOOLBAR", -1), + (254, "DELETE.TOOLBAR", 1), + (256, "RESET.TOOLBAR", 1), + (257, "EVALUATE", 1), + (258, "GET.TOOLBAR", -1), + (259, "GET.TOOL", -1), + (260, "SPELLING.CHECK", -1), + (261, "ERROR.TYPE", 1), + (262, "APP.TITLE", -1), + (263, "WINDOW.TITLE", -1), + (264, "SAVE.TOOLBAR", -1), + (265, "ENABLE.TOOL", 3), + (266, "PRESS.TOOL", 3), + (267, "REGISTER.ID", -1), + (268, "GET.WORKBOOK", -1), + (269, "AVEDEV", -1), + (270, "BETADIST", -1), + (271, "GAMMALN", 1), + (272, "BETAINV", -1), + (273, "BINOMDIST", 4), + (274, "CHIDIST", 2), + (275, "CHIINV", 2), + (276, "COMBIN", 2), + (277, "CONFIDENCE", 3), + (278, "CRITBINOM", 3), + (279, "EVEN", 1), + (280, "EXPONDIST", 3), + (281, "FDIST", 3), + (282, "FINV", 3), + (283, "FISHER", 1), + (284, "FISHERINV", 1), + (285, "FLOOR", 2), + (286, "GAMMADIST", 4), + (287, "GAMMAINV", 3), + (288, "CEILING", 2), + (289, "HYPGEOMDIST", 4), + (290, "LOGNORMDIST", 3), + (291, "LOGINV", 3), + (292, "NEGBINOMDIST", 3), + (293, "NORMDIST", 4), + (294, "NORMSDIST", 1), + (295, "NORMINV", 3), + (296, "NORMSINV", 1), + (297, "STANDARDIZE", 3), + (298, "ODD", 1), + (299, "PERMUT", 2), + (300, "POISSON", 3), + (301, "TDIST", 3), + (302, "WEIBULL", 4), + (303, "SUMXMY2", 2), + (304, "SUMX2MY2", 2), + (305, "SUMX2PY2", 2), + (306, "CHITEST", 2), + (307, "CORREL", 2), + (308, "COVAR", 2), + (309, "FORECAST", 3), + (310, "FTEST", 2), + (311, "INTERCEPT", 2), + (312, "PEARSON", 2), + (313, "RSQ", 2), + (314, "STEYX", 2), + (315, "SLOPE", 2), + (316, "TTEST", 4), + (317, "PROB", -1), + (318, "DEVSQ", -1), + (319, "GEOMEAN", -1), + (320, "HARMEAN", -1), + (321, "SUMSQ", -1), + (322, "KURT", -1), + (323, "SKEW", -1), + (324, "ZTEST", -1), + (325, "LARGE", 2), + (326, "SMALL", 2), + (327, "QUARTILE", 2), + (328, "PERCENTILE", 2), + (329, "PERCENTRANK", -1), + (330, "MODE", -1), + (331, "TRIMMEAN", 2), + (332, "TINV", 2), + (334, "MOVIE.COMMAND", -1), + (335, "GET.MOVIE", -1), + (336, "CONCATENATE", -1), + (337, "POWER", 2), + (338, "PIVOT.ADD.DATA", -1), + (339, "GET.PIVOT.TABLE", -1), + (340, "GET.PIVOT.FIELD", -1), + (341, "GET.PIVOT.ITEM", -1), + (342, "RADIANS", 1), + (343, "DEGREES", 1), + (344, "SUBTOTAL", -1), + (345, "SUMIF", -1), + (346, "COUNTIF", 2), + (347, "COUNTBLANK", 1), + (348, "SCENARIO.GET", -1), + (349, "OPTIONS.LISTS.GET", 1), + (350, "ISPMT", 4), + (351, "DATEDIF", 3), + (352, "DATESTRING", 1), + (353, "NUMBERSTRING", 2), + (354, "ROMAN", -1), + (355, "OPEN.DIALOG", -1), + (356, "SAVE.DIALOG", -1), + (357, "VIEW.GET", -1), + (358, "GETPIVOTDATA", -1), + (359, "HYPERLINK", -1), + (360, "PHONETIC", 1), + (361, "AVERAGEA", -1), + (362, "MAXA", -1), + (363, "MINA", -1), + (364, "STDEVPA", -1), + (365, "VARPA", -1), + (366, "STDEVA", -1), + (367, "VARA", -1), + (368, "BAHTTEXT", 1), + (369, "THAIDAYOFWEEK", 1), + (370, "THAIDIGIT", 1), + (371, "THAIMONTHOFYEAR", 1), + (372, "THAINUMSOUND", 1), + (373, "THAINUMSTRING", 1), + (374, "THAISTRINGLENGTH", 1), + (375, "ISTHAIDIGIT", 1), + (376, "ROUNDBAHTDOWN", 1), + (377, "ROUNDBAHTUP", 1), + (378, "THAIYEAR", 1), + (379, "RTD", -1), +]; diff --git a/crates/xls/src/lib.rs b/crates/xls/src/lib.rs new file mode 100644 index 00000000..20d73ab4 --- /dev/null +++ b/crates/xls/src/lib.rs @@ -0,0 +1,139 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Clean-room reader for Excel 97-2003 workbooks (`.xls`, BIFF8), written from Microsoft's +//! public specifications **[MS-XLS]: Excel Binary File Format (.xls) Structure** (the Workbook +//! stream) and [MS-CFB] (the compound file around it, read by `craft-cfb`). +//! +//! [`read`] gives a [`Workbook`]: the worksheets with their cells (values, cached formula +//! results, and formulas as A1 text, including shared and array formulas), the cell formats +//! (number formats, fonts, fills, borders, alignment, protection, with palette colours +//! resolved), user-defined cell styles, merged cells, column and row records, sheet display +//! flags, calculation settings, defined names, print areas and print titles, and the 1904 date +//! flag. Widths and heights stay in the file's units. +//! +//! Everything else (charts, pictures, comments, conditional formats, validation, hyperlinks, +//! PivotTables, macros) is skipped and named in [`Read::warnings`]. Encrypted workbooks and +//! versions before BIFF8 are refused ([`Error::Encrypted`], [`Error::Biff5`]). A formula that +//! cannot be put back into text (links to other workbooks, macro commands, unknown tokens) +//! leaves its cell with the saved result and a [`Warning::FormulaKept`]. +//! +//! Input is hostile: [`Limits`] caps records, cells and text, the remaining counts have fixed +//! caps, formula nesting is bounded, and nothing indexes by a number from the file. +//! +//! ``` +//! # fn main() -> Result<(), craft_xls::Error> { +//! # let bytes = craft_xls::testing::workbook(&[("Sheet1", vec![vec!["a".into(), "2.5".into()]])]); +//! let read = craft_xls::read(&bytes)?; +//! let sheet = &read.workbook.sheets[0]; +//! assert_eq!(sheet.name, "Sheet1"); +//! assert_eq!(sheet.cells[&craft_xls::CellRef::new(0, 1)].value, craft_xls::Value::Number(2.5)); +//! # Ok(()) +//! # } +//! ``` + +mod expr; +mod formula; +mod ftab; +mod model; +mod read; +mod records; +mod styles; +#[doc(hidden)] +pub mod testing; + +pub use expr::{quote_sheet, sheet_needs_quotes}; +pub use model::{ + Alignment, BorderLine, BorderStyle, Borders, Calc, CalcMode, Cell, CellError, CellFormat, CellRef, Color, Column, DefinedName, Fill, Font, + Formula, HAlign, Pattern, Range, Row, Script, Sheet, Underline, VAlign, Value, View, Visibility, Workbook, +}; +pub use read::{is_xls, read, read_with}; +pub use styles::DEFAULT_PALETTE; + +/// Why a workbook could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Not a compound file, no Workbook stream, or a damaged stream. + #[error("not a readable Excel 97-2003 workbook: {0}")] + Invalid(String), + /// More than a [`Limits`] value or a fixed cap allows. + #[error("the workbook is too large: {0}")] + TooLarge(String), + /// The workbook is password-protected (it has a FilePass record). + #[error("the workbook is password-protected (encrypted)")] + Encrypted, + /// An Excel 5.0/95 workbook (BIFF5), which is not read. + #[error("Excel 5.0/95 workbooks (BIFF5) are not supported")] + Biff5, +} + +/// What a read skipped or repaired. Each distinct warning is reported once. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Warning { + #[error("some cell formatting records were shorter than expected; the formatting present was kept")] + ShortFormatting, + #[error("some number formats were shorter than their declared length; the characters present were kept")] + ShortNumberFormat, + #[error("chart sheets were skipped")] + ChartSheet, + #[error("Excel 4.0 macro sheets were skipped")] + MacroSheet, + #[error("VBA modules and other non-worksheet sheets were skipped")] + OtherSheet, + #[error("dialog sheets and unreadable sheets were skipped")] + DialogSheet, + #[error("some cells refer to missing shared strings and were left empty")] + MissingSharedString, + #[error("what-if data tables were not read; their values were kept")] + DataTable, + #[error("cell comments were not read")] + Comments, + #[error("pictures, charts and shapes were not read")] + Drawings, + #[error("conditional formatting was not read")] + ConditionalFormatting, + #[error("data validation was not read")] + DataValidation, + #[error("hyperlinks were not read")] + Hyperlinks, + #[error("PivotTables were not read; their values were kept")] + PivotTables, + /// Cells beyond the BIFF8 grid (65,536 rows, 256 columns) in one sheet. + #[error("{0} cells outside the grid were skipped")] + CellsOutsideGrid(usize), + /// Some formulas use the named feature; their saved results were kept as values. + #[error("some formulas use {0}; their saved results were kept as values")] + FormulaKept(&'static str), + /// Some defined names use the named feature and were skipped. + #[error("some defined names use {0} and were skipped")] + NameSkipped(&'static str), + #[error("macro names were skipped")] + MacroNames, +} + +/// A workbook and what reading it skipped. +#[derive(Debug, Clone, PartialEq)] +pub struct Read { + pub workbook: Workbook, + pub warnings: Vec, +} + +/// Caps on what one workbook may hold. 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 { + /// Records read from the whole Workbook stream (a full 65,536 × 256 sheet has fewer than the + /// default). One allowance for every sheet: many sheets pointing at one huge substream do + /// not each get their own. + pub max_records: usize, + /// Stored cells in the workbook. + pub max_cells: usize, + /// Bytes of text (strings, number formats, expanded shared formulas) in the workbook. + pub max_text: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { max_records: 40_000_000, max_cells: 4_000_000, max_text: 256 * 1024 * 1024 } + } +} diff --git a/crates/xls/src/model.rs b/crates/xls/src/model.rs new file mode 100644 index 00000000..3aedf869 --- /dev/null +++ b/crates/xls/src/model.rs @@ -0,0 +1,422 @@ +//! What a workbook reads into: the file's own tables and cells, with nothing interpreted beyond +//! the format (widths and heights keep the file's units, formulas are text). + +use std::collections::BTreeMap; +use std::sync::Arc; + +/// A cell position, zero-based. Ordered by row, then column. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CellRef { + pub row: u32, + pub col: u32, +} + +impl CellRef { + pub const fn new(row: u32, col: u32) -> Self { + CellRef { row, col } + } + + /// The A1 text (`B3`). + pub fn a1(self) -> String { + crate::expr::a1(self.row, self.col) + } +} + +/// A rectangle of cells, both corners included; `first` is the top left one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Range { + pub first: CellRef, + pub last: CellRef, +} + +impl Range { + /// The rectangle spanned by two corners, in any order. + pub fn new(a: CellRef, b: CellRef) -> Self { + Range { first: CellRef::new(a.row.min(b.row), a.col.min(b.col)), last: CellRef::new(a.row.max(b.row), a.col.max(b.col)) } + } + + pub fn contains(&self, c: CellRef) -> bool { + (self.first.row..=self.last.row).contains(&c.row) && (self.first.col..=self.last.col).contains(&c.col) + } + + pub fn is_single(&self) -> bool { + self.first == self.last + } +} + +/// The error values a BIFF8 file stores ([MS-XLS] 2.5.10, BErr). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CellError { + Null, + Div0, + Value, + Ref, + Name, + Num, + NA, + GettingData, +} + +impl CellError { + pub fn as_str(&self) -> &'static str { + match self { + CellError::Null => "#NULL!", + CellError::Div0 => "#DIV/0!", + CellError::Value => "#VALUE!", + CellError::Ref => "#REF!", + CellError::Name => "#NAME?", + CellError::Num => "#NUM!", + CellError::NA => "#N/A", + CellError::GettingData => "#GETTING_DATA", + } + } +} + +/// A cell's stored value (for a formula cell, its saved result). +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Value { + #[default] + Empty, + Number(f64), + Text(Arc), + Bool(bool), + Error(CellError), +} + +impl Value { + /// A number, or `#NUM!` when it is NaN or infinite (Excel never stores those). Negative zero + /// becomes zero. + pub fn number(v: f64) -> Value { + if v.is_finite() { Value::Number(if v == 0.0 { 0.0 } else { v }) } else { Value::Error(CellError::Num) } + } + + pub fn is_empty(&self) -> bool { + matches!(self, Value::Empty) + } +} + +/// A cell's formula. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Formula { + /// The formula in A1 notation, without the leading `=`. Function names are the English + /// ones; newer functions come without their `_xlfn.` prefix. + pub text: String, + /// The range of a Ctrl+Shift+Enter array formula, on its top left cell. + pub array: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Cell { + pub value: Value, + /// Index into [`Workbook::formats`]. `None` for a cell with plain formatting (see + /// [`Workbook::formats`]) and for a format index the file does not have. + pub format: Option, + pub formula: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Visibility { + #[default] + Visible, + Hidden, + /// Hidden, and not offered by Excel's Unhide dialog. + VeryHidden, +} + +/// A column's ColInfo record ([MS-XLS] 2.4.53). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Column { + /// Width in 1/256 of the width of the default font's `0`. + pub width: u16, + /// The width was set by the user (`fUserSet`). + pub custom_width: bool, + pub hidden: bool, + /// Outline level, 0 to 7. + pub outline: u8, + pub collapsed: bool, + /// The column's default cell format, as [`Cell::format`]. + pub format: Option, +} + +/// A row's Row record ([MS-XLS] 2.4.221). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Row { + /// Height in twips (1/20 point). + pub height: u16, + /// The height was set by the user (`fUnsynced`). + pub custom_height: bool, + pub hidden: bool, + /// Outline level, 0 to 7. + pub outline: u8, + pub collapsed: bool, + /// The row's default cell format (`fGhostDirty`), as [`Cell::format`]. + pub format: Option, +} + +/// The display flags of a sheet's Window2 record ([MS-XLS] 2.4.346). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct View { + pub show_formulas: bool, + pub show_gridlines: bool, + pub show_headings: bool, + pub show_zeros: bool, + pub right_to_left: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CalcMode { + Manual, + #[default] + Automatic, + /// Automatic except for data tables. + AutomaticExceptTables, +} + +/// Calculation settings. Every worksheet repeats them; `None` is a record the sheet lacks. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Calc { + pub mode: Option, + pub iterative: Option, + pub max_iterations: Option, + pub max_change: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub name: String, + pub visibility: Visibility, + /// The stored cells. A blank cell with plain formatting is not stored. + pub cells: BTreeMap, + /// Merged ranges of more than one cell. + pub merges: Vec, + /// Columns that have a ColInfo record, by zero-based index (0 to 255). + pub columns: BTreeMap, + /// Rows that have a Row record, by zero-based index. + pub rows: BTreeMap, + /// The default row height in twips, when the sheet sets one. + pub default_row_height: Option, + /// `None` when the sheet has no usable Window2 record. + pub view: Option, + pub calc: Calc, + /// The sheet's `Print_Area` name as formula text (`Sheet1!$A$1:$C$9`). + pub print_area: Option, + /// The sheet's `Print_Titles` name as formula text (`Sheet1!$1:$2`). + pub print_titles: Option, +} + +/// A user-defined name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DefinedName { + pub name: String, + /// Index into [`Workbook::sheets`] for a sheet-level name, `None` for a workbook-level one. + pub scope: Option, + /// What the name refers to, as formula text. + pub formula: String, + pub hidden: bool, +} + +/// A colour: one from the workbook's palette, already resolved, or the automatic one. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Color { + #[default] + Auto, + /// `0xRRGGBB`. + Rgb(u32), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Underline { + #[default] + None, + Single, + Double, + SingleAccounting, + DoubleAccounting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Script { + #[default] + Baseline, + Superscript, + Subscript, +} + +/// A Font record ([MS-XLS] 2.4.122). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Font { + /// `None` when the record has no (or a blank) name. + pub name: Option, + /// Size in points, 1 to 409; `None` when the record gives a height of zero. + pub size: Option, + pub bold: bool, + pub italic: bool, + pub strike: bool, + pub underline: Underline, + pub script: Script, + pub color: Color, + /// The font family (`bFamily`) and character set (`bCharSet`); `None` for a font the file + /// does not describe. + pub family: Option, + pub charset: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum HAlign { + #[default] + General, + Left, + Center, + Right, + Fill, + Justify, + CenterAcross, + Distributed, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum VAlign { + Top, + Center, + #[default] + Bottom, + Justify, + Distributed, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Alignment { + pub h: HAlign, + pub v: VAlign, + pub wrap: bool, + /// Degrees counter-clockwise, -90 to 90; 255 is vertically stacked text. + pub rotation: i16, + pub indent: u8, + pub shrink: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum BorderStyle { + #[default] + None, + Thin, + Medium, + Dashed, + Dotted, + Thick, + Double, + Hair, + MediumDashed, + DashDot, + MediumDashDot, + DashDotDot, + MediumDashDotDot, + SlantDashDot, +} + +/// One border side. A side without a style has the automatic colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct BorderLine { + pub style: BorderStyle, + pub color: Color, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Borders { + pub left: BorderLine, + pub right: BorderLine, + pub top: BorderLine, + pub bottom: BorderLine, + /// The diagonal from the top left corner down. + pub diag_down: BorderLine, + /// The diagonal from the bottom left corner up. + pub diag_up: BorderLine, +} + +/// Fill patterns, named as SpreadsheetML names them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Pattern { + #[default] + None, + Solid, + Gray50, + Gray75, + Gray25, + DarkHorizontal, + DarkVertical, + DarkDown, + DarkUp, + DarkGrid, + DarkTrellis, + LightHorizontal, + LightVertical, + LightDown, + LightUp, + LightGrid, + LightTrellis, + Gray125, + Gray0625, +} + +/// A cell's fill. Without a pattern both colours are automatic. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Fill { + pub pattern: Pattern, + /// The pattern's colour (the whole fill for [`Pattern::Solid`]). + pub fg: Color, + pub bg: Color, +} + +/// The formatting of one XF record ([MS-XLS] 2.4.353), with its font, number format and +/// colours resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct CellFormat { + pub font: Font, + /// The number format code; `General` when the format is General or unknown. + pub number_format: String, + pub locked: bool, + pub hidden: bool, + pub align: Alignment, + pub borders: Borders, + pub fill: Fill, +} + +impl Default for CellFormat { + fn default() -> Self { + CellFormat { + font: Font::default(), + number_format: "General".into(), + locked: true, + hidden: false, + align: Alignment::default(), + borders: Borders::default(), + fill: Fill::default(), + } + } +} + +/// A workbook read from a BIFF8 file. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Workbook { + /// Dates count from 1904-01-01 instead of 1899-12-31. + pub date_1904: bool, + pub precision_as_displayed: bool, + /// The first worksheet's calculation settings, which Excel applies to the workbook. + pub calc: Calc, + /// The worksheets, in workbook order. Chart, macro and dialog sheets are left out. + pub sheets: Vec, + /// The sheet the workbook was saved on, as an index into `sheets`: `None` when the file has + /// no Window1 record or names a sheet that was not read. It may be a hidden sheet; Excel + /// then opens on the first visible one. + pub active_sheet: Option, + pub names: Vec, + /// The font of the Normal style: what an unformatted cell shows. `None` when the file has + /// no font at all. + pub normal_font: Option, + /// One entry per XF record, in file order. A format is *plain* when it is the Normal font + /// with everything else at [`CellFormat::default`]; cells, rows and columns with a plain + /// format carry `None` instead of its index. + pub formats: Vec, + /// User-defined cell styles (name, formatting). Built-in styles are left out. + pub cell_styles: Vec<(String, CellFormat)>, +} diff --git a/crates/xls/src/read.rs b/crates/xls/src/read.rs new file mode 100644 index 00000000..40d0665b --- /dev/null +++ b/crates/xls/src/read.rs @@ -0,0 +1,918 @@ +//! The Workbook stream ([MS-XLS] 2.1.7): the globals substream, then one substream per sheet. +//! +//! Every formula in the file is a legacy (pre-dynamic-array) one. A caller whose formula +//! language has dynamic arrays reads them with Excel's implicit intersection; nothing here +//! adds an `@`. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; +use crate::model::{CalcMode, Cell, CellRef, Column, DefinedName, Formula, Range, Row, Sheet, Value, View, Visibility, Workbook}; +use crate::records::{Cursor, Record, Records}; +use crate::{Error, Limits, Read, Warning, styles}; + +const MAX_STRINGS: usize = 4_000_000; +const MAX_SHEETS: usize = 4096; +const MAX_NAMES: usize = 65_536; +const MAX_XFS: usize = 65_536; +const MAX_MERGES: usize = 100_000; +/// The Workbook stream is read whole; larger ones are refused. +const MAX_STREAM: u64 = 1 << 30; +/// Distinct warnings kept. +const MAX_WARNINGS: usize = 1000; +/// The BIFF8 grid. +const GRID_ROWS: u32 = 65_536; +const GRID_COLS: u32 = 256; + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +const BIFF8: u16 = 0x0600; +/// WsBool (MS-XLS 2.4.351): its first byte's `fDialog` bit marks a dialog sheet, whose BOF says worksheet. +const WSBOOL: u16 = 0x0081; +const WSBOOL_DIALOG: u8 = 0x10; + +fn invalid(msg: impl Into) -> Error { + Error::Invalid(msg.into()) +} + +#[derive(Default)] +struct Report { + warnings: Vec, +} + +impl Report { + fn warn(&mut self, w: Warning) { + if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) { + self.warnings.push(w); + } + } +} + +/// The root-level stream called `name` (compared as compound files compare names). +fn root_stream(cf: &craft_cfb::CompoundFile<'_>, name: &str) -> Option { + cf.child(0, name).filter(|&id| cf.entry(id).is_some_and(|e| e.kind == craft_cfb::EntryKind::Stream)) +} + +/// The Workbook stream of a compound file, or why there is none. +fn workbook_stream(bytes: &[u8]) -> Result, Error> { + let cf = craft_cfb::CompoundFile::open(bytes).map_err(|e| invalid(e.to_string()))?; + let Some(id) = root_stream(&cf, "Workbook") else { + return Err(if root_stream(&cf, "Book").is_some() { + Error::Biff5 + } else { + invalid("the file has no Workbook stream, so it is not an Excel workbook") + }); + }; + cf.read_capped(id, MAX_STREAM).map_err(|e| match e { + craft_cfb::Error::LimitExceeded(_) => Error::TooLarge("the Workbook stream is larger than 1 GiB".into()), + e => invalid(e.to_string()), + }) +} + +/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook` +/// or BIFF5 `Book`). The stream itself is not read. +pub fn is_xls(bytes: &[u8]) -> bool { + craft_cfb::CompoundFile::open(bytes).is_ok_and(|cf| root_stream(&cf, "Workbook").or_else(|| root_stream(&cf, "Book")).is_some()) +} + +/// Most shared formulas one sheet expands, and the longest template text one attach may +/// produce: a template shared by its followers was costing every one of them a full decode and +/// print however often that could never succeed, so each anchor is tried once and failed ones +/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their +/// cached values. +const MAX_SHARED_ATTACHES: usize = 262_144; +const MAX_SHARED_TEMPLATE: usize = 8192; + +struct Budget { + limits: Limits, + cells: usize, + text: usize, + records: usize, + /// A shared formula's text was refused: no later shared formula, in any sheet, is expanded. + shared_text_spent: bool, +} + +impl Budget { + fn new(limits: Limits) -> Self { + Budget { limits, cells: 0, text: 0, records: 0, shared_text_spent: false } + } + fn record(&mut self) -> Result<(), Error> { + self.records = self.records.saturating_add(1); + if self.records > self.limits.max_records { + return Err(Error::TooLarge("too many records".into())); + } + Ok(()) + } + fn cell(&mut self) -> Result<(), Error> { + self.cells = self.cells.saturating_add(1); + if self.cells > self.limits.max_cells { + return Err(Error::TooLarge(format!("more than {} cells", self.limits.max_cells))); + } + Ok(()) + } + /// Charges `len` bytes of text if they fit the allowance, leaving it untouched otherwise: + /// for text the read can do without (expanded shared formulas keep their cached values). + fn try_text(&mut self, len: usize) -> bool { + let total = self.text.saturating_add(len); + let fits = total <= self.limits.max_text; + if fits { + self.text = total; + } + fits + } + /// [`try_text`](Self::try_text) for a shared formula's text. The first refusal ends shared + /// expansion for the whole workbook, so a shorter formula on a later sheet doesn't fit in the + /// room the refused one left, and refused formulas aren't decoded again on each sheet. + fn try_shared_text(&mut self, len: usize) -> bool { + self.shared_text_spent = self.shared_text_spent || !self.try_text(len); + !self.shared_text_spent + } + fn text(&mut self, len: usize) -> Result<(), Error> { + self.text = self.text.saturating_add(len); + if self.text > self.limits.max_text { + return Err(Error::TooLarge(format!("more than {} bytes of text", self.limits.max_text))); + } + Ok(()) + } +} + +struct BoundSheet { + pos: usize, + name: String, + visibility: Visibility, + kind: u8, +} + +struct Lbl { + name: String, + builtin: Option, + hidden: bool, + macro_name: bool, + itab: u16, + rgce: Vec, + rgcb: Vec, +} + +#[derive(Default)] +struct Globals { + sheets: Vec, + date1904: bool, + formats: HashMap, + styles: styles::StyleRecords, + sst: Vec>, + links: Links, + names: Vec, + precision_as_displayed: bool, + /// The sheet the workbook was saved on (`itabCur` of the first Window1), counted over every + /// BoundSheet8 record. + active_tab: Option, +} + +/// Reads an Excel 97-2003 workbook with the default [`Limits`]. +pub fn read(bytes: &[u8]) -> Result { + read_with(bytes, Limits::default()) +} + +/// [`read`] with the caller's [`Limits`]. +pub fn read_with(bytes: &[u8], limits: Limits) -> Result { + let stream = workbook_stream(bytes)?; + let mut budget = Budget::new(limits); + let mut report = Report::default(); + let g = globals(&stream, &mut budget, &mut report)?; + + let mut wb = Workbook { date_1904: g.date1904, precision_as_displayed: g.precision_as_displayed, ..Workbook::default() }; + // Per XF: whether its format is plain. + let plain = styles::build(&g.styles, &g.formats, &mut wb); + if g.styles.short { + report.warn(Warning::ShortFormatting); + } + + // BoundSheet8 index → index in `wb.sheets`. + let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); + let mut parsed = Vec::new(); + for bs in &g.sheets { + if bs.kind != 0 { + report.warn(match bs.kind { + 2 => Warning::ChartSheet, + 1 => Warning::MacroSheet, + _ => Warning::OtherSheet, + }); + model_index.push(None); + continue; + } + let Some(st) = worksheet(&stream, bs, &g, &plain, &mut budget, &mut report)? else { + report.warn(Warning::DialogSheet); + model_index.push(None); + continue; + }; + model_index.push(Some(parsed.len())); + parsed.push((bs.visibility, st)); + } + // Every sheet's mandatory text is charged by now; shared formulas take what is left. + for (visibility, st) in parsed { + let mut sheet = finish_sheet(st, &mut budget, &mut report); + sheet.visibility = visibility; + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = sheet.calc; + } + wb.sheets.push(sheet); + } + if wb.sheets.is_empty() { + return Err(invalid("the workbook has no worksheets")); + } + wb.active_sheet = g.active_tab.and_then(|t| model_index.get(usize::from(t)).copied().flatten()); + defined_names(&g, &model_index, &mut wb, &mut report); + Ok(Read { workbook: wb, warnings: report.warnings }) +} + +fn bof(rec: &Record<'_>) -> Result<(u16, u16), Error> { + if rec.id != BOF { + return Err(invalid("a substream does not start with a BOF record")); + } + let mut c = Cursor::new(&rec.parts); + Ok((c.u16()?, c.u16()?)) +} + +#[allow(clippy::cognitive_complexity, reason = "one arm per globals record of [MS-XLS] 2.1.7.20.3")] +fn globals(stream: &[u8], budget: &mut Budget, report: &mut Report) -> Result { + let mut recs = Records::new(stream, 0); + let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; + let (vers, dt) = bof(&first)?; + if vers != BIFF8 { + return Err(if vers == 0x0500 { Error::Biff5 } else { invalid("unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)") }); + } + if dt != 0x0005 { + return Err(invalid("the Workbook stream does not start with the workbook globals")); + } + let mut g = Globals::default(); + let mut offsets = HashSet::new(); + while let Some(rec) = recs.next_record()? { + budget.record()?; + let mut c = Cursor::new(&rec.parts); + match rec.id { + EOF => break, + 0x002F => return Err(Error::Encrypted), + 0x0085 => { + if g.sheets.len() >= MAX_SHEETS { + return Err(Error::TooLarge("too many sheets".into())); + } + let pos = c.u32()? as usize; + if !offsets.insert(pos) { + return Err(invalid("two sheets point at the same substream")); + } + let state = c.u8()?; + let kind = c.u8()?; + let name = c.short_string()?; + let visibility = match state & 3 { + 0 => Visibility::Visible, + 1 => Visibility::Hidden, + _ => Visibility::VeryHidden, + }; + g.links.sheets.push(name.clone()); + g.sheets.push(BoundSheet { pos, name, visibility, kind }); + } + 0x0022 => g.date1904 = c.u16()? == 1, + 0x000E => g.precision_as_displayed = c.u16()? == 0, + 0x041E => { + let ifmt = c.u16()?; + let cch = usize::from(c.u16()?); + let high = c.u8()? & 1 != 0; + // Some writers count one character more than the record holds; Excel opens + // those files, so keep the characters that are there. + let fit = c.remaining() / if high { 2 } else { 1 }; + if cch > fit { + report.warn(Warning::ShortNumberFormat); + } + let code = c.chars(cch.min(fit), high)?; + budget.text(code.len())?; + g.formats.insert(ifmt, code); + } + // Window1: position, size and flags (five u16), then the active sheet. Only the first + // window counts; a record too short for it is ignored. + 0x003D if g.active_tab.is_none() && c.remaining() >= 12 => { + c.skip(10)?; + g.active_tab = Some(c.u16()?); + } + 0x00E0 => g.styles.xf(&mut c, MAX_XFS), + 0x0031 => g.styles.font(&mut c), + 0x0092 => g.styles.palette(&mut c), + 0x0293 => g.styles.style(&mut c), + 0x00FC => g.sst = shared_strings(&mut c, budget)?, + 0x01AE => { + let _ctab = c.u16()?; + let cch = c.u16()?; + g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] }); + } + 0x0023 => { + // ExternName: flags, then an AddinUdf or ExternDocName body; both start with + // four bytes before the name. + c.u16()?; + c.skip(4)?; + let name = c.short_string()?; + if let Some(book) = g.links.books.last_mut() + && book.names.len() < MAX_NAMES + { + book.names.push(name); + } + } + 0x0017 => { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 6) { + let book = c.u16()?; + let first = c.u16()? as i16; + let last = c.u16()? as i16; + g.links.xti.push(Xti { book, first, last }); + } + } + 0x0018 => { + if g.names.len() >= MAX_NAMES { + continue; + } + let lbl = label(&mut c)?; + g.links.names.push(lbl.name.clone()); + g.names.push(lbl); + } + _ => {} + } + } + // The same test `defined_names` applies, before any cell formula is decoded. + let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect(); + g.links.skipped = skipped; + Ok(g) +} + +/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name). +const BUILTIN_NAMES: [&str; 14] = [ + "Consolidate_Area", + "Auto_Open", + "Auto_Close", + "Extract", + "Database", + "Criteria", + "Print_Area", + "Print_Titles", + "Recorder", + "Data_Form", + "Auto_Activate", + "Auto_Deactivate", + "Sheet_Title", + "_FilterDatabase", +]; + +fn label(c: &mut Cursor<'_, '_>) -> Result { + let flags = c.u16()?; + let _key = c.u8()?; + let cch = usize::from(c.u8()?); + let cce = usize::from(c.u16()?); + c.skip(2)?; + let itab = c.u16()?; + c.skip(4)?; + let high = c.u8()? & 1 != 0; + let raw = c.chars(cch, high)?; + let builtin = flags & 0x0020 != 0; + let (name, builtin) = if builtin { + let code = raw.chars().next().map_or(0, |ch| ch as u32); + let code = u8::try_from(code).unwrap_or(u8::MAX); + (BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code)) + } else { + (raw, None) + }; + let rest = c.rest(); + let rgce = rest.get(..cce).unwrap_or(&rest).to_vec(); + let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec(); + Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb }) +} + +fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result>, Error> { + let _total = c.u32()?; + let unique = c.u32()? as usize; + if unique > MAX_STRINGS { + return Err(Error::TooLarge("too many shared strings".into())); + } + // Each string takes at least three bytes: the declared count can't exceed what's there. + let mut out = Vec::with_capacity(unique.min(c.remaining() / 3)); + for _ in 0..unique { + if c.remaining() == 0 { + break; + } + let s = c.rich_string()?; + budget.text(s.len())?; + out.push(Arc::from(s)); + } + Ok(out) +} + +/// A formula cell waiting for its shared or array formula (which follows it in the file). +struct Pending { + pos: CellRef, + anchor: CellRef, +} + +/// A parsed formula: tokens and extra data. +struct Parsed { + rgce: Vec, + rgcb: Vec, +} + +fn parsed(c: &mut Cursor<'_, '_>) -> Result { + let cce = usize::from(c.u16()?); + let rest = c.rest(); + if cce > rest.len() { + return Err(invalid("formula runs past the end of its record")); + } + Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() }) +} + +struct SheetState<'g> { + g: &'g Globals, + plain: &'g [bool], + sheet: Sheet, + pending: Vec, + shared: HashMap, + arrays: HashMap, + last_formula: Option, + /// The cell waiting for its String record, with its format: a blank cell isn't stored, so a + /// formula kept as its value may have no cell to receive the text. + pending_string: Option<(CellRef, Option)>, + dropped: usize, + unsupported: Vec<&'static str>, +} + +impl SheetState<'_> { + /// Records why a formula was kept as its value (each reason once). + fn unsupported(&mut self, why: &'static str) { + if !self.unsupported.contains(&why) { + self.unsupported.push(why); + } + } + + /// The format index a cell, row or column keeps: none for a plain or missing XF. + fn format(&self, ixfe: u16) -> Option { + (self.plain.get(usize::from(ixfe)) == Some(&false)).then_some(ixfe) + } + + fn pos(&mut self, row: u16, col: u16) -> Option { + let (r, c) = (u32::from(row), u32::from(col)); + if r >= GRID_ROWS || c >= GRID_COLS { + self.dropped += 1; + return None; + } + Some(CellRef::new(r, c)) + } + + fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result, Error> { + let Some(pos) = self.pos(row, col) else { return Ok(None) }; + let format = self.format(ixfe); + if value.is_empty() && format.is_none() { + return Ok(Some(pos)); + } + budget.cell()?; + self.sheet.cells.insert(pos, Cell { value, format, formula: None }); + Ok(Some(pos)) + } + + /// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be. + fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option) { + match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) { + // A one-cell Ctrl+Shift+Enter array keeps its range: it evaluates as an array. + Ok(e) => self.sheet.cells.entry(pos).or_default().formula = Some(Formula { text: crate::expr::print(&e), array }), + Err(Unsupported(why)) => self.unsupported(why), + } + } + + fn formula_at(&self, pos: CellRef) -> Option<&Formula> { + self.sheet.cells.get(&pos).and_then(|c| c.formula.as_ref()) + } +} + +/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100. +fn rk(v: u32) -> Value { + let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) }; + Value::number(if v & 1 != 0 { n / 100.0 } else { n }) +} + +/// Attaches every pending formula to its anchor's template. An anchor whose template can't be +/// read is refused once: its followers re-decode the same template for nothing, so they are +/// skipped instead (they keep their cached values). +fn resolve_pending( + st: &mut SheetState<'_>, + budget: &mut Budget, + arrays: &HashMap, + shared: &HashMap, + pending: &[Pending], +) { + const TOO_MUCH: &str = "more shared-formula text than the read allows"; + let mut failed: HashSet = HashSet::new(); + let mut shared_attaches = 0usize; + for p in pending { + if let Some((range, f)) = arrays.get(&p.anchor) { + if p.pos == p.anchor { + st.attach(p.pos, f, Mode::Cell, Some(*range)); + } + } else if let Some(f) = shared.get(&p.anchor) { + if budget.shared_text_spent { + st.unsupported(TOO_MUCH); + continue; + } + if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES { + continue; + } + shared_attaches += 1; + st.attach(p.pos, f, Mode::Shared, None); + // Every follower keeps its own text: charge it to the workbook's text allowance, and + // past it drop the formula again so the cell keeps its cached value. + if let Some(len) = st.formula_at(p.pos).map(|f| f.text.len()) + && !budget.try_shared_text(len) + { + if let Some(cell) = st.sheet.cells.get_mut(&p.pos) { + cell.formula = None; + } + st.unsupported(TOO_MUCH); + continue; + } + if !st.formula_at(p.pos).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE) { + failed.insert(p.anchor); + } + } else { + st.unsupported("a shared formula that is missing from the file"); + } + } + if shared_attaches >= MAX_SHARED_ATTACHES { + st.unsupported("more shared formulas than the expansion cap; some formulas kept their values"); + } +} + +/// The other cells of a legacy array formula hold only cached results: blanked, so that the +/// formula fills its range on recalculation. If the formula itself couldn't be read, they keep +/// their values. +fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) { + for p in pending { + let filled = st.formula_at(p.anchor).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); + if p.pos == p.anchor || !filled { + continue; + } + match st.sheet.cells.get_mut(&p.pos) { + Some(cell) if cell.format.is_some() => *cell = Cell { value: Value::Empty, format: cell.format, formula: None }, + _ => { + st.sheet.cells.remove(&p.pos); + } + } + } +} + +fn worksheet<'g>( + stream: &[u8], + bs: &BoundSheet, + g: &'g Globals, + plain: &'g [bool], + budget: &mut Budget, + report: &mut Report, +) -> Result>, Error> { + if bs.pos >= stream.len() { + return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); + } + let mut recs = Records::new(stream, bs.pos); + let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?; + let (vers, dt) = bof(&first)?; + if vers != BIFF8 || dt != 0x0010 { + return Ok(None); + } + let mut st = SheetState { + g, + plain, + sheet: Sheet { name: bs.name.clone(), ..Sheet::default() }, + pending: vec![], + shared: HashMap::new(), + arrays: HashMap::new(), + last_formula: None, + pending_string: None, + dropped: 0, + unsupported: vec![], + }; + let mut depth = 1usize; + while let Some(rec) = recs.next_record()? { + budget.record()?; + match rec.id { + BOF => { + // An embedded chart's substream. + depth += 1; + continue; + } + EOF => { + depth -= 1; + if depth == 0 { + break; + } + continue; + } + _ if depth > 1 => continue, + WSBOOL if rec.parts.first().and_then(|p| p.first()).is_some_and(|b| b & WSBOOL_DIALOG != 0) => return Ok(None), + _ => {} + } + sheet_record(&rec, &mut st, budget, report)?; + } + Ok(Some(st)) +} + +/// Attaches a sheet's shared and array formulas (their definitions follow the first cell that +/// uses them). Run for every sheet only after all sheets' mandatory text is charged, so the +/// optional shared-formula text can't use up the allowance a later sheet's strings need. +fn finish_sheet(mut st: SheetState<'_>, budget: &mut Budget, report: &mut Report) -> Sheet { + let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); + let pending = std::mem::take(&mut st.pending); + resolve_pending(&mut st, budget, &arrays, &shared, &pending); + blank_array_followers(&mut st, &pending); + if st.dropped > 0 { + report.warn(Warning::CellsOutsideGrid(st.dropped)); + } + for why in &st.unsupported { + report.warn(Warning::FormulaKept(why)); + } + st.sheet +} + +/// The shortest WINDOW2 a file holds (the chart-sheet form); a shorter one is truncated. +const WINDOW2_MIN_LEN: usize = 10; + +#[allow(clippy::cognitive_complexity, reason = "one arm per worksheet record of [MS-XLS] 2.1.7.20.5")] +fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut Report) -> Result<(), Error> { + let mut c = Cursor::new(&rec.parts); + // A formula's string result is the String record right after it (shared/array records may sit between). + if !matches!(rec.id, 0x0207 | 0x04BC | 0x0221 | 0x0236) { + st.pending_string = None; + } + match rec.id { + 0x0203 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = Value::number(c.f64()?); + st.put(budget, r, col, x, v)?; + } + 0x027E => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let v = rk(c.u32()?); + st.put(budget, r, col, x, v)?; + } + 0x00BD => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 6; + for i in 0..n { + let x = c.u16()?; + let v = rk(c.u32()?); + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, v)?; + } + } + 0x00FD => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let i = c.u32()? as usize; + match st.g.sst.get(i) { + Some(s) => { + st.put(budget, r, col, x, Value::Text(s.clone()))?; + } + None => report.warn(Warning::MissingSharedString), + } + } + // Label, and RString (a label followed by formatting runs, which are skipped). + 0x0204 | 0x00D6 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let s = c.string()?; + budget.text(s.len())?; + st.put(budget, r, col, x, Value::Text(Arc::from(s)))?; + } + 0x0205 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let (v, is_err) = (c.u8()?, c.u8()?); + let v = if is_err != 0 { Value::Error(cell_error(v)) } else { Value::Bool(v != 0) }; + st.put(budget, r, col, x, v)?; + } + 0x0201 => { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + st.put(budget, r, col, x, Value::Empty)?; + } + 0x00BE => { + let r = c.u16()?; + let first = c.u16()?; + let n = c.remaining().saturating_sub(2) / 2; + for i in 0..n { + let x = c.u16()?; + let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX); + st.put(budget, r, col, x, Value::Empty)?; + } + } + 0x0006 => formula_cell(&mut c, st, budget)?, + 0x0207 => { + if let Some((pos, format)) = st.pending_string.take() { + let s = c.string()?; + budget.text(s.len())?; + let cell = st.sheet.cells.entry(pos).or_insert_with(|| Cell { value: Value::Empty, format, formula: None }); + cell.value = Value::Text(Arc::from(s)); + } + } + 0x04BC => { + // RefU (the cells that may use it), reserved, cUse. + c.skip(8)?; + let f = parsed(&mut c)?; + if let Some(at) = st.last_formula { + st.shared.insert(at, f); + } + } + 0x0221 => { + let (r0, r1) = (c.u16()?, c.u16()?); + let (c0, c1) = (c.u8()?, c.u8()?); + c.skip(6)?; + let f = parsed(&mut c)?; + let range = Range::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1.max(r0)), u32::from(c1.max(c0)))); + if let Some(at) = st.last_formula { + st.arrays.insert(at, (range, f)); + } + } + 0x000D => { + st.sheet.calc.mode = Some(match c.u16()? as i16 { + 0 => CalcMode::Manual, + -1 => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, + }); + } + 0x000C => st.sheet.calc.max_iterations = Some(c.u16()?), + 0x0011 => st.sheet.calc.iterative = Some(c.u16()? == 1), + 0x0010 => st.sheet.calc.max_change = Some(c.f64()?), + 0x0236 => report.warn(Warning::DataTable), + 0x00E5 => merges(&mut c, st)?, + 0x007D => { + let (first, last) = (c.u16()?, c.u16()?); + let width = c.u16()?; + let x = c.u16()?; + let flags = c.u16()?; + let column = Column { + width, + custom_width: flags & 0x0002 != 0, + hidden: flags & 0x0001 != 0, + outline: ((flags >> 8) & 7) as u8, + collapsed: flags & 0x1000 != 0, + format: st.format(x), + }; + // An empty range when `first` is past `last`. + for col in first..=last.min(255) { + st.sheet.columns.insert(col, column); + } + } + 0x0208 => { + let r = c.u16()?; + c.skip(4)?; + let height = c.u16()?; + c.skip(4)?; + let flags = c.u32()?; + let format = if flags & 0x80 != 0 { st.format(((flags >> 16) & 0x0FFF) as u16) } else { None }; + let row = Row { + height: height & 0x7FFF, + custom_height: flags & 0x40 != 0, + hidden: flags & 0x20 != 0, + outline: (flags & 7) as u8, + collapsed: flags & 0x10 != 0, + format, + }; + st.sheet.rows.insert(u32::from(r), row); + } + 0x0225 => { + let flags = c.u16()?; + let height = c.u16()?; + if flags & 0x0002 == 0 && height > 0 { + st.sheet.default_row_height = Some(height); + } + } + 0x023E => { + let flags = if c.remaining() >= WINDOW2_MIN_LEN { c.u16().ok() } else { None }; + let Some(flags) = flags else { return Ok(()) }; + let on = |bit: u16| flags & bit != 0; + st.sheet.view = Some(View { + show_formulas: on(0x0001), + show_gridlines: on(0x0002), + show_headings: on(0x0004), + show_zeros: on(0x0010), + right_to_left: on(0x0040), + }); + } + 0x001C => report.warn(Warning::Comments), + 0x00EC => report.warn(Warning::Drawings), + 0x01B0 | 0x0879 => report.warn(Warning::ConditionalFormatting), + 0x01B2 => report.warn(Warning::DataValidation), + 0x01B8 => report.warn(Warning::Hyperlinks), + 0x00B0 => report.warn(Warning::PivotTables), + _ => {} + } + Ok(()) +} + +/// MergeCells ([MS-XLS] 2.4.168). +fn merges(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>) -> Result<(), Error> { + let n = usize::from(c.u16()?); + for _ in 0..n.min(c.remaining() / 8) { + let (r0, r1, c0, c1) = (c.u16()?, c.u16()?, c.u16()?, c.u16()?); + if r1 < r0 || c1 < c0 || u32::from(c1) >= GRID_COLS { + continue; + } + if st.sheet.merges.len() >= MAX_MERGES { + return Err(Error::TooLarge("too many merged ranges".into())); + } + let range = Range::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1), u32::from(c1))); + if !range.is_single() { + st.sheet.merges.push(range); + } + } + Ok(()) +} + +fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), Error> { + let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?); + let mut val = [0u8; 8]; + for b in val.iter_mut() { + *b = c.u8()?; + } + let _flags = c.u16()?; + c.skip(4)?; + let f = parsed(c)?; + let [b0, _, b2, _, _, _, e0, e1] = val; + let mut string_result = false; + let value = if [e0, e1] == [0xFF, 0xFF] { + match b0 { + 0x00 => { + string_result = true; + Value::Empty + } + 0x01 => Value::Bool(b2 != 0), + 0x02 => Value::Error(cell_error(b2)), + 0x03 => Value::Text(Arc::from("")), + _ => Value::Empty, + } + } else { + Value::number(f64::from_le_bytes(val)) + }; + let Some(pos) = st.pos(r, col) else { return Ok(()) }; + budget.cell()?; + let format = st.format(x); + st.sheet.cells.insert(pos, Cell { value, format, formula: None }); + st.last_formula = Some(pos); + if string_result { + st.pending_string = Some((pos, format)); + } + // PtgExp: part of a shared or array formula anchored at another cell. + if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { + let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0); + let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)]))); + st.pending.push(Pending { pos, anchor }); + return Ok(()); + } + st.attach(pos, &f, Mode::Cell, None); + Ok(()) +} + +fn defined_names(g: &Globals, model_index: &[Option], wb: &mut Workbook, report: &mut Report) { + let scope_of = |itab: u16| -> Option> { + if itab == 0 { + return Some(None); + } + model_index.get(usize::from(itab) - 1).copied().flatten().map(Some) + }; + for lbl in &g.names { + let Some(scope) = scope_of(lbl.itab) else { continue }; + let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) { + Ok(e) => crate::expr::print(&e), + Err(_) if lbl.rgce.is_empty() => continue, + Err(Unsupported(why)) => { + report.warn(Warning::NameSkipped(why)); + continue; + } + }; + match lbl.builtin { + Some(6 | 7) => { + let Some(sheet) = scope.and_then(|si| wb.sheets.get_mut(si)) else { continue }; + if lbl.builtin == Some(6) { + sheet.print_area = Some(text); + } else { + sheet.print_titles = Some(text); + } + } + Some(_) => {} + None if lbl.macro_name => report.warn(Warning::MacroNames), + None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, hidden: lbl.hidden }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_refused_shared_formula_ends_shared_expansion_for_later_sheets() { + let mut budget = Budget::new(Limits { max_text: 10, ..Limits::default() }); + assert!(budget.try_shared_text(8)); + assert!(!budget.try_shared_text(8), "over the allowance"); + // a shorter formula would fit the 2 bytes left, but the cut-off holds + assert!(!budget.try_shared_text(1)); + assert!(budget.try_text(1), "text the read needs is still charged normally"); + } +} diff --git a/crates/xls/src/records.rs b/crates/xls/src/records.rs new file mode 100644 index 00000000..67fcca08 --- /dev/null +++ b/crates/xls/src/records.rs @@ -0,0 +1,227 @@ +//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240, +//! 2.5.293, 2.5.294) that may continue across `Continue` records. + +use crate::Error; + +/// `Continue` record type. +pub(crate) const CONTINUE: u16 = 0x003C; +/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer). +const MAX_CHARS: usize = 1 << 20; +/// `Continue` records gathered for one record at most. +const MAX_PARTS: usize = 1 << 16; + +/// One record and the payloads of the `Continue` records that follow it. +pub(crate) struct Record<'a> { + pub id: u16, + pub parts: Vec<&'a [u8]>, +} + +/// Iterates over the records of a stream starting at `pos`. +pub(crate) struct Records<'a> { + stream: &'a [u8], + pos: usize, +} + +impl<'a> Records<'a> { + pub fn new(stream: &'a [u8], pos: usize) -> Self { + Records { stream, pos } + } + + fn raw(&mut self) -> Result, Error> { + if self.pos >= self.stream.len() { + return Ok(None); + } + let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| Error::Invalid("truncated record header".into()))?; + let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]); + let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)])); + let start = self.pos + 4; + let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| Error::Invalid("truncated record".into()))?; + self.pos = start + len; + Ok(Some((id, data))) + } + + fn peek_id(&self) -> Option { + let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?; + Some(u16::from_le_bytes([*h.first()?, *h.get(1)?])) + } + + /// The next record with its `Continue` records, or `None` at the end of the stream. + pub fn next_record(&mut self) -> Result>, Error> { + let Some((id, data)) = self.raw()? else { return Ok(None) }; + let mut parts = vec![data]; + while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS { + if let Some((_, more)) = self.raw()? { + parts.push(more); + } + } + Ok(Some(Record { id, parts })) + } +} + +/// Reads a record's payload, crossing into its `Continue` records when needed. +pub(crate) struct Cursor<'a, 'b> { + parts: &'b [&'a [u8]], + part: usize, + pos: usize, + /// Bytes in every part after the current one, kept up to date as parts advance, so + /// `remaining()` is constant-time however many `Continue` records a string session crosses. + after: usize, +} + +impl<'a, 'b> Cursor<'a, 'b> { + pub fn new(parts: &'b [&'a [u8]]) -> Self { + let after = parts.iter().skip(1).map(|p| p.len()).sum::(); + Cursor { parts, part: 0, pos: 0, after } + } + + /// Skips to the next part when the current one is used up. Returns whether a new part began. + fn settle(&mut self) -> bool { + let mut moved = false; + while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() { + self.after = self.after.saturating_sub(self.parts[self.part + 1].len()); + self.part += 1; + self.pos = 0; + moved = true; + } + moved + } + + pub fn u8(&mut self) -> Result { + self.settle(); + let b = self + .parts + .get(self.part) + .and_then(|p| p.get(self.pos)) + .copied() + .ok_or_else(|| Error::Invalid("record is shorter than its contents".into()))?; + self.pos += 1; + Ok(b) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes([self.u8()?, self.u8()?])) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?])) + } + + pub fn f64(&mut self) -> Result { + let mut b = [0u8; 8]; + for x in b.iter_mut() { + *x = self.u8()?; + } + Ok(f64::from_le_bytes(b)) + } + + pub fn skip(&mut self, n: usize) -> Result<(), Error> { + for _ in 0..n { + self.u8()?; + } + Ok(()) + } + + /// Bytes left in the record (all parts), constant-time in the part count. + pub fn remaining(&self) -> usize { + let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); + here.saturating_add(self.after) + } + + /// The rest of the record as one buffer. + pub fn rest(&mut self) -> Vec { + let mut out = Vec::with_capacity(self.remaining()); + if let Some(p) = self.parts.get(self.part) { + out.extend_from_slice(p.get(self.pos..).unwrap_or(&[])); + } + for p in self.parts.iter().skip(self.part + 1) { + out.extend_from_slice(p); + } + self.part = self.parts.len(); + self.pos = 0; + self.after = 0; + out + } + + /// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into + /// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293). + pub fn chars(&mut self, cch: usize, mut high: bool) -> Result { + if cch > MAX_CHARS { + return Err(Error::Invalid("string too long".into())); + } + let mut units: Vec = Vec::with_capacity(cch.min(self.remaining())); + while units.len() < cch { + let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len()); + if at_end && self.settle() { + high = self.u8()? & 1 != 0; + } + units.push(if high { self.u16()? } else { u16::from(self.u8()?) }); + } + Ok(String::from_utf16_lossy(&units)) + } + + /// ShortXLUnicodeString: 1-byte count, flags, characters. + pub fn short_string(&mut self) -> Result { + let cch = usize::from(self.u8()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeString: 2-byte count, flags, characters. + pub fn string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let high = self.u8()? & 1 != 0; + self.chars(cch, high) + } + + /// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped. + pub fn rich_string(&mut self) -> Result { + let cch = usize::from(self.u16()?); + let flags = self.u8()?; + let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 }; + let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 }; + let text = self.chars(cch, flags & 1 != 0)?; + let extra = runs.saturating_mul(4).saturating_add(ext); + if extra > self.remaining() { + return Err(Error::Invalid("string formatting runs past the end of the record".into())); + } + self.skip(extra)?; + Ok(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_continue_with_a_new_compression_flag() { + // "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part. + let first: &[u8] = &[4, 0, 0, b'a', b'b']; + let second: &[u8] = &[1, 0xE7, 0, b'd', 0]; + let parts = [first, second]; + let mut c = Cursor::new(&parts); + assert_eq!(c.rich_string().unwrap(), "abçd"); + assert_eq!(c.remaining(), 0); + let mut c = Cursor::new(&parts); + assert!(c.chars(MAX_CHARS + 1, false).is_err()); + } + + #[test] + fn remaining_counts_every_part_wherever_the_cursor_is() { + let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]]; + let mut c = Cursor::new(&parts); + assert_eq!(c.remaining(), 13); + for b in 1..=3u64 { + assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order"); + } + assert_eq!(c.remaining(), 10, "after the first part is used up"); + for _ in 0..4 { + c.u8().unwrap(); + } + assert_eq!(c.remaining(), 6, "into the Continue parts"); + let mut c = Cursor::new(&parts); + assert_eq!(u64::from(c.u8().unwrap()), 1); + assert_eq!(c.rest().len(), 12); + assert_eq!(c.remaining(), 0); + } +} diff --git a/crates/xls/src/styles.rs b/crates/xls/src/styles.rs new file mode 100644 index 00000000..59d68784 --- /dev/null +++ b/crates/xls/src/styles.rs @@ -0,0 +1,472 @@ +//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with +//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is +//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether +//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`. + +use std::collections::HashMap; + +use crate::model::{ + Alignment, BorderLine, BorderStyle, Borders, CellFormat, Color, Fill, Font, HAlign, Pattern, Script, Underline, VAlign, Workbook, +}; +use crate::records::Cursor; + +/// Built-in number format codes (en-US) by id. Ids not listed (23–36 are East Asian +/// locale formats) read as General. +const BUILTIN_FORMATS: [(u32, &str); 36] = [ + (0, "General"), + (1, "0"), + (2, "0.00"), + (3, "#,##0"), + (4, "#,##0.00"), + (5, "\"$\"#,##0_);\\(\"$\"#,##0\\)"), + (6, "\"$\"#,##0_);[Red]\\(\"$\"#,##0\\)"), + (7, "\"$\"#,##0.00_);\\(\"$\"#,##0.00\\)"), + (8, "\"$\"#,##0.00_);[Red]\\(\"$\"#,##0.00\\)"), + (9, "0%"), + (10, "0.00%"), + (11, "0.00E+00"), + (12, "# ?/?"), + (13, "# ??/??"), + (14, "m/d/yyyy"), + (15, "d-mmm-yy"), + (16, "d-mmm"), + (17, "mmm-yy"), + (18, "h:mm AM/PM"), + (19, "h:mm:ss AM/PM"), + (20, "h:mm"), + (21, "h:mm:ss"), + (22, "m/d/yyyy h:mm"), + (37, "#,##0 ;(#,##0)"), + (38, "#,##0 ;[Red](#,##0)"), + (39, "#,##0.00;(#,##0.00)"), + (40, "#,##0.00;[Red](#,##0.00)"), + (41, "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"), + (42, "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"), + (43, "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)"), + (44, "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), + (45, "mm:ss"), + (46, "[h]:mm:ss"), + (47, "mmss.0"), + (48, "##0.0E+0"), + (49, "@"), +]; + +pub(crate) fn builtin_format(id: u32) -> Option<&'static str> { + BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) +} + +/// The colour table a workbook without a Palette record uses, as `0xRRGGBB`: the eight fixed +/// colours, then the 56 a Palette record replaces ([MS-XLS] 2.4.188). +pub const DEFAULT_PALETTE: [u32; 64] = [ + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, + 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, + 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, +]; + +/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). +const MAX_FONTS: usize = 1024; +/// Style records kept at most. +const MAX_STYLES: usize = 65_536; +/// Characters kept from a style name (Excel allows 255). +const MAX_STYLE_NAME: usize = 255; + +/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours. +const ICV_FONT_AUTO: u16 = 0x7FFF; +const ICV_DEFAULT_FG: u16 = 0x40; +const ICV_DEFAULT_BG: u16 = 0x41; + +/// One XF record, decoded as far as its bytes allow. +#[derive(Clone, Debug, Default)] +pub(crate) struct Xf { + ifnt: u16, + ifmt: u16, + flags: u16, + /// The CellXF/StyleXF data: 14 bytes when complete. + data: [u8; 14], + /// Whether all 20 bytes were present. + complete: bool, +} + +/// A Style record: the XF it names and its name. +#[derive(Clone, Debug)] +pub(crate) struct NamedStyle { + ixf: u16, + /// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style. + builtin: Option, + /// A user style's name (empty for built-in styles). + name: String, +} + +/// Formatting records collected from the globals substream. +#[derive(Debug, Default)] +pub(crate) struct StyleRecords { + fonts: Vec, + font_colors: Vec, + xfs: Vec, + /// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present. + palette: Option>, + styles: Vec, + /// Records that were shorter than their structure; the parts present were used. + pub(crate) short: bool, +} + +impl StyleRecords { + /// A Font record. The colour is resolved later, once the palette is known. + pub(crate) fn font(&mut self, c: &mut Cursor<'_, '_>) { + if self.fonts.len() >= MAX_FONTS { + return; + } + match read_font(c) { + Some((font, icv)) => { + self.fonts.push(font); + self.font_colors.push(icv); + } + None => { + // Keep the numbering of the fonts after it. + self.short = true; + self.fonts.push(Font::default()); + self.font_colors.push(ICV_FONT_AUTO); + } + } + } + + /// An XF record. A short record keeps the fields it has. + pub(crate) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) { + if self.xfs.len() >= max { + return; + } + let mut xf = Xf::default(); + let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))(); + match head { + Some((ifnt, ifmt, flags)) => { + xf.ifnt = ifnt; + xf.ifmt = ifmt; + xf.flags = flags; + xf.complete = true; + for b in xf.data.iter_mut() { + match c.u8() { + Ok(v) => *b = v, + Err(_) => { + xf.complete = false; + break; + } + } + } + } + None => { + // Not even the font and format: an unformatted, locked cell. + xf.flags = 1; + } + } + if !xf.complete { + self.short = true; + } + self.xfs.push(xf); + } + + /// A Palette record: colours for indexes 8 onwards. + pub(crate) fn palette(&mut self, c: &mut Cursor<'_, '_>) { + let Ok(ccv) = c.u16() else { + self.short = true; + return; + }; + let n = usize::from(ccv).min(56); + if usize::from(ccv) > c.remaining() / 4 { + self.short = true; + } + let mut colors = Vec::with_capacity(n); + for _ in 0..n { + let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break }; + colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)); + } + self.palette = Some(colors); + } + + /// A Style record. + pub(crate) fn style(&mut self, c: &mut Cursor<'_, '_>) { + if self.styles.len() >= MAX_STYLES { + return; + } + let Ok(word) = c.u16() else { + self.short = true; + return; + }; + let ixf = word & 0x0FFF; + if word & 0x8000 != 0 { + let Ok(id) = c.u8() else { + self.short = true; + return; + }; + // BuiltInStyle: the style's number (0 is Normal) and outline level. + self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() }); + } else { + match c.string() { + Ok(name) => { + let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect(); + if !name.is_empty() { + self.styles.push(NamedStyle { ixf, builtin: None, name }); + } + } + Err(_) => self.short = true, + } + } + } + + /// The 64-entry colour table: the built-in eight, then the palette (or the default one). + fn color_table(&self) -> [u32; 64] { + let mut table = DEFAULT_PALETTE; + if let Some(p) = &self.palette { + for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) { + *slot = *c; + } + } + table + } +} + +/// Font record fields up to the name; `None` if the record is too short for them. +fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { + let height = c.u16().ok()?; + let flags = c.u16().ok()?; + let icv = c.u16().ok()?; + let weight = c.u16().ok()?; + let script = c.u16().ok()?; + let underline = c.u8().ok()?; + let family = c.u8().ok()?; + let charset = c.u8().ok()?; + c.skip(1).ok()?; // reserved + let name = c.short_string().ok()?; + let mut f = Font::default(); + let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); + if !name.trim().is_empty() { + f.name = Some(name); + } + if height > 0 { + f.size = Some((f32::from(height) / 20.0).clamp(1.0, 409.0)); + } + f.italic = flags & 0x0002 != 0; + f.strike = flags & 0x0008 != 0; + // Weights run 100-1000; Excel writes 400 for normal and 700 for bold. + f.bold = weight >= 600; + f.script = match script { + 1 => Script::Superscript, + 2 => Script::Subscript, + _ => Script::Baseline, + }; + f.underline = match underline { + 0x01 => Underline::Single, + 0x02 => Underline::Double, + 0x21 => Underline::SingleAccounting, + 0x22 => Underline::DoubleAccounting, + _ => Underline::None, + }; + f.family = Some(family); + f.charset = Some(charset); + Some((f, icv)) +} + +/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system +/// and automatic colours as automatic. +fn color(icv: u16, table: &[u32; 64]) -> Color { + match icv { + ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto, + _ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)), + } +} + +/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border. +fn border_style(dg: u32) -> BorderStyle { + match dg { + 1 => BorderStyle::Thin, + 2 => BorderStyle::Medium, + 3 => BorderStyle::Dashed, + 4 => BorderStyle::Dotted, + 5 => BorderStyle::Thick, + 6 => BorderStyle::Double, + 7 => BorderStyle::Hair, + 8 => BorderStyle::MediumDashed, + 9 => BorderStyle::DashDot, + 10 => BorderStyle::MediumDashDot, + 11 => BorderStyle::DashDotDot, + 12 => BorderStyle::MediumDashDotDot, + 13 => BorderStyle::SlantDashDot, + _ => BorderStyle::None, + } +} + +/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern. +fn pattern(fls: u16) -> Pattern { + match fls { + 0x01 => Pattern::Solid, + 0x02 => Pattern::Gray50, + 0x03 => Pattern::Gray75, + 0x04 => Pattern::Gray25, + 0x05 => Pattern::DarkHorizontal, + 0x06 => Pattern::DarkVertical, + 0x07 => Pattern::DarkDown, + 0x08 => Pattern::DarkUp, + 0x09 => Pattern::DarkGrid, + 0x0A => Pattern::DarkTrellis, + 0x0B => Pattern::LightHorizontal, + 0x0C => Pattern::LightVertical, + 0x0D => Pattern::LightDown, + 0x0E => Pattern::LightUp, + 0x0F => Pattern::LightGrid, + 0x10 => Pattern::LightTrellis, + 0x11 => Pattern::Gray125, + 0x12 => Pattern::Gray0625, + _ => Pattern::None, + } +} + +/// One border side: its style and colour; a side without a style has no colour. +fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { + let style = border_style(dg); + if style == BorderStyle::None { + return BorderLine::default(); + } + // Colour 0 means "not specified": automatic. + let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) }; + BorderLine { style, color } +} + +/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one). +fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { + let i = match ifnt { + 0..=3 => usize::from(ifnt), + 4 => return fonts.first().cloned().unwrap_or_default(), + _ => usize::from(ifnt) - 1, + }; + fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() +} + +/// The number format an XF names: a custom format, else a built-in one, else General. +fn num_fmt(ifmt: u16, formats: &HashMap) -> String { + let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { "General".into() } else { code.to_string() } +} + +/// The formatting of one XF. +fn xf_format(xf: &Xf, fonts: &[Font], formats: &HashMap, table: &[u32; 64]) -> CellFormat { + let mut s = CellFormat { + font: xf_font(xf.ifnt, fonts), + number_format: num_fmt(xf.ifmt, formats), + locked: xf.flags & 0x0001 != 0, + hidden: xf.flags & 0x0002 != 0, + ..CellFormat::default() + }; + if !xf.complete { + return s; + } + let d = &xf.data; + let byte = |i: usize| d.get(i).copied().unwrap_or(0); + let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]); + let (a0, rot, a2) = (byte(0), byte(1), byte(2)); + s.align = Alignment { + h: match a0 & 0x07 { + 1 => HAlign::Left, + 2 => HAlign::Center, + 3 => HAlign::Right, + 4 => HAlign::Fill, + 5 => HAlign::Justify, + 6 => HAlign::CenterAcross, + 7 => HAlign::Distributed, + _ => HAlign::General, + }, + wrap: a0 & 0x08 != 0, + v: match (a0 >> 4) & 0x07 { + 0 => VAlign::Top, + 1 => VAlign::Center, + 3 => VAlign::Justify, + 4 => VAlign::Distributed, + _ => VAlign::Bottom, + }, + rotation: match rot { + 0..=90 => i16::from(rot), + 91..=180 => -(i16::from(rot) - 90), + 255 => 255, + _ => 0, + }, + indent: a2 & 0x0F, + shrink: a2 & 0x10 != 0, + }; + let b1 = word32(4); + let b2 = word32(8); + let mut borders = Borders { + left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table), + right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table), + top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table), + bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table), + ..Borders::default() + }; + let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table); + let which = (b1 >> 30) & 0x03; + if which & 1 != 0 { + borders.diag_down = diag; + } + if which & 2 != 0 { + borders.diag_up = diag; + } + s.borders = borders; + let fill = u16::from_le_bytes([byte(12), byte(13)]); + let fls = ((b2 >> 26) & 0x3F) as u16; + let fg = color(fill & 0x7F, table); + let bg = color((fill >> 7) & 0x7F, table); + s.fill = match pattern(fls) { + Pattern::None => Fill::default(), + Pattern::Solid => Fill { pattern: Pattern::Solid, fg, bg: Color::Auto }, + p => Fill { pattern: p, fg, bg }, + }; + s +} + +/// Fills in the workbook's Normal font, its formats (one per XF, in file order) and its +/// user-defined cell styles. Returns, per XF, whether its format is plain. +pub(crate) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut Workbook) -> Vec { + let table = r.color_table(); + let fonts: Vec = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect(); + // The Normal style's font is what every unformatted cell shows. + let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf)); + wb.normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned()); + let plain = CellFormat { font: wb.normal_font.clone().unwrap_or_default(), ..CellFormat::default() }; + wb.formats = r.xfs.iter().map(|xf| xf_format(xf, &fonts, formats, &table)).collect(); + // User-defined named styles. Excel 2007 and later write every built-in style into each .xls + // (about fifty, their colours approximated to the palette); those are left out. + let mut named: Vec<(String, CellFormat)> = Vec::new(); + // Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear. + let mut seen: std::collections::HashSet = std::collections::HashSet::from(["normal".to_string()]); + for ns in &r.styles { + if ns.builtin.is_some() { + continue; + } + let key = ns.name.to_ascii_lowercase(); + if seen.contains(&key) { + continue; + } + if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + seen.insert(key); + named.push((ns.name.clone(), xf_format(xf, &fonts, formats, &table))); + } + } + wb.cell_styles = named; + wb.formats.iter().map(|f| *f == plain).collect() +} + +#[cfg(test)] +mod tests { + use super::builtin_format; + + #[test] + fn builtin_format_ids() { + assert_eq!((0..=60).filter(|&id| builtin_format(id).is_some()).count(), 36); + assert_eq!(builtin_format(0), Some("General")); + assert_eq!(builtin_format(14), Some("m/d/yyyy")); + assert_eq!(builtin_format(22), Some("m/d/yyyy h:mm")); + assert_eq!(builtin_format(49), Some("@")); + // 23-36 are locale formats, above 49 is custom. + assert_eq!((23..=36).find_map(builtin_format), None); + assert_eq!(builtin_format(50), None); + } +} diff --git a/crates/xls/src/testing.rs b/crates/xls/src/testing.rs new file mode 100644 index 00000000..dcb29c82 --- /dev/null +++ b/crates/xls/src/testing.rs @@ -0,0 +1,130 @@ +//! A minimal BIFF8 writer for tests and fixtures (not a general `.xls` writer). + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +/// Characters kept from one string: 4,000 UTF-16 units and the 3-byte header fit one record. +const MAX_STRING: usize = 4000; + +fn rec(out: &mut Vec, kind: u16, data: &[u8]) { + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&(data.len().min(usize::from(u16::MAX)) as u16).to_le_bytes()); + out.extend_from_slice(data); +} + +fn chars(out: &mut Vec, s: &str) { + let wide = s.chars().any(|c| u32::from(c) > 0xFF); + out.push(u8::from(wide)); + if wide { + for u in s.encode_utf16() { + out.extend_from_slice(&u.to_le_bytes()); + } + } else { + out.extend(s.chars().map(|c| u32::from(c) as u8)); + } +} + +fn bof(dt: u16) -> Vec { + let mut d = vec![0x00, 0x06]; + d.extend_from_slice(&dt.to_le_bytes()); + d.extend_from_slice(&[0; 12]); + d +} + +/// A compound file whose root holds one stream. +pub fn compound(stream: &str, data: &[u8]) -> Vec { + craft_cfb::write_streams(craft_cfb::Version::V3, &[(stream, data)]).unwrap_or_default() +} + +/// A minimal BIFF8 workbook: one worksheet per `(name, rows)`. Cells that read as numbers are +/// Number records, a cell `date:` is a Number with the date format `m/d/yyyy` (built-in +/// format 14), an empty cell is left out, and everything else goes through the shared-string +/// table (as UTF-16 when it isn't Latin-1). +/// +/// shortcut: characters outside the Basic Multilingual Plane are dropped, sheet names are cut at +/// 255 characters and strings at 4,000 (so each fits one record); lift when a fixture needs more. +pub fn workbook(sheets: &[(&str, Vec>)]) -> Vec { + let mut sst: Vec<&str> = Vec::new(); + let mut out = Vec::new(); + rec(&mut out, BOF, &bof(0x0005)); + for ifmt in [0u16, 14] { + let mut xf = vec![0u8; 20]; + if let Some(slot) = xf.get_mut(2..4) { + slot.copy_from_slice(&ifmt.to_le_bytes()); + } + rec(&mut out, 0x00E0, &xf); + } + let mut patches = Vec::new(); + for (name, _) in sheets { + patches.push(out.len() + 4); + let name: String = name.chars().filter(|c| c.len_utf16() == 1).take(255).collect(); + let mut d = vec![0, 0, 0, 0, 0, 0, name.chars().count() as u8]; + chars(&mut d, &name); + rec(&mut out, 0x0085, &d); + } + for c in sheets.iter().flat_map(|(_, rows)| rows.iter().flatten()) { + if c.parse::().is_err() && !c.starts_with("date:") && !sst.contains(&c.as_str()) { + sst.push(c); + } + } + let mut d = Vec::new(); + d.extend_from_slice(&(sst.len() as u32).to_le_bytes()); + d.extend_from_slice(&(sst.len() as u32).to_le_bytes()); + let mut sst_records = vec![d]; + for s in &sst { + let text: String = s.chars().filter(|c| c.len_utf16() == 1).take(MAX_STRING).collect(); + let mut d = (text.chars().count() as u16).to_le_bytes().to_vec(); + chars(&mut d, &text); + sst_records.push(d); + } + write_sst(&mut out, &sst_records); + rec(&mut out, EOF, &[]); + for ((_, rows), at) in sheets.iter().zip(patches) { + let off = (out.len() as u32).to_le_bytes(); + if let Some(slot) = out.get_mut(at..at + 4) { + slot.copy_from_slice(&off); + } + rec(&mut out, BOF, &bof(0x0010)); + for (r, row) in rows.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + if v.is_empty() { + continue; + } + let mut d = Vec::new(); + d.extend_from_slice(&(r as u16).to_le_bytes()); + d.extend_from_slice(&(c as u16).to_le_bytes()); + if let Some(x) = v.strip_prefix("date:").and_then(|x| x.parse::().ok()) { + d.extend_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&x.to_le_bytes()); + rec(&mut out, 0x0203, &d); + } else if let Ok(x) = v.parse::() { + d.extend_from_slice(&0u16.to_le_bytes()); + d.extend_from_slice(&x.to_le_bytes()); + rec(&mut out, 0x0203, &d); + } else { + d.extend_from_slice(&0u16.to_le_bytes()); + let i = sst.iter().position(|s| s == v).unwrap_or(0) as u32; + d.extend_from_slice(&i.to_le_bytes()); + rec(&mut out, 0x00FD, &d); + } + } + } + rec(&mut out, EOF, &[]); + } + compound("Workbook", &out) +} + +/// The SST record and its Continue records: each string goes whole into a record of at most +/// 8,224 bytes, starting a Continue record when the current one is full. +fn write_sst(out: &mut Vec, parts: &[Vec]) { + let mut kind = 0x00FC; + let mut cur: Vec = Vec::new(); + for part in parts { + if cur.len() + part.len() > 8224 { + rec(out, kind, &cur); + kind = 0x003C; + cur.clear(); + } + cur.extend_from_slice(part); + } + rec(out, kind, &cur); +} diff --git a/crates/xls/tests/common/mod.rs b/crates/xls/tests/common/mod.rs new file mode 100644 index 00000000..100d2bf6 --- /dev/null +++ b/crates/xls/tests/common/mod.rs @@ -0,0 +1,241 @@ +//! Helpers shared by the integration tests: a hand-laid compound file (independent of the +//! `craft-cfb` writer, and with a layout the hostile-input tests can poke at) and BIFF8 record +//! builders. Every test file is assembled from the [MS-CFB] and [MS-XLS] structures; no +//! Excel-produced file is used. +#![allow(dead_code, reason = "each test binary uses its own part of these helpers")] + +use craft_xls::{CellRef, Error, Limits, Value, Workbook}; + +pub const END: u32 = 0xFFFF_FFFE; +pub const FREE: u32 = 0xFFFF_FFFF; + +pub fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); +} + +pub fn dir_entry(name: &str, kind: u8, child: u32, start: u32, size: u32) -> [u8; 128] { + let mut e = [0u8; 128]; + let units: Vec = name.encode_utf16().collect(); + for (i, u) in units.iter().enumerate() { + e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes()); + } + if !name.is_empty() { + e[64..66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes()); + } + e[66] = kind; + e[67] = 1; // black + put32(&mut e, 68, FREE); + put32(&mut e, 72, FREE); + put32(&mut e, 76, child); + put32(&mut e, 116, start); + put32(&mut e, 120, size); + e +} + +/// A version 3 compound file (512-byte sectors) whose root holds one stream. Streams under +/// 4096 bytes go to the mini stream, like real files. +pub fn compound(name: &str, data: &[u8]) -> Vec { + let mini = data.len() < 4096; + let pad = |n: usize, to: usize| n.div_ceil(to) * to; + // Sector payloads after the FAT: directory, mini FAT (if any), then data. + let mut payload: Vec> = vec![]; + let mut chains: Vec<(usize, usize)> = vec![]; // (first payload index, sector count) + let (dir_at, minifat_at, data_at); + let mut dir = vec![]; + let mut minifat = vec![]; + let stream_sectors; + if mini { + let m = data.len().div_ceil(64).max(1); + for i in 0..m { + minifat.extend_from_slice(&(if i + 1 == m { END } else { i as u32 + 1 }).to_le_bytes()); + } + minifat.resize(512, 0xFF); + let mut container = data.to_vec(); + container.resize(pad(data.len().max(1), 64), 0); + stream_sectors = container.len().div_ceil(512); + container.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = Some(1); + data_at = 2; + payload.push(vec![]); + payload.push(minifat); + payload.extend(container.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, 0, (stream_sectors * 512) as u32)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } else { + let mut body = data.to_vec(); + stream_sectors = body.len().div_ceil(512); + body.resize(stream_sectors * 512, 0); + dir_at = 0; + minifat_at = None; + data_at = 1; + payload.push(vec![]); + payload.extend(body.chunks(512).map(<[u8]>::to_vec)); + chains.push((data_at, stream_sectors)); + dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, END, 0)); + dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32)); + } + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0)); + // FAT sectors come first; their count depends on the total. + let mut fat_n = 1; + while (fat_n + payload.len()).div_ceil(128) > fat_n { + fat_n += 1; + } + let sec = |i: usize| (fat_n + i) as u32; + // Fix the directory's start sectors now that the layout is known. + let first_data = sec(data_at); + if mini { + put32(&mut dir, 116, first_data); // root: mini stream container + } else { + put32(&mut dir, 128 + 116, first_data); + } + payload[dir_at] = dir; + let total = fat_n + payload.len(); + let mut fat = vec![FREE; fat_n * 128]; + for f in fat.iter_mut().take(fat_n) { + *f = 0xFFFF_FFFD; + } + fat[sec(dir_at) as usize] = END; + if let Some(m) = minifat_at { + fat[sec(m) as usize] = END; + } + for (first, n) in chains { + for k in 0..n { + fat[sec(first + k) as usize] = if k + 1 == n { END } else { sec(first + k + 1) }; + } + } + let mut out = vec![0u8; 512]; + out[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + out[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); + out[26..28].copy_from_slice(&3u16.to_le_bytes()); + out[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); + out[30..32].copy_from_slice(&9u16.to_le_bytes()); + out[32..34].copy_from_slice(&6u16.to_le_bytes()); + put32(&mut out, 44, fat_n as u32); + put32(&mut out, 48, sec(dir_at)); + put32(&mut out, 56, 4096); + put32(&mut out, 60, minifat_at.map_or(END, sec)); + put32(&mut out, 64, u32::from(minifat_at.is_some())); + put32(&mut out, 68, END); + for i in 0..109 { + put32(&mut out, 76 + i * 4, if i < fat_n { i as u32 } else { FREE }); + } + for f in &fat { + out.extend_from_slice(&f.to_le_bytes()); + } + for p in payload { + let mut p = p; + p.resize(512, 0); + out.extend_from_slice(&p); + } + assert_eq!(out.len(), 512 * (1 + total)); + out +} + +pub fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +pub fn cat(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +pub fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Cell header: row, column, XF index. +pub fn cell(r: u16, c: u16, xf: u16) -> Vec { + u16s(&[r, c, xf]) +} + +pub fn bof(dt: u16) -> Vec { + rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]])) +} + +pub fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// XLUnicodeString with compressed characters. +pub fn xlstr(s: &str) -> Vec { + cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()]) +} + +/// A FORMULA record with a numeric cached result. +pub fn formula(r: u16, c: u16, cached: [u8; 8], rgce: &[u8]) -> Vec { + let mut p = cell(r, c, 0); + p.extend_from_slice(&cached); + p.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // flags, chn + p.extend_from_slice(&(rgce.len() as u16).to_le_bytes()); + p.extend_from_slice(rgce); + rec(0x0006, &p) +} + +pub fn num(n: f64) -> [u8; 8] { + n.to_le_bytes() +} + +pub fn special(kind: u8, v: u8) -> [u8; 8] { + [kind, 0, v, 0, 0, 0, 0xFF, 0xFF] +} + +/// PtgRef (value class) with relative row and column. +pub fn ptg_ref(r: u16, c: u16) -> Vec { + cat(&[&[0x44], &u16s(&[r, c | 0xC000])]) +} + +pub fn ptg_int(n: u16) -> Vec { + cat(&[&[0x1E], &n.to_le_bytes()]) +} + +pub fn exp(r: u16, c: u16) -> Vec { + cat(&[&[0x01], &u16s(&[r, c])]) +} + +/// What a read skipped, as text. +#[derive(Debug)] +pub struct Report { + pub warnings: Vec, +} + +pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, Report), Error> { + read_limited(bytes, Limits::default()) +} + +pub fn read_limited(bytes: &[u8], limits: Limits) -> Result<(Workbook, Report), Error> { + let read = craft_xls::read_with(bytes, limits)?; + Ok((read.workbook, Report { warnings: read.warnings.iter().map(ToString::to_string).collect() })) +} + +/// `B3` → its position. +pub fn at(a1: &str) -> CellRef { + let letters = a1.bytes().take_while(u8::is_ascii_alphabetic).count(); + let col = a1[..letters].bytes().fold(0u32, |n, b| n * 26 + u32::from(b.to_ascii_uppercase() - b'A') + 1) - 1; + CellRef::new(a1[letters..].parse::().unwrap() - 1, col) +} + +pub fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +/// The value at `a1` of sheet `sheet` (empty for a cell that is not stored). +pub fn v(wb: &Workbook, sheet: usize, a1: &str) -> Value { + wb.sheets[sheet].cells.get(&at(a1)).map(|c| c.value.clone()).unwrap_or_default() +} + +/// The formula text at `a1` of the first sheet. +pub fn ftext(wb: &Workbook, a1: &str) -> Option { + wb.sheets[0].cells.get(&at(a1)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()) +} + +/// The number format of the cell at `a1` of the first sheet. +pub fn fmt(wb: &Workbook, a1: &str) -> String { + let cell = &wb.sheets[0].cells[&at(a1)]; + cell.format.map_or_else(|| "General".to_string(), |i| wb.formats[usize::from(i)].number_format.clone()) +} diff --git a/crates/xls/tests/read.rs b/crates/xls/tests/read.rs new file mode 100644 index 00000000..24540b1f --- /dev/null +++ b/crates/xls/tests/read.rs @@ -0,0 +1,703 @@ +//! Reading BIFF8 workbooks: values, formulas, layout, names, limits and hostile input. + +mod common; + +use common::{ + at, bof, cat, cell, compound, eof, exp, fmt, formula, ftext, num, ptg_int, ptg_ref, put32, read_limited, read_xls, rec, special, text, u16s, v, + xlstr, +}; +use craft_xls::{CalcMode, CellError, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls}; + +fn range(a1: &str) -> Range { + let (a, b) = a1.split_once(':').unwrap_or((a1, a1)); + Range::new(at(a), at(b)) +} + +fn text_limit(max_text: usize) -> Limits { + Limits { max_text, ..Limits::default() } +} + +/// A workbook with two worksheets and a chart sheet covering the stage-1 features. +fn sample(date1904: bool) -> Vec { + // SST: "Hello", then "Wörld" as UTF-16 split across a Continue record. + let mut sst = cat(&[&2u32.to_le_bytes(), &2u32.to_le_bytes(), &xlstr("Hello")]); + sst.extend_from_slice(&[5, 0, 0, b'W']); + let sst_rec = cat(&[&rec(0x00FC, &sst), &rec(0x003C, &cat(&[&[1], &u16s(&[0xF6, 'r' as u16, 'l' as u16, 'd' as u16])]))]); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + // Lbl "Rate" = Data!$A$1 (PtgRef3d, absolute). + let rate = cat(&[&[0x3A], &1u16.to_le_bytes(), &u16s(&[0, 0])]); + let lbl = rec(0x0018, &cat(&[&u16s(&[0]), &[0, 4], &u16s(&[rate.len() as u16, 0, 0]), &[0; 4], &[0], b"Rate", &rate])); + // Print_Area on Sheet1 (built-in name 6, itab 1) = Sheet1!$A$1:$B$2. + let pa = cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[0, 1, 0, 1])]); + let print_area = rec(0x0018, &cat(&[&u16s(&[0x0020]), &[0, 1], &u16s(&[pa.len() as u16, 0, 1]), &[0; 4], &[0, 6], &pa])); + let boundsheet = |pos: u32, kind: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, kind, name.len() as u8, 0], name.as_bytes()])); + let globals = |positions: [u32; 3]| { + cat(&[ + &bof(0x0005), + &rec(0x0022, &u16s(&[u16::from(date1904)])), + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(10), // built-in 0.00% + &boundsheet(positions[0], 0, "Sheet1"), + &boundsheet(positions[1], 0, "Data"), + &boundsheet(positions[2], 2, "Chart1"), + &rec(0x01AE, &u16s(&[3, 0x0401])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[0, 1, 1])])), + &lbl, + &print_area, + &sst_rec, + &eof(), + ]) + }; + let mulrk = cat(&[&u16s(&[1, 2]), &u16s(&[0]), &((7u32 << 2) | 2).to_le_bytes(), &u16s(&[2]), &((150u32 << 2) | 3).to_le_bytes(), &u16s(&[3])]); + let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]); + let array = cat(&[&[0x65], &u16s(&[4, 5, 0xC000, 0xC000]), &ptg_int(2), &[0x05]]); + let sum = cat(&[&[0x25], &u16s(&[0, 0, 1 | 0xC000, 2 | 0xC000]), &[0x22, 1], &4u16.to_le_bytes()]); + let concat = cat(&[&ptg_ref(0, 0), &[0x17, 1, 0, b'!'], &[0x08]]); + let round = cat(&[&ptg_ref(0, 1), &ptg_int(0), &[0x41], &27u16.to_le_bytes()]); + let name_ref = cat(&[&[0x43], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let ref3d = cat(&[&[0x5A], &1u16.to_le_bytes(), &u16s(&[0, 0xC000])]); + let sheet1 = cat(&[ + &bof(0x0010), + &rec(0x0225, &u16s(&[0, 255])), + &rec(0x007D, &u16s(&[0, 0, 20 * 256, 0, 0x0002, 0])), + &rec(0x0208, &cat(&[&u16s(&[0, 0, 4, 600, 0, 0]), &(0x0140u32).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 0, 0), &0u32.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(0, 1, 0), &1.5f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(0, 2, 0), &((42u32 << 2) | 2).to_le_bytes()])), + &rec(0x00FD, &cat(&[&cell(0, 3, 0), &1u32.to_le_bytes()])), + &rec(0x0205, &cat(&[&cell(1, 0, 0), &[1, 0]])), + &rec(0x0205, &cat(&[&cell(1, 1, 0), &[0x07, 1]])), + &rec(0x00BD, &mulrk), + &formula(2, 0, num(3.0), &cat(&[&ptg_ref(0, 1), &ptg_int(2), &[0x05]])), + &formula(2, 1, num(43.5), &sum), + &formula(2, 2, special(0, 0), &concat), + &rec(0x0207, &xlstr("Hello!")), + &formula(2, 3, special(1, 1), &cat(&[&ptg_ref(0, 1), &ptg_int(1), &[0x0D]])), + &formula(2, 4, num(14.0), &name_ref), + &formula(2, 5, num(2.0), &round), + &formula(2, 6, num(7.0), &ref3d), + &formula(2, 7, special(2, 0x2A), &[0x1C, 0x2A]), + &rec(0x0203, &cat(&[&cell(3, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(3, 1, 2), &0.25f64.to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(4, 0, 0), &((1u32 << 2) | 2).to_le_bytes()])), + &rec(0x027E, &cat(&[&cell(5, 0, 0), &((2u32 << 2) | 2).to_le_bytes()])), + &formula(4, 1, num(10.0), &exp(4, 1)), + &rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(5, 1, num(20.0), &exp(4, 1)), + &formula(4, 2, num(2.0), &exp(4, 2)), + &rec(0x0221, &cat(&[&u16s(&[4, 5]), &[2, 2], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])), + &formula(5, 2, num(4.0), &exp(4, 2)), + &rec(0x00E5, &cat(&[&u16s(&[1]), &u16s(&[6, 6, 0, 1])])), + // An embedded chart substream: its records are not cells. + &bof(0x0020), + &rec(0x0203, &cat(&[&cell(9, 9, 0), &9.0f64.to_le_bytes()])), + &eof(), + &eof(), + ]); + let data = cat(&[&bof(0x0010), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); + let chart = cat(&[&bof(0x0020), &eof()]); + let len = globals([0; 3]).len() as u32; + let p1 = len; + let p2 = p1 + sheet1.len() as u32; + let p3 = p2 + data.len() as u32; + cat(&[&globals([p1, p2, p3]), &sheet1, &data, &chart]) +} + +#[test] +fn reads_values_formulas_formats_and_layout() { + for big in [false, true] { + let mut stream = sample(false); + if big { + // Past the mini stream cutoff: the stream lives in regular sectors. + stream.extend(std::iter::repeat_n(0u8, 5000)); + } + let bytes = compound("Workbook", &stream); + assert!(is_xls(&bytes)); + let (wb, report) = read_xls(&bytes).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("chart sheets"))); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1", "Data"]); + assert_values_roundtrip(&wb); + assert_formula_roundtrip(&wb); + assert_layout_roundtrip(&wb); + } + let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); + assert!(wb.date_1904); +} + +fn assert_values_roundtrip(wb: &Workbook) { + assert_eq!(v(wb, 0, "A1"), text("Hello")); + assert_eq!(v(wb, 0, "B1"), Value::Number(1.5)); + assert_eq!(v(wb, 0, "C1"), Value::Number(42.0)); + assert_eq!(v(wb, 0, "D1"), text("Wörld")); + assert_eq!(v(wb, 0, "A2"), Value::Bool(true)); + assert_eq!(v(wb, 0, "B2"), Value::Error(CellError::Div0)); + assert_eq!(v(wb, 0, "C2"), Value::Number(7.0)); + assert_eq!(v(wb, 0, "D2"), Value::Number(1.5)); +} + +fn assert_formula_roundtrip(wb: &Workbook) { + // Formulas: text plus the cached result. + assert_eq!(ftext(wb, "A3").as_deref(), Some("B1*2")); + assert_eq!(v(wb, 0, "A3"), Value::Number(3.0)); + assert_eq!(ftext(wb, "B3").as_deref(), Some("SUM(B1:C1)")); + assert_eq!(ftext(wb, "C3").as_deref(), Some("A1&\"!\"")); + assert_eq!(v(wb, 0, "C3"), text("Hello!")); + assert_eq!(ftext(wb, "D3").as_deref(), Some("B1>1")); + assert_eq!(v(wb, 0, "D3"), Value::Bool(true)); + assert_eq!(ftext(wb, "E3").as_deref(), Some("Rate*2")); + assert_eq!(ftext(wb, "F3").as_deref(), Some("ROUND(B1,0)")); + assert_eq!(ftext(wb, "G3").as_deref(), Some("Data!A1")); + assert_eq!(v(wb, 0, "H3"), Value::Error(CellError::NA)); + // Shared formula: relative offsets resolve per cell. + assert_eq!(ftext(wb, "B5").as_deref(), Some("A5*10")); + assert_eq!(ftext(wb, "B6").as_deref(), Some("A6*10")); + // Array formula: the anchor holds it; the other cell's cached result is dropped so that + // the formula fills the range on recalculation . + let c5 = wb.sheets[0].cells[&at("C5")].formula.clone().unwrap(); + assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(range("C5:C6")))); + // The follower keeps its (unlocked, so not plain) format on a blank cell. + assert_eq!(wb.sheets[0].cells[&at("C6")], craft_xls::Cell { value: Value::Empty, format: Some(0), formula: None }); + assert_eq!(ftext(wb, "C6"), None); + assert_eq!(v(wb, 0, "C6"), Value::Empty); +} + +fn assert_layout_roundtrip(wb: &Workbook) { + // Number formats, merges, sizes, names, print area; embedded chart records ignored. + let sh = &wb.sheets[0]; + assert_eq!(fmt(wb, "A4"), "yyyy-mm-dd"); + assert_eq!(fmt(wb, "B4"), "0.00%"); + assert_eq!(fmt(wb, "A1"), "General"); + assert_eq!(sh.merges, vec![range("A7:B7")]); + // Column A: 20 characters wide, set by the user, in XF 0 (unlocked here, so not plain). Row 1: 600 twips (30 pt), set by the user. + assert_eq!(sh.columns, [(0, Column { width: 20 * 256, custom_width: true, format: Some(0), ..Column::default() })].into()); + assert_eq!(sh.rows, [(0, Row { height: 600, custom_height: true, ..Row::default() })].into()); + assert_eq!(sh.default_row_height, Some(255)); + assert_eq!(sh.view, None); + assert_eq!(sh.print_area.as_deref(), Some("Sheet1!$A$1:$B$2")); + assert_eq!(sh.print_titles, None); + assert_eq!(v(wb, 0, "J10"), Value::Empty); + assert_eq!(wb.names.len(), 1); + assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1")); + assert_eq!(v(wb, 1, "A1"), Value::Number(7.0)); + assert!(!wb.date_1904); +} + +#[test] +fn text_result_of_a_formula_kept_as_its_value() { + // A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result + // is kept. With the default XF the cell is blank until the String record brings the text. + let table = cat(&[&[0x02], &u16s(&[0, 0])]); + let sheet = cat(&[&bof(0x0010), &formula(0, 0, special(0, 0), &table), &rec(0x0207, &xlstr("103a")), &eof()]); + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let len = cat(&[&bof(0x0005), &boundsheet(0), &eof()]).len() as u32; + let stream = cat(&[&bof(0x0005), &boundsheet(len), &eof(), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("what-if data tables")), "{:?}", report.warnings); + assert_eq!(ftext(&wb, "A1"), None); + assert_eq!(v(&wb, 0, "A1"), text("103a")); +} + +#[test] +fn refuses_encrypted_old_and_foreign_files() { + // FILEPASS in the globals: an encrypted workbook. + let mut stream = bof(0x0005); + stream.extend(rec(0x002F, &[1, 0, 1, 0])); + stream.extend(eof()); + assert_eq!(read_xls(&compound("Workbook", &stream)).err(), Some(Error::Encrypted)); + // A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream. + let book = compound("Book", &stream); + assert!(is_xls(&book)); + assert_eq!(read_xls(&book).err(), Some(Error::Biff5)); + // So does one whose Workbook stream starts with a BIFF5 BOF. + let mut old = rec(0x0809, &u16s(&[0x0500, 0x0005, 0, 0])); + old.extend(eof()); + assert_eq!(read_xls(&compound("Workbook", &old)).err(), Some(Error::Biff5)); + // Another compound file (a word processor document): not a workbook. + let doc = compound("WordDocument", &[0u8; 100]); + assert!(!is_xls(&doc)); + assert!(matches!(read_xls(&doc), Err(Error::Invalid(_)))); + assert!(!is_xls(b"PK\x03\x04") && !is_xls(&[])); +} + +#[test] +fn hostile_xls_input_never_panics_or_hangs() { + let stream = sample(false); + let good = compound("Workbook", &stream); + // Truncation anywhere. + for cut in (0..good.len()).step_by(37) { + let _ = read_xls(&good[..cut]); + let _ = is_xls(&good[..cut]); + } + // Byte mutations in the container and the stream. + for at in (0..good.len()).step_by(7) { + for x in [0x00u8, 0x01, 0x7F, 0xFF] { + let mut b = good.clone(); + b[at] = x; + let _ = read_xls(&b); + } + } + for at in 0..stream.len() { + let mut s = stream.clone(); + s[at] ^= 0xA5; + let _ = read_xls(&compound("Workbook", &s)); + } + // A FAT chain that loops on itself (big stream in regular sectors). + let mut big = stream.clone(); + big.extend(std::iter::repeat_n(0u8, 5000)); + let mut b = compound("Workbook", &big); + let fat_at = 512; + let first_data = 2usize; // FAT, directory, then data + put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32); + assert!(read_xls(&b).is_err()); + // Huge declared counts: shared strings, merges, external sheets. + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()]))); + huge.extend(eof()); + assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(Error::TooLarge(_)))); + let mut huge = bof(0x0005); + huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")]))); + huge.extend(rec(0x0017, &u16s(&[0xFFFF]))); + huge.extend(eof()); + assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets + // A sheet pointing outside the stream and a stream size larger than the file. + let mut bad = bof(0x0005); + bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"]))); + bad.extend(eof()); + assert!(read_xls(&compound("Workbook", &bad)).is_err()); + let mut b = compound("Workbook", &big); + put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF); + assert!(read_xls(&b).is_err()); + // Random bytes behind a compound-file signature. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + for _ in 0..200 { + let mut b = good[..512].to_vec(); + for _ in 0..2048 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + b.push(seed as u8); + } + let _ = read_xls(&b); + } +} + +#[test] +fn dialog_sheets_are_skipped_not_read_as_worksheets() { + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |positions: [u32; 2]| cat(&[&bof(0x0005), &boundsheet(positions[0], "Dialog1"), &boundsheet(positions[1], "Sheet1"), &eof()]); + let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &1.0f64.to_le_bytes()])), &eof()]); + let worksheet = cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); + let first = globals([0; 2]).len() as u32; + let stream = cat(&[&globals([first, first + dialog.len() as u32]), &dialog, &worksheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Sheet1"]); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert!(report.warnings.iter().any(|w| w.contains("dialog sheets")), "{:?}", report.warnings); +} + +#[test] +fn sheets_share_one_record_budget() { + // Many BoundSheet records point into one long substream (at successive nested BOFs), so each + // sheet would read the whole tail. The record allowance is shared, not per sheet. + let n = 300u32; + let tail: Vec = (0..2000u16).flat_map(|r| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]))).collect(); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |base: u32| { + let mut g = bof(0x0005); + for i in 0..n { + g.extend(boundsheet(base + i * bof(0x0010).len() as u32, &format!("S{i}"))); + } + g.extend(eof()); + g + }; + let base = globals(0).len() as u32; + let mut stream = globals(base); + for _ in 0..n { + stream.extend(bof(0x0010)); + } + stream.extend(tail); + stream.extend(eof()); + let file = compound("Workbook", &stream); + // About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits. + assert!(matches!(read_limited(&file, Limits { max_records: 100_000, ..Limits::default() }), Err(Error::TooLarge(_)))); + assert!(read_limited(&file, Limits { max_records: 10_000_000, ..Limits::default() }).is_ok()); + + // Two sheets at the same offset are refused outright. + let mut dup = bof(0x0005); + let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32; + dup.extend(boundsheet(at, "A")); + dup.extend(boundsheet(at, "B")); + dup.extend(eof()); + dup.extend(bof(0x0010)); + dup.extend(eof()); + let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); + assert!(err.contains("same substream"), "{err}"); +} + +#[test] +fn formulas_using_a_skipped_name_keep_their_saved_results() { + // Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook), + // "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a + // name that uses it is still read as it was. + let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]); + let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]); + let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let lbl = |name: &str, rgce: &[u8]| { + rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce])) + }; + let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(pos), + &rec(0x01AE, &u16s(&[1, 0x0401])), + &rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])), + &rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])), + &lbl("ExtRange", &ext), + &lbl("Local", &local), + &lbl("Twice", &twice), + &eof(), + ]) + }; + let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]); + let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]); + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])), + &formula(5, 1, num(60.0), &sum_ext), + &formula(6, 1, num(20.0), &local_x2), + &eof(), + ]); + let pos = globals(0).len() as u32; + let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap(); + let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect(); + assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0)); + assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2")); + assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings); +} + +#[test] +fn reads_iterative_calculation_settings() { + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert_eq!(wb.calc.iterative, Some(true)); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (Some(50), Some(0.01))); + assert_eq!(wb.calc.mode, Some(CalcMode::Manual)); + assert_eq!((wb.sheets[0].calc, wb.sheets[1].calc), (wb.calc, craft_xls::Calc::default())); + assert!(wb.precision_as_displayed); + // Without the records nothing is set: the caller applies Excel's defaults. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, craft_xls::Calc::default()); + assert!(!wb.precision_as_displayed); +} + +#[test] +fn a_format_shorter_than_its_count_keeps_its_characters() { + // A FORMAT whose cch is one more than the characters in the record (seen in real files): + // Excel opens them, the record's characters are the format code. + let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"])); + let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]])); + let globals = |pos: u32| { + cat(&[ + &bof(0x0005), + &short, + &rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])), + &xf(0), + &xf(164), + &xf(165), + &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), + &eof(), + ]) + }; + let sheet = cat(&[ + &bof(0x0010), + &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])), + &rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])), + &eof(), + ]); + let stream = cat(&[&globals(globals(0).len() as u32), &sheet]); + let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap(); + let fmt = |a1: &str| fmt(&wb, a1); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25)); + assert_eq!(fmt("A2"), "yyyy-mm-dd"); + assert_eq!(fmt("A3"), "0.000"); + assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings); +} + +/// A workbook whose "Report" sheet holds legacy formulas over ranges of "Data": Data!A1 = 11, +/// B1:G1 = 1, A2 = 5, A3 = 7, and Report!B1:G1 filled, so a formula in Report!A1 that spilled +/// would be blocked. +fn legacy_formulas_book() -> Vec { + let area3d = |r0: u16, r1: u16, c0: u16, c1: u16| cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[r0, r1, c0, c1])]); + let a2a3 = area3d(1, 2, 0, 0); + let sum = |inner: &[u8]| cat(&[inner, &[0x22, 1], &4u16.to_le_bytes()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| { + cat(&[ + &bof(0x0005), + &boundsheet(p1, "Data"), + &boundsheet(p2, "Report"), + &rec(0x01AE, &u16s(&[2, 0x0401])), + &rec(0x0017, &cat(&[&u16s(&[1]), &u16s(&[0, 0, 0])])), + &eof(), + ]) + }; + let number = |r: u16, c: u16, n: f64| rec(0x0203, &cat(&[&cell(r, c, 0), &n.to_le_bytes()])); + let mut data = bof(0x0010); + data.extend(number(0, 0, 11.0)); + for c in 1..7 { + data.extend(number(0, c, 1.0)); + } + data.extend(number(1, 0, 5.0)); + data.extend(number(2, 0, 7.0)); + data.extend(eof()); + let shared = cat(&[&a2a3, &ptg_int(10), &[0x05]]); + let array = cat(&[&a2a3, &ptg_int(2), &[0x05]]); + let single_array = sum(&cat(&[&a2a3, &ptg_int(2), &[0x05]])); + let mut report = bof(0x0010); + for c in 1..7 { + report.extend(number(0, c, 0.0)); + } + report.extend(cat(&[ + // A1 = Data!A1:G1, A2 = +Data!A1:G1 (a title row repeated from another sheet). + &formula(0, 0, num(11.0), &area3d(0, 0, 0xC000, 6 | 0xC000)), + &formula(1, 0, num(11.0), &cat(&[&area3d(0, 0, 0xC000, 6 | 0xC000), &[0x12]])), + // C2:C3 share Data!$A$2:$A$3*10. + &formula(1, 2, num(50.0), &exp(1, 2)), + &rec(0x04BC, &cat(&[&u16s(&[1, 2]), &[2, 2, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(2, 2, num(70.0), &exp(1, 2)), + // D2:D3 = {Data!$A$2:$A$3*2}, entered with Ctrl+Shift+Enter. + &formula(1, 3, num(10.0), &exp(1, 3)), + &rec(0x0221, &cat(&[&u16s(&[1, 2]), &[3, 3], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])), + &formula(2, 3, num(14.0), &exp(1, 3)), + // H1 = {SUM(Data!$A$2:$A$3*2)}, a one-cell Ctrl+Shift+Enter array. + &formula(0, 7, num(24.0), &exp(0, 7)), + &rec(0x0221, &cat(&[&u16s(&[0, 0]), &[7, 7], &[0; 6], &(single_array.len() as u16).to_le_bytes(), &single_array])), + // H2 = MAX(SUM(Data!$A$2:$A$3/12),0), H3 = SUM(Data!$A$2:$A$3), H4 = SQRT(Data!$A$2:$A$3). + &formula(1, 7, num(5.0 / 12.0), &cat(&[&sum(&cat(&[&a2a3, &ptg_int(12), &[0x06]])), &ptg_int(0), &[0x22, 2], &7u16.to_le_bytes()])), + &formula(2, 7, num(12.0), &sum(&a2a3)), + &formula(3, 7, special(2, 0x0F), &cat(&[&a2a3, &[0x21], &20u16.to_le_bytes()])), + &eof(), + ])); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + data.len() as u32; + cat(&[&globals(p1, p2), &data, &report]) +} + +/// Every formula in an .xls file is a legacy one. They are read as stored: adding Excel's +/// implicit intersection (`@`) is the job of a caller whose formula language has dynamic arrays. +#[test] +fn legacy_formulas_are_read_as_stored() { + let bytes = compound("Workbook", &legacy_formulas_book()); + let formula = |wb: &Workbook, a1: &str| { + let f = wb.sheets[1].cells[&at(a1)].formula.clone().unwrap(); + (f.text, f.array) + }; + let (wb, _) = read_xls(&bytes).unwrap(); + assert_eq!(formula(&wb, "A1"), ("Data!A1:G1".into(), None)); + assert_eq!(formula(&wb, "A2"), ("+Data!A1:G1".into(), None)); + // Shared formulas, each cell on its own. + assert_eq!(formula(&wb, "C2"), ("Data!$A$2:$A$3*10".into(), None)); + assert_eq!(formula(&wb, "C3"), ("Data!$A$2:$A$3*10".into(), None)); + // Ctrl+Shift+Enter arrays, one cell or several, keep their range on the anchor. + assert_eq!(formula(&wb, "D2"), ("Data!$A$2:$A$3*2".into(), Some(range("D2:D3")))); + assert_eq!(formula(&wb, "H1"), ("SUM(Data!$A$2:$A$3*2)".into(), Some(range("H1")))); + assert_eq!(formula(&wb, "H2"), ("MAX(SUM(Data!$A$2:$A$3/12),0)".into(), None)); + assert_eq!(formula(&wb, "H3"), ("SUM(Data!$A$2:$A$3)".into(), None)); + assert_eq!(formula(&wb, "H4"), ("SQRT(Data!$A$2:$A$3)".into(), None)); + assert_eq!(v(&wb, 1, "H4"), Value::Error(CellError::Value)); +} + +fn one_sheet_with(sheet_records: &[u8]) -> Vec { + let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &eof()]); + let sheet = cat(&[&bof(0x0010), sheet_records, &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]); + compound("Workbook", &cat(&[&globals(globals(0).len() as u32), &sheet])) +} + +#[test] +fn xls_window2_right_to_left_sheet_opens_right_to_left() { + let window2 = |flags: u16| rec(0x023E, &u16s(&[flags, 0, 0, 64, 0, 0, 0, 0, 0])); + let view = |file: &[u8]| { + let (wb, _) = read_xls(file).unwrap(); + let view = wb.sheets[0].view.unwrap(); + (view.right_to_left, view.show_gridlines, view.show_headings, view.show_zeros, view.show_formulas) + }; + assert_eq!(view(&one_sheet_with(&window2(0x0040 | 0x0004))), (true, false, true, false, false)); + assert_eq!(view(&one_sheet_with(&window2(0x0001 | 0x0002 | 0x0010))), (false, true, false, true, true)); +} + +#[test] +fn xls_short_window2_is_ignored() { + for payload in [&[][..], &[0x40][..], &[0x40, 0][..], &[0x40, 0, 0, 0, 0, 0, 0, 0, 0][..]] { + let (wb, _) = read_xls(&one_sheet_with(&rec(0x023E, payload))).unwrap(); + assert_eq!(wb.sheets[0].view, None::); + assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0)); + } +} + +/// Shared-formula followers each keep their own text, charged to the workbook's text allowance: +/// with no allowance left they keep their cached values, and the import still succeeds. +#[test] +fn shared_formula_text_counts_against_the_text_allowance() { + let file = compound("Workbook", &sample(false)); + // The smallest allowance that reads every string: nothing is left for expanded formulas. + let left = (0..1000).find(|n| read_limited(&file, text_limit(*n)).is_ok()).unwrap(); + let (wb, report) = read_limited(&file, text_limit(left)).unwrap(); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0)); + assert!(report.warnings.iter().any(|w| w.contains("shared-formula text")), "{:?}", report.warnings); + // an array anchor after the cut-off still gets its formula + assert!(ftext(&wb, "C5").is_some()); + // With room, the followers keep their formulas. + let (wb, _) = read_xls(&file).unwrap(); + assert!(ftext(&wb, "B6").is_some()); +} + +/// Shared-formula text is optional: an early sheet's expanded formulas must not use the allowance +/// a later sheet's inline strings need, or the whole import fails for text that fits. +#[test] +fn shared_formulas_do_not_starve_a_later_sheets_text() { + let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p: [u32; 2]| cat(&[&bof(0x0005), &boundsheet(p[0], "One"), &boundsheet(p[1], "Two"), &eof()]); + let one = cat(&[ + &bof(0x0010), + &formula(4, 1, num(10.0), &exp(4, 1)), + &rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])), + &formula(5, 1, num(20.0), &exp(4, 1)), + &eof(), + ]); + let two = cat(&[&bof(0x0010), &rec(0x0204, &cat(&[&cell(0, 0, 0), &xlstr("abcdefgh")])), &eof()]); + let p1 = globals([0; 2]).len() as u32; + let file = compound("Workbook", &cat(&[&globals([p1, p1 + one.len() as u32]), &one, &two])); + // The smallest allowance that reads every string leaves nothing for expanded formulas. + let left = (0..1000).find(|n| read_limited(&file, text_limit(*n)).is_ok()).unwrap(); + let (wb, _) = read_limited(&file, text_limit(left)).unwrap(); + assert_eq!(v(&wb, 1, "A1"), text("abcdefgh")); + assert_eq!(ftext(&wb, "B6"), None); + assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0)); + let (wb, _) = read_xls(&file).unwrap(); + assert!(ftext(&wb, "B6").is_some()); +} + +/// Sheet names are returned as stored: two worksheets may share a name (a caller that needs +/// unique names checks them). +#[test] +fn duplicate_sheet_names_are_returned_as_stored() { + let sheet = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p: u32| cat(&[&bof(0x0005), &boundsheet(p, "One"), &boundsheet(p + sheet.len() as u32, "one"), &eof()]); + let file = compound("Workbook", &cat(&[&globals(globals(0).len() as u32), &sheet, &sheet])); + let (wb, _) = read_xls(&file).unwrap(); + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["One", "one"]); +} + +/// RString ([MS-XLS] 2.4.229): a label with formatting runs. The text is read, the runs skipped. +#[test] +fn rstring_cells_are_read() { + let rstring = rec(0x00D6, &cat(&[&cell(1, 0, 0), &xlstr("rich"), &u16s(&[1, 0, 5])])); + let (wb, _) = read_xls(&one_sheet_with(&rstring)).unwrap(); + assert_eq!(v(&wb, 0, "A2"), text("rich")); +} + +#[test] +fn the_fixture_writer_reads_back() { + let long = "é".repeat(3000); + let rows = + vec![vec!["Name".to_string(), "Żółw".into(), String::new(), "2.5".into()], vec!["date:45000".into(), long.clone(), long.clone() + "x"]]; + let bytes = craft_xls::testing::workbook(&[("Erste", rows), ("Second", vec![vec!["Name".into()]])]); + assert!(is_xls(&bytes)); + let read = craft_xls::read(&bytes).unwrap(); + assert_eq!(read.warnings, []); + let wb = read.workbook; + assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::>(), ["Erste", "Second"]); + assert_eq!((v(&wb, 0, "A1"), v(&wb, 0, "B1"), v(&wb, 0, "C1"), v(&wb, 0, "D1")), (text("Name"), text("Żółw"), Value::Empty, Value::Number(2.5))); + assert_eq!((v(&wb, 0, "A2"), fmt(&wb, "A2")), (Value::Number(45000.0), "m/d/yyyy".to_string())); + // Two 3,000-character strings do not fit one record: the table continues in a Continue record. + assert_eq!((v(&wb, 0, "B2"), v(&wb, 0, "C2")), (text(&long), text(&(long.clone() + "x")))); + assert_eq!(v(&wb, 1, "A1"), text("Name")); +} + +#[test] +fn cell_sheet_and_merge_caps_are_enforced() { + let number = |r: u16| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()])); + // `one_sheet_with` adds one cell of its own. + let file = one_sheet_with(&cat(&[&number(1), &number(2)])); + assert!(read_limited(&file, Limits { max_cells: 3, ..Limits::default() }).is_ok()); + assert!(matches!(read_limited(&file, Limits { max_cells: 2, ..Limits::default() }), Err(Error::TooLarge(_)))); + + // 4,097 BoundSheet records. + let mut globals = bof(0x0005); + for i in 0..4097u32 { + globals.extend(rec(0x0085, &cat(&[&i.to_le_bytes(), &[0, 0, 1, 0], b"S"]))); + } + globals.extend(eof()); + assert!(matches!(read_xls(&compound("Workbook", &globals)), Err(Error::TooLarge(_)))); + + // 100,001 merged ranges, 1,000 to a record. + let merges = |n: u16| { + (0..n).fold(u16s(&[n]), |mut p, i| { + p.extend(u16s(&[i, i, 0, 1])); + p + }) + }; + let mut records: Vec = (0..100).flat_map(|_| rec(0x00E5, &merges(1000))).collect(); + assert_eq!(read_xls(&one_sheet_with(&records)).unwrap().0.sheets[0].merges.len(), 100_000); + records.extend(rec(0x00E5, &merges(1))); + assert!(matches!(read_xls(&one_sheet_with(&records)), Err(Error::TooLarge(_)))); +} + +/// The sheet a workbook was saved on (Window1's `itabCur`) is counted over all the file's +/// sheets, skipped ones included. +#[test] +fn reports_the_sheet_it_was_saved_on() { + // BoundSheet: position, visibility (0 visible, 1 hidden), kind, name. + let boundsheet = |pos: u32, state: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[state, 0, name.len() as u8, 0], name.as_bytes()])); + let window1 = |tab: u16| rec(0x003D, &u16s(&[0, 0, 0x4000, 0x2000, 0x0038, tab, 0, 1, 600])); + let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &eof()]); + let worksheet = |n: f64| cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &n.to_le_bytes()])), &eof()]); + // Dialog1 (skipped), Sheet1, Sheet2, Hidden (hidden). + let book = |window: &[u8]| { + let globals = |p: [u32; 4]| { + cat(&[ + &bof(0x0005), + window, + &boundsheet(p[0], 0, "Dialog1"), + &boundsheet(p[1], 0, "Sheet1"), + &boundsheet(p[2], 0, "Sheet2"), + &boundsheet(p[3], 1, "Hidden"), + &eof(), + ]) + }; + let (d, s1, s2, h) = (dialog.clone(), worksheet(1.0), worksheet(2.0), worksheet(3.0)); + let base = globals([0; 4]).len() as u32; + let p = [base, base + d.len() as u32, base + (d.len() + s1.len()) as u32, base + (d.len() + s1.len() + s2.len()) as u32]; + let stream = cat(&[&globals(p), &d, &s1, &s2, &h]); + let (wb, _) = read_xls(&compound("Workbook", &stream)).unwrap(); + wb.active_sheet.map(|i| wb.sheets[i].name.clone()) + }; + assert_eq!(book(&window1(2)).as_deref(), Some("Sheet2"), "itabCur 2 is Sheet2, counting the skipped dialog sheet"); + assert_eq!(book(&window1(1)).as_deref(), Some("Sheet1")); + assert_eq!(book(&window1(3)).as_deref(), Some("Hidden"), "a hidden sheet is reported; the caller falls back"); + assert_eq!(book(&window1(0)), None, "a skipped sheet"); + assert_eq!(book(&window1(99)), None, "an index past the sheets"); + assert_eq!(book(&[]), None, "no window record"); + assert_eq!(book(&rec(0x003D, &u16s(&[0, 0, 0, 0, 0]))), None, "a window record too short for the index"); + // Only the first window counts. + assert_eq!(book(&cat(&[&window1(1), &window1(2)])).as_deref(), Some("Sheet1")); +} diff --git a/crates/xls/tests/styles.rs b/crates/xls/tests/styles.rs new file mode 100644 index 00000000..b948fd9c --- /dev/null +++ b/crates/xls/tests/styles.rs @@ -0,0 +1,335 @@ +//! Cell formatting: Font, XF, Palette and Style records. Every workbook is assembled here from +//! the [MS-XLS] structures; no Excel-produced file is used. + +mod common; + +use common::{at, bof, compound, eof, read_xls, rec, u16s, v}; +use craft_xls::{BorderLine, BorderStyle, CellFormat, Color, DEFAULT_PALETTE, Fill, HAlign, Pattern, Script, Underline, VAlign, Value, Workbook}; + +/// The format of an unformatted cell: the Normal font, everything else at its default. +fn normal(wb: &Workbook) -> CellFormat { + CellFormat { font: wb.normal_font.clone().unwrap_or_default(), ..CellFormat::default() } +} + +/// The format index stored on the cell at `a1` (`None`: plain, or not stored). +fn format_index(wb: &Workbook, a1: &str) -> Option { + wb.sheets[0].cells.get(&at(a1)).and_then(|c| c.format) +} + +fn style_at(wb: &Workbook, a1: &str) -> CellFormat { + format_index(wb, a1).map_or_else(|| normal(wb), |i| wb.formats[usize::from(i)].clone()) +} + +fn solid(fg: Color) -> Fill { + Fill { pattern: Pattern::Solid, fg, bg: Color::Auto } +} + +/// Font: height in twips, flags, colour, weight, script, underline, then a UTF-16 name. +fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u8, name: &str) -> Vec { + let mut p = u16s(&[twips, flags, icv, weight, script]); + p.extend_from_slice(&[underline, 0, 0, 0]); + p.push(name.encode_utf16().count() as u8); + p.push(1); + p.extend(u16s(&name.encode_utf16().collect::>())); + rec(0x0031, &p) +} + +/// Sets the family and character-set bytes of a Font record built by `font` (after the 4-byte +/// record header, 10 bytes of fields and the underline byte). +fn with_family_charset(mut font_record: Vec, family: u8, charset: u8) -> Vec { + font_record[15] = family; + font_record[16] = charset; + font_record +} + +/// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. +#[derive(Clone, Copy, Default)] +struct XfData { + alc: u8, + wrap: bool, + alc_v: u8, + trot: u8, + indent: u8, + shrink: bool, + /// dgLeft, dgRight, dgTop, dgBottom, dgDiag. + dg: [u32; 5], + /// icvLeft, icvRight, icvTop, icvBottom, icvDiag. + icv: [u32; 5], + diag: u32, + fls: u32, + fore: u16, + back: u16, +} + +fn xf(ifnt: u16, ifmt: u16, flags: u16, d: XfData) -> Vec { + let mut p = u16s(&[ifnt, ifmt, flags]); + p.push(d.alc | (u8::from(d.wrap) << 3) | (d.alc_v << 4)); + p.push(d.trot); + p.push(d.indent | (u8::from(d.shrink) << 4)); + p.push(0xFC); // fAtr* bits: no bearing on the values read + let b1 = d.dg[0] | (d.dg[1] << 4) | (d.dg[2] << 8) | (d.dg[3] << 12) | (d.icv[0] << 16) | (d.icv[1] << 23) | (d.diag << 30); + let b2 = d.icv[2] | (d.icv[3] << 7) | (d.icv[4] << 14) | (d.dg[4] << 21) | (d.fls << 26); + p.extend_from_slice(&b1.to_le_bytes()); + p.extend_from_slice(&b2.to_le_bytes()); + p.extend_from_slice(&(d.fore | (d.back << 7)).to_le_bytes()); + rec(0x00E0, &p) +} + +/// What Excel writes for unformatted cells: bottom aligned, automatic fill colours. +fn plain() -> XfData { + XfData { alc_v: 2, fore: 0x40, back: 0x41, ..XfData::default() } +} + +/// Locked, a cell XF inheriting from style XF 0. +const CELL: u16 = 0x0001; +/// Locked, a style XF with no parent. +const STYLE: u16 = 0xFFF5; + +fn number(r: u16, c: u16, ixfe: u16, n: f64) -> Vec { + let mut p = u16s(&[r, c, ixfe]); + p.extend_from_slice(&n.to_le_bytes()); + rec(0x0203, &p) +} + +fn blank(r: u16, c: u16, ixfe: u16) -> Vec { + rec(0x0201, &u16s(&[r, c, ixfe])) +} + +/// A one-sheet workbook: `formatting` records in the globals, `cells` in the sheet. +fn workbook(formatting: &[Vec], cells: &[Vec]) -> Vec { + let boundsheet = |pos: u32| { + let mut p = pos.to_le_bytes().to_vec(); + p.extend_from_slice(&[0, 0, 6, 0]); + p.extend_from_slice(b"Sheet1"); + rec(0x0085, &p) + }; + let globals = |pos: u32| [vec![bof(0x0005)], formatting.to_vec(), vec![boundsheet(pos), eof()]].concat().concat(); + let sheet = [vec![bof(0x0010)], cells.to_vec(), vec![eof()]].concat().concat(); + let len = globals(0).len() as u32; + [globals(len), sheet].concat() +} + +/// Four default fonts (Arial 10 and its bold/italic variants), as Excel writes them. +fn default_fonts() -> Vec> { + vec![ + font(200, 0, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 0, 0x7FFF, 700, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 700, 0, 0, "Arial"), + ] +} + +/// Fonts, a palette, style XFs, cell XFs and named styles covering every decoded field. +fn formatted() -> Vec { + let mut f = default_fonts(); + // ifnt 5 (index 4 is never written): 14 pt italic, struck, bold, double underline, + // superscript, red (colour 10). + f.push(font(280, 0x000A, 10, 700, 1, 2, "Courier New")); + // ifnt 6: single accounting underline, subscript, a custom palette colour. + f.push(font(220, 0, 9, 400, 2, 0x21, "Times New Roman")); + // Palette: the first two of the 56 custom entries (colour indexes 8 and 9) replaced. + let mut pal = u16s(&[56]); + for (i, c) in DEFAULT_PALETTE.iter().enumerate().skip(8) { + let c = match i { + 8 => 0x123456, + 9 => 0xABCDEF, + _ => *c, + }; + pal.extend_from_slice(&[(c >> 16) as u8, (c >> 8) as u8, c as u8, 0]); + } + f.push(rec(0x0092, &pal)); + f.push(rec(0x041E, &[&164u16.to_le_bytes()[..], &[4u8, 0, 0][..], &b"0.0%"[..]].concat())); + // XF 0: the Normal style. + f.push(xf(0, 0, STYLE, plain())); + // XF 1: everything at once. + f.push(xf( + 5, + 164, + 0x0002, // hidden, not locked + XfData { + alc: 2, + wrap: true, + alc_v: 1, + trot: 135, + indent: 3, + shrink: true, + dg: [1, 0, 7, 2, 3], + icv: [9, 0, 8, 0x40, 10], + diag: 3, + fls: 1, + fore: 8, + back: 0x41, + }, + )); + // XF 2: a 12.5% gray pattern on yellow, font index 4 (invalid: the default font). + f.push(xf(4, 0, CELL, XfData { alc: 7, alc_v: 4, trot: 255, fls: 0x11, fore: 10, back: 13, ..plain() })); + // XF 3: a font index past the end, right aligned, diagonal-up only. + f.push(xf(200, 0, CELL, XfData { alc: 3, alc_v: 0, trot: 90, dg: [0, 13, 0, 0, 5], icv: [0, 12, 0, 0, 16], diag: 2, ..plain() })); + // XF 4: an ordinary cell: the default style. + f.push(xf(0, 0, CELL, plain())); + // XF 5: font ifnt 6. + f.push(xf(6, 0, CELL, plain())); + // Styles: built-in Normal, a user style on XF 1 and a duplicate name (ignored), a built-in + // style (Comma) left out. + f.push(rec(0x0293, &[&u16s(&[0x8000])[..], &[0u8, 0xFF][..]].concat())); + f.push(rec(0x0293, &[&u16s(&[1, 5])[..], &[0u8][..], &b"Fancy"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[2, 5])[..], &[0u8][..], &b"FANCY"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[0x8002])[..], &[3u8, 0xFF][..]].concat())); + workbook( + &f, + &[ + number(0, 0, 1, 0.5), + blank(0, 1, 1), + number(1, 0, 2, 2.0), + number(2, 0, 3, 3.0), + number(3, 0, 4, 4.0), + number(4, 0, 999, 5.0), + number(5, 0, 5, 6.0), + blank(6, 0, 4), + ], + ) +} + +#[test] +fn reads_fonts_fills_borders_alignment_and_protection() { + let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + reads_primary_style(&wb); + reads_fallback_styles(&wb); + reads_cell_styles(&wb); +} + +fn reads_primary_style(wb: &Workbook) { + // The Normal style's font is the workbook default. + let normal = normal(wb); + assert_eq!((normal.font.name.as_deref(), normal.font.size, normal.font.bold), (Some("Arial"), Some(10.0), false)); + assert_eq!(normal.font.color, Color::Auto); + + let s = style_at(wb, "A1"); + let f = &s.font; + assert_eq!((f.name.as_deref(), f.size), (Some("Courier New"), Some(14.0))); + assert!(f.bold && f.italic && f.strike); + assert_eq!((f.underline, f.script, f.color), (Underline::Double, Script::Superscript, Color::Rgb(0xFF0000))); + assert_eq!(s.number_format, "0.0%"); + assert_eq!((s.locked, s.hidden), (false, true)); + assert_eq!((s.align.h, s.align.v, s.align.wrap, s.align.shrink), (HAlign::Center, VAlign::Center, true, true)); + assert_eq!((s.align.rotation, s.align.indent), (-45, 3)); + // Colour index 8 and 9 come from the Palette record. + assert_eq!(s.fill, solid(Color::Rgb(0x123456))); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Rgb(0xABCDEF) }); + assert_eq!(s.borders.right, BorderLine::default()); + assert_eq!(s.borders.top, BorderLine { style: BorderStyle::Hair, color: Color::Rgb(0x123456) }); + assert_eq!(s.borders.bottom, BorderLine { style: BorderStyle::Medium, color: Color::Auto }); + let dashed = BorderLine { style: BorderStyle::Dashed, color: Color::Rgb(0xFF0000) }; + assert_eq!((s.borders.diag_down, s.borders.diag_up), (dashed, dashed)); + // A formatted blank cell keeps its formatting. + assert_eq!(format_index(wb, "B1"), Some(1)); + assert!(wb.sheets[0].cells.contains_key(&at("B1"))); + assert_eq!(v(wb, 0, "B1"), Value::Empty); +} + +fn reads_fallback_styles(wb: &Workbook) { + let normal = normal(wb); + let s = style_at(wb, "A2"); + assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); + assert_eq!(s.fill, Fill { pattern: Pattern::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); + + let s = style_at(wb, "A3"); + assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); + assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); + assert_eq!(s.borders.diag_up, BorderLine { style: BorderStyle::Thick, color: Color::Rgb(0x800000) }); + assert_eq!(s.borders.diag_down, BorderLine::default()); + + // An ordinary cell XF is the default style; so is an XF index past the end. + assert_eq!((format_index(wb, "A4"), v(wb, 0, "A4")), (None, Value::Number(4.0))); + assert_eq!((format_index(wb, "A5"), v(wb, 0, "A5")), (None, Value::Number(5.0))); + // A blank cell in the default style isn't stored. + assert!(!wb.sheets[0].cells.contains_key(&at("A7"))); +} + +fn reads_cell_styles(wb: &Workbook) { + let s = style_at(wb, "A6"); + assert_eq!((s.font.name.as_deref(), s.font.size, s.font.color), (Some("Times New Roman"), Some(11.0), Color::Rgb(0xABCDEF))); + assert_eq!((s.font.underline, s.font.script), (Underline::SingleAccounting, Script::Subscript)); + + // User-defined styles are kept (first of a name wins); built-in ones are left out. + assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); + assert_eq!(wb.cell_styles[0].0, "Fancy"); + assert_eq!(wb.cell_styles[0].1, style_at(wb, "A1")); +} + +#[test] +fn font_family_and_charset_are_kept() { + let mut f = default_fonts(); + // ifnt 5: family 3 (modern) and character set 0xEE (Eastern European). + f.push(with_family_charset(font(200, 0, 0x7FFF, 400, 0, 0, "Courier New"), 3, 0xEE)); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(5, 0, CELL, plain())); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0)]))).unwrap(); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_deref(), s.font.family, s.font.charset), (Some("Courier New"), Some(3), Some(0xEE))); +} + +#[test] +fn without_a_palette_the_default_colours_apply() { + let mut f = default_fonts(); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 63, ..plain() })); + // Out-of-range colour indexes are automatic. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 0x7F, dg: [1, 0, 0, 0, 0], icv: [0x50, 0, 0, 0, 0], ..plain() })); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]))).unwrap(); + assert_eq!(style_at(&wb, "A1").fill, solid(Color::Rgb(0x000000))); + assert_eq!(style_at(&wb, "A2").fill, solid(Color::Rgb(0x333333))); + let s = style_at(&wb, "A3"); + assert_eq!(s.fill, solid(Color::Auto)); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Auto }); +} + +#[test] +fn short_formatting_records_keep_what_is_there() { + let mut f = default_fonts(); + // A truncated font (no name): the default font keeps the numbering of the next one. + f.push(rec(0x0031, &u16s(&[240, 0]))); + f.push(font(300, 0, 0x7FFF, 700, 0, 0, "Georgia")); + // A palette claiming more entries than it holds. + f.push(rec(0x0092, &[&u16s(&[56])[..], &[1u8, 2, 3, 0][..]].concat())); + f.push(xf(0, 0, STYLE, plain())); + // XF 1: only ifnt and ifmt and flags: number format kept, no alignment. + f.push(rec(0x00E0, &u16s(&[6, 10, 0x0001]))); + // XF 2: two bytes. + f.push(rec(0x00E0, &u16s(&[6]))); + // XF 3: palette colour 8 (overridden) as a fill. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + // Style records cut short, and a style naming a missing XF. + f.push(rec(0x0293, &u16s(&[0x8000]))); + f.push(rec(0x0293, &[&u16s(&[1, 40])[..], &[0u8][..], &b"Cut"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[900, 4])[..], &[0u8][..], &b"Gone"[..]].concat())); + let book = workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]); + let (wb, report) = read_xls(&compound("Workbook", &book)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_deref(), s.font.bold), (Some("Georgia"), true)); + assert_eq!(s.number_format, "0.00%"); + assert_eq!((s.align.v, s.locked), (VAlign::Bottom, true)); + assert_eq!((format_index(&wb, "A2"), v(&wb, 0, "A2")), (None, Value::Number(1.0))); + assert_eq!(style_at(&wb, "A3").fill, solid(Color::Rgb(0x010203))); + assert!(wb.cell_styles.is_empty()); +} + +#[test] +fn hostile_formatting_records_never_panic() { + let stream = formatted(); + for at in 0..stream.len() { + for x in [0x00u8, 0xFF, 0x80] { + let mut s = stream.clone(); + s[at] = x; + let _ = read_xls(&compound("Workbook", &s)); + } + } + for cut in 0..stream.len() { + let _ = read_xls(&compound("Workbook", &stream[..cut])); + } +}