From bd5c5c1d261e14f82ac6aecc6a657774774d96fe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:09:27 +0200 Subject: [PATCH 1/2] XLSX/IO: read legacy .xls (BIFF8) workbooks (#121) Clean-room import of Excel 97-2003 workbooks from the public [MS-CFB] and [MS-XLS] specifications: compound-file streams (FAT, mini FAT, directory tree), the BIFF8 Workbook stream with Continue records, shared strings, all cell records, formulas decoded from Ptg tokens (shared and array formulas, 3-D refs, names, add-in/newer functions by name) with cached results, number formats, the 1904 date system, merges, column widths, row heights, defined names and print areas/titles. Encrypted (FILEPASS) and BIFF5 files are refused with a clear message; everything else is skipped with a warning. .xls opens as a new unsaved workbook (Save As .xlsx). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + ROADMAP.md | 1 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 3 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/io.rs | 20 +- crates/engine/src/tests.rs | 85 ++++ crates/mcp/src/tools.rs | 2 +- crates/xlsx/src/cfb.rs | 216 ++++++++ crates/xlsx/src/lib.rs | 20 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/xls_tests.rs | 439 +++++++++++++++++ crates/xlsx/src/xls/formula.rs | 496 +++++++++++++++++++ crates/xlsx/src/xls/ftab.rs | 376 ++++++++++++++ crates/xlsx/src/xls/mod.rs | 765 +++++++++++++++++++++++++++++ crates/xlsx/src/xls/records.rs | 198 ++++++++ docs/cli.md | 5 + docs/file-format-parity.md | 11 +- docs/gaps.md | 10 +- 19 files changed, 2635 insertions(+), 23 deletions(-) create mode 100644 crates/xlsx/src/tests/xls_tests.rs create mode 100644 crates/xlsx/src/xls/formula.rs create mode 100644 crates/xlsx/src/xls/ftab.rs create mode 100644 crates/xlsx/src/xls/mod.rs create mode 100644 crates/xlsx/src/xls/records.rs diff --git a/README.md b/README.md index 770b3fe..db6a5bf 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, PivotTables and print settings round-trip. CSV and TSV too. + Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..f961ed4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd..f4f4e3f 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -109,7 +109,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let Some(file) = rfd::AsyncFileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .pick_file() .await else { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3c73a95..1495350 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -126,8 +126,9 @@ fn services() -> Services { })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) + .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) .add_filter("Excel Workbook", &["xlsx", "xlsm"]) + .add_filter("Excel 97-2003 Workbook (import)", &["xls"]) .add_filter("Excel Binary Workbook (data import)", &["xlsb"]) .add_filter("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b..8837ea6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and ods data import, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -47,8 +47,9 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; - let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb)) - || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb); + let imported = + matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) + || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls); let title = if imported { format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book")) } else { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 25dec98..52ee2d1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,4 +1,4 @@ -//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. +//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export. use std::fmt::Write as _; @@ -11,6 +11,8 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + /// Excel 97-2003 workbook (import only). + Xls, Xlsb, Ods, Csv, @@ -24,6 +26,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "xls" | "xlt" => FileKind::Xls, "xlsb" => FileKind::Xlsb, "ods" => FileKind::Ods, "csv" => FileKind::Csv, @@ -45,9 +48,13 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { "'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again." ))); } + if sniffed == gridcraft_xlsx::Format::Xls { + let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; + return Ok((wb, report.warnings)); + } if sniffed == gridcraft_xlsx::Format::LegacyBinary { return Err(EngineError::Other(format!( - "'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again." + "'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it." ))); } // Content wins over the extension: an ODS or XLSB package is imported as what it is, @@ -107,6 +114,9 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) { FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())), + FileKind::Xls => { + Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into())) + } FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()), @@ -388,10 +398,10 @@ mod tests { } #[test] - fn legacy_xls_is_not_called_encrypted() { - let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err(); + fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}"); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); assert!(!msg.contains("password"), "got: {msg}"); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..df8b142 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1242,6 +1242,91 @@ fn xlsb_import_never_reuses_source_as_save_target() { std::fs::remove_dir_all(dir).unwrap(); } +/// A one-sheet Excel 97-2003 workbook ([MS-CFB] container, [MS-XLS] BIFF8 stream) built in code: +/// A1 = 42 and A2 = `A1*2` with its cached result. +fn xls_fixture() -> Vec { + fn rec(id: u16, payload: &[u8]) -> Vec { + [&id.to_le_bytes()[..], &(payload.len() as u16).to_le_bytes(), payload].concat() + } + let bof = |dt: u16| rec(0x0809, &[&0x0600u16.to_le_bytes()[..], &dt.to_le_bytes(), &[0; 12]].concat()); + let sheet = [ + bof(0x0010), + rec(0x0203, &[&[0u8, 0, 0, 0, 0, 0][..], &42f64.to_le_bytes()].concat()), + rec(0x0006, &[&[1u8, 0, 0, 0, 0, 0][..], &84f64.to_le_bytes(), &[0; 6], &[9, 0, 0x44, 0, 0, 0, 0xC0, 0x1E, 2, 0, 0x05]].concat()), + rec(0x000A, &[]), + ] + .concat(); + let globals = |pos: u32| [bof(0x0005), rec(0x0085, &[&pos.to_le_bytes()[..], &[0, 0, 3, 0], b"Old"].concat()), rec(0x000A, &[])].concat(); + let stream = [globals(globals(0).len() as u32), sheet].concat(); + // Sectors: 0 FAT, 1 directory, 2 mini FAT, 3 mini stream container. + let mut b = vec![0u8; 512 * 5]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(26, &3u16.to_le_bytes()); + put(30, &9u16.to_le_bytes()); + put(32, &6u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(56, &4096u32.to_le_bytes()); + put(60, &2u32.to_le_bytes()); + put(64, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + for (i, v) in [0xFFFF_FFFDu32, 0xFFFF_FFFE, 0xFFFF_FFFE, 0xFFFF_FFFE].iter().enumerate() { + put(512 + i * 4, &v.to_le_bytes()); + } + for i in 4..128 { + put(512 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + let minis = stream.len().div_ceil(64); + for i in 0..128 { + let next = if i + 1 < minis { + i as u32 + 1 + } else if i + 1 == minis { + 0xFFFF_FFFE + } else { + 0xFFFF_FFFF + }; + put(512 * 3 + i * 4, &next.to_le_bytes()); + } + let entry = |k: usize, name: &str, kind: u8, child: u32, start: u32, size: u32, put: &mut dyn FnMut(usize, &[u8])| { + let at = 512 * 2 + k * 128; + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(at, &units); + put(at + 64, &(units.len() as u16 + 2).to_le_bytes()); + put(at + 66, &[kind, 1]); + put(at + 68, &[0xFF; 8]); + put(at + 76, &child.to_le_bytes()); + put(at + 116, &start.to_le_bytes()); + put(at + 120, &size.to_le_bytes()); + }; + entry(0, "Root Entry", 5, 1, 3, 512, &mut put); + entry(1, "Workbook", 2, 0xFFFF_FFFF, 0, stream.len() as u32, &mut put); + put(512 * 4, &stream); + b +} + +#[test] +fn xls_import_keeps_formulas_and_never_saves_back_to_xls() { + let bytes = xls_fixture(); + assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xls); + let mut s = s(); + let r = s.execute("file.open", json!({"name": "legacy.xls", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains("97-2003")); + assert_eq!(r["sheets"], json!(["Old"])); + let d = s.doc().unwrap(); + assert!(d.path.is_none()); + assert_eq!(d.display_title(), "legacy (imported).xlsx"); + assert_eq!(v(&s, "A2"), Value::Number(84.0)); + // The formula is live: editing its input recalculates it. + s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(100.0)); + assert!(s.execute("file.saveBytes", json!({"format": "xls"})).is_err()); + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} + #[test] fn malformed_xlsb_keeps_the_current_workbook() { let mut s = s(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d5d5c36..eb741b8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "open_workbook", "Open workbook", - "Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", + "Open a workbook file (.xlsx, .xlsm, .xls, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. Excel 97-2003 .xls imports values, formulas and number formats; XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.", obj(json!({"path": string("File path")}), &["path"]), false, ), diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 252096d..3a74718 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -97,6 +97,222 @@ fn directory_names(b: &[u8]) -> Option> { Some(names) } +/// Directory entry type of a stream object ([MS-CFB] 2.6.1). +const STREAM_OBJECT: u8 = 2; +/// "No entry" in the directory tree (NOSTREAM). +const NO_STREAM: u32 = 0xFFFF_FFFF; +/// Streams larger than this are refused (the format allows more, a workbook never needs it). +const MAX_STREAM: u64 = 1 << 30; + +/// One directory entry ([MS-CFB] 2.6). +struct DirEntry { + name: String, + kind: u8, + left: u32, + right: u32, + child: u32, + start: u32, + size: u64, +} + +/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked. +/// Implemented from Microsoft's public [MS-CFB] specification. +pub(crate) struct Compound<'a> { + bytes: &'a [u8], + sector_size: usize, + mini_sector_size: usize, + mini_cutoff: u64, + fat: Vec, + mini_fat: Vec, + entries: Vec, +} + +impl<'a> Compound<'a> { + /// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed. + pub(crate) fn open(b: &'a [u8]) -> Result, String> { + let bad = |m: &str| format!("damaged compound file: {m}"); + if !b.starts_with(&SIGNATURE) { + return Err(bad("missing signature")); + } + let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?; + let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?; + if shift != 9 && shift != 12 { + return Err(bad("unsupported sector size")); + } + let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?; + if mini_shift != 6 { + return Err(bad("unsupported mini sector size")); + } + let sector_size = 1usize << shift; + let per = sector_size / 4; + // Sectors that physically exist after the header sector: every chain is bounded by it. + let sectors = (b.len() / sector_size).saturating_sub(1); + let sector = |id: u32| -> Option<&[u8]> { + let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?; + b.get(start..start.checked_add(sector_size)?) + }; + // DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors. + let mut fat_sectors: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect(); + let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?; + let mut hops = 0usize; + while next < MAX_REG_SECT { + hops += 1; + if hops > sectors.min(MAX_DIFAT_SECTORS * 64) { + return Err(bad("DIFAT chain loops")); + } + let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?; + fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT)); + next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?; + } + if fat_sectors.len() > sectors { + return Err(bad("more FAT sectors than the file holds")); + } + let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per)); + for id in fat_sectors { + let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?; + fat.extend((0..per).filter_map(|i| u32_at(s, i * 4))); + } + let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] }; + if let Some(c) = u32_at(b, 56) { + cf.mini_cutoff = u64::from(c); + } + // Directory. + let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?; + let dir = cf.read_chain(dir_start, u64::MAX)?; + for e in dir.as_chunks::<128>().0 { + let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64); + let units: Vec = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect(); + let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0); + if major >= 4 { + size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32; + } + cf.entries.push(DirEntry { + name: String::from_utf16_lossy(&units), + kind: e.get(66).copied().unwrap_or(0), + left: u32_at(e, 68).unwrap_or(NO_STREAM), + right: u32_at(e, 72).unwrap_or(NO_STREAM), + child: u32_at(e, 76).unwrap_or(NO_STREAM), + start: u32_at(e, 116).unwrap_or(END_OF_CHAIN), + size, + }); + } + if cf.entries.first().is_none_or(|r| r.kind != 5) { + return Err(bad("missing root entry")); + } + // Mini FAT. + let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?; + if mini_start < MAX_REG_SECT { + let raw = cf.read_chain(mini_start, u64::MAX)?; + cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect(); + } + Ok(cf) + } + + /// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the + /// FAT (so a cycle) or pointing outside the file is an error. + fn read_chain(&self, start: u32, limit: u64) -> Result, String> { + let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX); + let mut out = Vec::new(); + let mut id = start; + let mut seen = vec![false; self.fat.len()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: sector chain loops".into()), + None => return Err("damaged compound file: sector missing from the FAT".into()), + } + let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size)); + let s = start + .and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?)) + .ok_or("damaged compound file: sector out of range (the file may be truncated)")?; + out.extend_from_slice(s); + id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?; + } + Ok(out) + } + + /// Follows a mini FAT chain inside the mini stream container. + fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result, String> { + let mut out = Vec::new(); + let mut id = start; + let mut seen = vec![false; self.mini_fat.len()]; + while id != END_OF_CHAIN && out.len() < limit { + if id >= MAX_REG_SECT { + return Err("damaged compound file: broken mini sector chain".into()); + } + match seen.get_mut(id as usize) { + Some(v) if !*v => *v = true, + Some(_) => return Err("damaged compound file: mini sector chain loops".into()), + None => return Err("damaged compound file: mini sector missing from the mini FAT".into()), + } + let at = (id as usize).checked_mul(self.mini_sector_size); + let s = + at.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?)).ok_or("damaged compound file: mini sector out of range")?; + out.extend_from_slice(s); + id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?; + } + Ok(out) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + /// The directory entry of the stream called `name` (case-insensitive) directly under the + /// root storage. + fn root_entry(&self, name: &str) -> Result, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + // Walk the root's children (a red-black tree through left/right siblings), bounded. + let mut stack = vec![root.child]; + let mut seen = 0usize; + while let Some(i) = stack.pop() { + if i == NO_STREAM { + continue; + } + seen += 1; + if seen > self.entries.len() { + return Err("damaged compound file: directory tree loops".into()); + } + let Some(e) = self.entries.get(i as usize) else { continue }; + if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) { + return Ok(Some(e)); + } + stack.push(e.left); + stack.push(e.right); + } + Ok(None) + } + + /// Whether the root storage holds a stream called `name`, without reading it. + pub(crate) fn has_root_stream(&self, name: &str) -> bool { + matches!(self.root_entry(name), Ok(Some(_))) + } + + /// The contents of the stream called `name` (case-insensitive) directly under the root + /// storage, or `None` when there is no such stream. + pub(crate) fn root_stream(&self, name: &str) -> Result>, String> { + let Some(root) = self.entries.first() else { return Ok(None) }; + let found = self.root_entry(name)?; + let Some(e) = found else { return Ok(None) }; + if e.size > MAX_STREAM { + return Err("the workbook stream is larger than 1 GiB".into()); + } + let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?; + let mut data = if e.size < self.mini_cutoff { + let container = self.read_chain(root.start, root.size)?; + self.read_mini_chain(&container, e.start, size)? + } else { + self.read_chain(e.start, e.size)? + }; + if data.len() < size { + return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into()); + } + data.truncate(size); + Ok(Some(data)) + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 05d1afc..ce3353a 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,4 +1,5 @@ -//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import. +//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import +//! and legacy Excel 97-2003 `.xls` (BIFF8) import. //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -28,6 +29,7 @@ mod styles; mod tables; mod theme; mod write; +mod xls; mod xlsb; mod xml; @@ -39,6 +41,7 @@ use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; pub use ods::read_ods; +pub use xls::read_xls; pub use xlsb::read_xlsb; /// Errors from reading or writing files. @@ -105,7 +108,9 @@ pub enum Format { Csv, /// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`. Encrypted, - /// Another compound (CFB/OLE2) file: an Excel 97-2003 `.xls` or other legacy Office binary. + /// An Excel 97-2003 `.xls` workbook: a compound file with a `Workbook` (or BIFF5 `Book`) stream. + Xls, + /// Another compound (CFB/OLE2) file: a legacy Office binary that isn't an Excel workbook. LegacyBinary, Unknown, } @@ -122,9 +127,16 @@ pub fn sniff(bytes: &[u8]) -> Format { }; } // CFB/OLE2 compound file: a password-protected workbook when it holds an encryption - // stream, otherwise a legacy binary (.xls, .doc…). + // stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another + // legacy binary (.doc…). if bytes.starts_with(&cfb::SIGNATURE) { - return if cfb::is_encrypted(bytes) { Format::Encrypted } else { Format::LegacyBinary }; + return if cfb::is_encrypted(bytes) { + Format::Encrypted + } else if xls::is_xls(bytes) { + Format::Xls + } else { + Format::LegacyBinary + }; } if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { return Format::Csv; diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 43639fe..e311a50 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -9,6 +9,7 @@ mod pivot_tests; mod richdata_tests; mod roundtrip; mod smooth_roundtrip; +mod xls_tests; mod xlsb_tests; use std::io::Write; diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs new file mode 100644 index 0000000..a150dc0 --- /dev/null +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -0,0 +1,439 @@ +//! Legacy `.xls` (BIFF8) import. Every test file is assembled here from the [MS-CFB] and +//! [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; + +use crate::{Format, IoError, read_xls, read_xlsx, sniff, write_xlsx}; + +const END: u32 = 0xFFFF_FFFE; +const FREE: u32 = 0xFFFF_FFFF; + +fn put32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); +} + +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(crate) 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 +} + +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 +} + +fn cat(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Cell header: row, column, XF index. +fn cell(r: u16, c: u16, xf: u16) -> Vec { + u16s(&[r, c, xf]) +} + +fn bof(dt: u16) -> Vec { + rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]])) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// XLUnicodeString with compressed characters. +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. +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) +} + +fn num(n: f64) -> [u8; 8] { + n.to_le_bytes() +} + +fn special(kind: u8, v: u8) -> [u8; 8] { + [kind, 0, v, 0, 0, 0, 0xFF, 0xFF] +} + +/// PtgRef (value class) with relative row and column. +fn ptg_ref(r: u16, c: u16) -> Vec { + cat(&[&[0x44], &u16s(&[r, c | 0xC000])]) +} + +fn ptg_int(n: u16) -> Vec { + cat(&[&[0x1E], &n.to_le_bytes()]) +} + +fn exp(r: u16, c: u16) -> Vec { + cat(&[&[0x01], &u16s(&[r, c])]) +} + +/// 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]) +} + +fn v(wb: &gridcraft_model::Workbook, sheet: usize, a1: &str) -> Value { + wb.sheets[sheet].value(CellRef::parse(a1).unwrap()) +} + +fn ftext(wb: &gridcraft_model::Workbook, a1: &str) -> Option { + wb.sheets[0].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()) +} + +#[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_eq!(sniff(&bytes), Format::Xls); + let (wb, report) = read_xls(&bytes).unwrap(); + assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings); + 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_eq!(v(&wb, 0, "A1"), Value::from("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"), Value::from("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)); + // 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"), Value::from("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 keeps its value. + let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap(); + assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap()))); + assert_eq!(ftext(&wb, "C6"), None); + assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0)); + // Number formats, merges, sizes, names, print area; embedded chart records ignored. + let sh = &wb.sheets[0]; + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd"); + assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]); + assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width); + assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0)); + assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom); + assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2")); + 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_eq!(wb.date_system, DateSystem::D1900); + // The import saves as .xlsx and reads back. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2"); + assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!")); + } + let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap(); + assert_eq!(wb.date_system, DateSystem::D1904); +} + +#[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()); + let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string(); + assert!(err.contains("password-protected"), "{err}"); + // A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream. + let book = compound("Book", &stream); + assert_eq!(sniff(&book), Format::Xls); + assert!(read_xls(&book).unwrap_err().to_string().contains("BIFF5")); + // Another compound file (a word processor document): not a workbook. + let doc = compound("WordDocument", &[0u8; 100]); + assert_eq!(sniff(&doc), Format::LegacyBinary); + assert!(read_xls(&doc).is_err()); +} + +#[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 _ = sniff(&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(IoError::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); + } +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs new file mode 100644 index 0000000..3fc3fc4 --- /dev/null +++ b/crates/xlsx/src/xls/formula.rs @@ -0,0 +1,496 @@ +//! 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 gridcraft_core::{CellError, CellRef}; +use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp}; + +use super::ftab::FTAB; + +/// 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(super) 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(super) 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(super) 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(super) struct Xti { + pub book: u16, + pub first: i16, + pub last: i16, +} + +/// Workbook-level tables formulas refer to. +#[derive(Default)] +pub(super) 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, + pub books: Vec, + pub xti: Vec, +} + +pub(super) 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(super) 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() +} + +fn pop(stack: &mut Vec) -> Result { + stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +} + +fn pop_n(stack: &mut Vec, n: usize) -> Result, Unsupported> { + let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; + Ok(stack.split_off(at)) +} + +/// 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(super) 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: Vec = Vec::new(); + while t.pos < rgce.len() { + let ptg = t.u8()?; + // 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)?; + let a = pop(&mut stack)?; + stack.push(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)?; + stack.push(Expr::Unary(op, Box::new(x))); + } + 0x15 => { + let x = pop(&mut stack)?; + stack.push(Expr::Paren(Box::new(x))); + } + 0x16 => stack.push(Expr::Missing), + 0x17 => { + let cch = usize::from(t.u8()?); + let high = t.u8()? & 1 != 0; + stack.push(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)?; + stack.push(Expr::Call("SUM".into(), vec![x])); + } + // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. + } + 0x1C => { + let e = t.u8()?; + stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + } + 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), + 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 0x1F => { + let n = t.f64()?; + if !n.is_finite() { + return Err(Unsupported("invalid number constant")); + } + stack.push(Expr::Number(n)); + } + 0x20 => { + t.take(7)?; + stack.push(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)?; + stack.push(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)?; + 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")), + }; + stack.push(Expr::Call(name, args)); + } else { + let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; + stack.push(Expr::Call(name.to_string(), args)); + } + } + 0x23 => { + let i = t.u32()?; + let name = usize::try_from(i) + .ok() + .and_then(|i| i.checked_sub(1)) + .and_then(|i| links.names.get(i)) + .filter(|n| !n.is_empty()) + .ok_or(Unsupported("reference to a missing defined name"))?; + stack.push(Expr::Name(name.clone())); + } + 0x24 | 0x2C => { + let (r, c) = (t.u16()?, t.u16()?); + let a = anchor(r, c, base, id == 0x2C); + stack.push(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); + stack.push(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)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x2B => { + t.take(8)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x39 => { + let ixti = t.u16()?; + let i = t.u32()?; + stack.push(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); + stack.push(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); + stack.push(match links.sheet_sel(ixti)? { + Some(sheet) => Expr::Ref(Reference { sheet, kind }), + None => Expr::Error(CellError::Ref), + }); + } + 0x3C => { + t.take(6)?; + stack.push(Expr::Error(CellError::Ref)); + } + 0x3D => { + t.take(10)?; + stack.push(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 = pop(&mut stack)?; + 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()], + 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| gridcraft_formula::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()); + } +} diff --git a/crates/xlsx/src/xls/ftab.rs b/crates/xlsx/src/xls/ftab.rs new file mode 100644 index 0000000..d796419 --- /dev/null +++ b/crates/xlsx/src/xls/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/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs new file mode 100644 index 0000000..b070478 --- /dev/null +++ b/crates/xlsx/src/xls/mod.rs @@ -0,0 +1,765 @@ +//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public +//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream): +//! . +//! +//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including +//! shared and array formulas), number formats, the 1904 date system, merged cells, column +//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, +//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is +//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. + +mod formula; +mod ftab; +mod records; + +use std::collections::HashMap; +use std::sync::Arc; + +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; +use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; + +use crate::cfb::Compound; +use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::{IoError, ReadReport}; +use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; +use records::{Cursor, Record, Records}; + +const MAX_CELLS: usize = 4_000_000; +const MAX_STRINGS: usize = 4_000_000; +const MAX_TEXT: usize = 256 * 1024 * 1024; +const MAX_SHEETS: usize = 4096; +const MAX_NAMES: usize = 65_536; +const MAX_XFS: usize = 65_536; +const MAX_MERGES: usize = 100_000; +/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +const MAX_RECORDS: usize = 40_000_000; + +const BOF: u16 = 0x0809; +const EOF: u16 = 0x000A; +const BIFF8: u16 = 0x0600; + +pub(crate) fn invalid(msg: impl Into) -> IoError { + IoError::Format(format!("XLS: {}", msg.into())) +} + +/// The message for an encrypted workbook (a FilePass record). +const ENCRYPTED: &str = + "this .xls workbook is password-protected (encrypted). GridCraft can't open encrypted .xls files; remove the password in Excel and try again."; + +/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook` +/// or BIFF5 `Book`). +pub(crate) fn is_xls(bytes: &[u8]) -> bool { + let Ok(cf) = Compound::open(bytes) else { return false }; + cf.has_root_stream("Workbook") || cf.has_root_stream("Book") +} + +#[derive(Default)] +struct Budget { + cells: usize, + text: usize, +} + +impl Budget { + fn cell(&mut self) -> Result<(), IoError> { + self.cells += 1; + if self.cells > MAX_CELLS { + return Err(IoError::TooLarge("XLS: more than 4 million cells".into())); + } + Ok(()) + } + fn text(&mut self, len: usize) -> Result<(), IoError> { + self.text = self.text.saturating_add(len); + if self.text > MAX_TEXT { + return Err(IoError::TooLarge("XLS: more than 256 MB of text".into())); + } + 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, + xf_formats: Vec, + sst: Vec>, + links: Links, + names: Vec, +} + +/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. +pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + let cf = Compound::open(bytes).map_err(invalid)?; + let stream = match cf.root_stream("Workbook").map_err(invalid)? { + Some(s) => s, + None => { + if cf.has_root_stream("Book") { + return Err(invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again.")); + } + return Err(invalid("the file has no Workbook stream, so it is not an Excel workbook")); + } + }; + let mut report = ReadReport::default(); + let mut budget = Budget::default(); + let g = globals(&stream, &mut budget)?; + + let mut wb = Workbook::new(); + wb.sheets.clear(); + wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; + // One model style per XF: only the number format is imported. + let styles: Vec = g + .xf_formats + .iter() + .map(|ifmt| { + let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { + StyleId::DEFAULT + } else { + wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) + } + }) + .collect(); + + // BoundSheet8 index → model sheet index. + let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); + for bs in &g.sheets { + if bs.kind != 0 { + report.warn(match bs.kind { + 2 => "Chart sheets in .xls files are not imported yet and were skipped.", + 1 => "Excel 4.0 macro sheets were skipped.", + _ => "VBA modules and other non-worksheet sheets were skipped.", + }); + model_index.push(None); + continue; + } + wb.check_sheet_name(&bs.name, None).map_err(invalid)?; + let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + report.warn("Dialog sheets and unreadable sheets were skipped."); + model_index.push(None); + continue; + }; + sheet.visibility = bs.visibility; + model_index.push(Some(wb.sheets.len())); + wb.sheets.push(Arc::new(sheet)); + } + if wb.sheets.is_empty() { + return Err(invalid("the workbook has no worksheets GridCraft can read")); + } + defined_names(&g, &model_index, &mut wb, &mut report); + wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + // The overview comes first. + report.warnings.insert( + 0, + "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + ); + Ok((wb, report)) +} + +fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> { + 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()?)) +} + +fn globals(stream: &[u8], budget: &mut Budget) -> 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(invalid(if vers == 0x0500 { + "Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again." + } else { + "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 count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + let mut c = Cursor::new(&rec.parts); + match rec.id { + EOF => break, + 0x002F => return Err(invalid(ENCRYPTED)), + 0x0085 => { + if g.sheets.len() >= MAX_SHEETS { + return Err(IoError::TooLarge("XLS: too many sheets".into())); + } + let pos = c.u32()? as usize; + 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, + 0x041E => { + let ifmt = c.u16()?; + let code = c.string()?; + budget.text(code.len())?; + g.formats.insert(ifmt, code); + } + 0x00E0 => { + if g.xf_formats.len() < MAX_XFS { + c.u16()?; + g.xf_formats.push(c.u16()?); + } + } + 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); + } + _ => {} + } + } + 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>, IoError> { + let _total = c.u32()?; + let unique = c.u32()? as usize; + if unique > MAX_STRINGS { + return Err(IoError::TooLarge("XLS: 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, + styles: &'g [StyleId], + sheet: Sheet, + pending: Vec, + shared: HashMap, + arrays: HashMap, + last_formula: Option, + pending_string: 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); + } + } + + fn style(&self, ixfe: u16) -> StyleId { + self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT) + } + + fn pos(&mut self, row: u16, col: u16) -> Option { + let (r, c) = (u32::from(row), u32::from(col)); + if r >= MAX_ROWS || c >= MAX_COLS.min(256) { + 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, IoError> { + let Some(pos) = self.pos(row, col) else { return Ok(None) }; + let style = self.style(ixfe); + if value.is_empty() && style == StyleId::DEFAULT { + return Ok(Some(pos)); + } + budget.cell()?; + self.sheet.cells.set(pos, Cell { value, formula: None, style }); + 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) { + Ok(e) => { + let text = gridcraft_formula::print(&e); + let mut f = Formula::new(&text); + if f.expr.is_none() { + self.unsupported("syntax GridCraft can't parse yet"); + return; + } + f.array = array.filter(|r| !r.is_single()); + if let Some(cell) = self.sheet.cells.get_mut(pos) { + cell.formula = Some(Arc::new(f)); + } else { + self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }); + } + } + Err(Unsupported(why)) => self.unsupported(why), + } + } +} + +/// 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 }) +} + +fn worksheet( + stream: &[u8], + bs: &BoundSheet, + g: &Globals, + styles: &[StyleId], + budget: &mut Budget, + report: &mut ReadReport, +) -> Result, IoError> { + 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, + styles, + sheet: Sheet::new(&bs.name), + pending: vec![], + shared: HashMap::new(), + arrays: HashMap::new(), + last_formula: None, + pending_string: None, + dropped: 0, + unsupported: vec![], + }; + let mut depth = 1usize; + let mut count = 0usize; + while let Some(rec) = recs.next_record()? { + count += 1; + if count > MAX_RECORDS { + return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); + } + match rec.id { + BOF => { + // An embedded chart's substream. + depth += 1; + continue; + } + EOF => { + depth -= 1; + if depth == 0 { + break; + } + continue; + } + _ if depth > 1 => continue, + _ => {} + } + sheet_record(&rec, &mut st, budget, report)?; + } + // Shared and array formulas: their definitions follow the first cell that uses them. + let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); + for p in std::mem::take(&mut st.pending) { + if let Some((range, f)) = arrays.get(&p.anchor) { + // The other cells of an array formula keep only their values. + if p.pos == p.anchor { + st.attach(p.pos, f, Mode::Cell, Some(*range)); + } + } else if let Some(f) = shared.get(&p.anchor) { + st.attach(p.pos, f, Mode::Shared, None); + } else { + st.unsupported("a shared formula that is missing from the file"); + } + } + if st.dropped > 0 { + report.warn(format!("{} cells outside the grid were skipped.", st.dropped)); + } + for why in &st.unsupported { + report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); + } + Ok(Some(st.sheet)) +} + +fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { + 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("Some cells refer to missing shared strings and were left empty."), + } + } + 0x0204 => { + 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::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) = st.pending_string.take() { + let s = c.string()?; + budget.text(s.len())?; + if let Some(cell) = st.sheet.cells.get_mut(pos) { + cell.value = Value::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 = RangeRef::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)); + } + } + 0x0236 => report.warn("What-if data tables are not supported; their values were kept."), + 0x00E5 => { + 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) >= 256 { + continue; + } + if st.sheet.merges.len() >= MAX_MERGES { + return Err(IoError::TooLarge("XLS: too many merged ranges".into())); + } + let range = RangeRef::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); + } + } + } + 0x007D => { + let (first, last) = (c.u16()?, c.u16()?); + let width = c.u16()?; + let x = c.u16()?; + let flags = c.u16()?; + let last = last.min(255); + if first > last { + return Ok(()); + } + let mut info = LineInfo::default(); + let px = col_width_to_px(f64::from(width) / 256.0); + if flags & 0x0002 != 0 || px != st.sheet.default_col_width { + info.size = Some(px); + } + info.hidden = flags & 0x0001 != 0; + info.outline = ((flags >> 8) & 7) as u8; + info.collapsed = flags & 0x1000 != 0; + let style = st.style(x); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + if info != LineInfo::default() { + for col in first..=last { + st.sheet.cols.insert(u32::from(col), info); + } + } + } + 0x0208 => { + let r = c.u16()?; + c.skip(4)?; + let height = c.u16()?; + c.skip(4)?; + let flags = c.u32()?; + let mut info = LineInfo { custom: flags & 0x40 != 0, hidden: flags & 0x20 != 0, ..LineInfo::default() }; + let px = pt_to_px(f64::from(height & 0x7FFF) / 20.0); + if info.custom || (px - st.sheet.default_row_height).abs() > 0.5 { + info.size = Some(px); + } + info.outline = (flags & 7) as u8; + info.collapsed = flags & 0x10 != 0; + if flags & 0x80 != 0 { + let style = st.style(((flags >> 16) & 0x0FFF) as u16); + if style != StyleId::DEFAULT { + info.style = Some(style); + } + } + if info != LineInfo::default() && u32::from(r) < MAX_ROWS { + st.sheet.rows.insert(u32::from(r), info); + } + } + 0x0225 => { + let flags = c.u16()?; + let height = c.u16()?; + if flags & 0x0002 == 0 && height > 0 { + st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0); + } + } + 0x001C => report.warn("Cell comments in .xls files are not imported yet."), + 0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."), + 0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."), + 0x01B2 => report.warn("Data validation in .xls files is not imported yet."), + 0x01B8 => report.warn("Hyperlinks in .xls files are not imported yet."), + 0x00B0 => report.warn("PivotTables in .xls files are not imported yet; their values were kept."), + _ => {} + } + Ok(()) +} + +fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), IoError> { + 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::from(""), + _ => Value::Empty, + } + } else { + Value::number(f64::from_le_bytes(val)) + }; + let Some(pos) = st.pos(r, col) else { return Ok(()) }; + budget.cell()?; + let style = st.style(x); + st.sheet.cells.set(pos, Cell { value, formula: None, style }); + st.last_formula = Some(pos); + if string_result { + st.pending_string = Some(pos); + } + // 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 ReadReport) { + 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) => gridcraft_formula::print(&e), + Err(_) if lbl.rgce.is_empty() => continue, + Err(Unsupported(why)) => { + report.warn(format!("Some defined names use {why} and were skipped.")); + continue; + } + }; + match lbl.builtin { + Some(6 | 7) => { + let Some(si) = scope else { continue }; + let Some(sheet) = wb.sheet_mut(si) else { continue }; + if lbl.builtin == Some(6) { + sheet.print.print_area = crate::read::parse_print_area(&text); + } else { + let (rows, cols) = crate::read::parse_print_titles(&text); + sheet.print.title_rows = rows; + sheet.print.title_cols = cols; + } + } + Some(_) => {} + None if lbl.macro_name => report.warn("Macro names were skipped."), + None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, comment: String::new(), hidden: lbl.hidden }), + } + } +} diff --git a/crates/xlsx/src/xls/records.rs b/crates/xlsx/src/xls/records.rs new file mode 100644 index 0000000..fac7847 --- /dev/null +++ b/crates/xlsx/src/xls/records.rs @@ -0,0 +1,198 @@ +//! 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 super::invalid; +use crate::IoError; + +/// `Continue` record type. +pub(super) 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(super) struct Record<'a> { + pub id: u16, + pub parts: Vec<&'a [u8]>, +} + +/// Iterates over the records of a stream starting at `pos`. +pub(super) 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, IoError> { + if self.pos >= self.stream.len() { + return Ok(None); + } + let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?; + 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(|| invalid("truncated record"))?; + 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>, IoError> { + 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(super) struct Cursor<'a, 'b> { + parts: &'b [&'a [u8]], + part: usize, + pos: usize, +} + +impl<'a, 'b> Cursor<'a, 'b> { + pub fn new(parts: &'b [&'a [u8]]) -> Self { + Cursor { parts, part: 0, pos: 0 } + } + + /// 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.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(|| invalid("record is shorter than its contents"))?; + 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<(), IoError> { + for _ in 0..n { + self.u8()?; + } + Ok(()) + } + + /// Bytes left in the record (all parts). + pub fn remaining(&self) -> usize { + let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); + here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::() + } + + /// 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; + 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(invalid("string too long")); + } + 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(invalid("string formatting runs past the end of the record")); + } + 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()); + } +} diff --git a/docs/cli.md b/docs/cli.md index 44e69e5..a4606fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,11 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a non-zero exit code. +`.xls` (Excel 97-2003, BIFF8) files are imported with values, formulas, number formats, merges, +column widths, row heights and defined names; fonts, fills, borders, charts and other objects are +not imported yet. Import warnings go to stderr. Convert to `.xlsx` to save; `.xls` output is not +supported. + `.xlsb` files support [worksheet data import](xlsb-import.md). Formula cells use their last saved values; source formatting and other workbook features are omitted. Import warnings go to stderr. Convert to `.xlsx` to save the imported workbook; XLSB output is not supported. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eeb2dd9..7477f02 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; every format Excel reads or writes, against the code on main) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.xls` BIFF8 import, #121) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -9,7 +9,7 @@ Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready Excel's list comes from Microsoft's public "File formats that are supported in Excel" documentation (the Office bundle is off limits under this repo's clean-room rule). GridCraft's support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in -`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, PivotTables). +`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables). ## Workbook formats @@ -20,8 +20,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **no** (#121); recognised and refused with a message since #17 | no | Still common in finance, government and legacy systems | — | -| `.xlt` Excel 97-2003 template | R/W | no | no | | — | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | | `.ods` OpenDocument Spreadsheet | R/W | **values only** (#100) | no | Sheets, values, dates, merges, repeated cells, cached formula results; no formulas, formatting, charts; encrypted ODS refused | `ods_tests.rs` | @@ -64,7 +64,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8) import: cells, formulas, styles, names; then write | 20–30 | +| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: displayed-value export, encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 5–8 | @@ -74,5 +74,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8..1da4104 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -64,11 +64,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** wrong numbers are worse than missing features. - **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language) -### 7. No legacy `.xls`; no password-protected workbooks (FF) -- **Evidence:** #121. Since #17 (closed #11) both are recognised through a CFB sniff and refused with a clear message, but neither opens. +### 7. Legacy `.xls` only partly read; no password-protected workbooks (FF) +- **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number + formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, validation and BIFF5 files are not read, and `.xls` is never written. Encrypted + workbooks (OOXML and `.xls`) are recognised and refused with a clear message since #17 (closed #11). - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 18–27 h (`.xls` styles and objects 10–15, encryption 8–12). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, @@ -149,6 +152,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | From e78cc6edc91d35f4ca6af6fd8b7bdcce1bd3198f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 12:55:17 -0400 Subject: [PATCH 2/2] Fix-up #332: bound .xls formula nesting and share one record budget across sheets Chained PtgParen/PtgUminus (or operator) tokens built a ~16K-deep Expr whose printing or drop overflowed the stack; nesting past the formula parser's MAX_DEPTH (256) is now reported as unsupported. BoundSheet records pointing into one huge substream each got their own 40M-record allowance; the allowance is now shared by the whole Workbook stream, and two sheets at the same offset are refused. Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/src/tests/xls_tests.rs | 39 ++++++++ crates/xlsx/src/xls/formula.rs | 138 ++++++++++++++++++++--------- crates/xlsx/src/xls/mod.rs | 49 +++++++--- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index a150dc0..fd37795 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -437,3 +437,42 @@ fn hostile_xls_input_never_panics_or_hangs() { let _ = read_xls(&b); } } + +#[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!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_)))); + assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).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}"); +} diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs index 3fc3fc4..87a512c 100644 --- a/crates/xlsx/src/xls/formula.rs +++ b/crates/xlsx/src/xls/formula.rs @@ -180,13 +180,35 @@ fn function_name(name: &str) -> String { n.to_ascii_uppercase() } -fn pop(stack: &mut Vec) -> Result { - stack.pop().ok_or(Unsupported("malformed formula (operand missing)")) +/// 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 Vec, n: usize) -> Result, Unsupported> { +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)"))?; - Ok(stack.split_off(at)) + 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 @@ -197,9 +219,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } let mut t = Bytes { b: rgce, pos: 0 }; let mut extra = Bytes { b: rgcb, pos: 0 }; - let mut stack: Vec = Vec::new(); + 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 { @@ -221,9 +244,9 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x10 => BinOp::Union, _ => BinOp::Range, }; - let b = pop(&mut stack)?; - let a = pop(&mut stack)?; - stack.push(Expr::Binary(op, Box::new(a), Box::new(b))); + 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 { @@ -231,18 +254,18 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: 0x13 => UnOp::Neg, _ => UnOp::Percent, }; - let x = pop(&mut stack)?; - stack.push(Expr::Unary(op, Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; } 0x15 => { - let x = pop(&mut stack)?; - stack.push(Expr::Paren(Box::new(x))); + let x = pop(&mut stack, &mut kids)?; + push(&mut stack, kids, Expr::Paren(Box::new(x)))?; } - 0x16 => stack.push(Expr::Missing), + 0x16 => push(&mut stack, kids, Expr::Missing)?, 0x17 => { let cch = usize::from(t.u8()?); let high = t.u8()? & 1 != 0; - stack.push(Expr::Text(Arc::from(t.chars(cch, high)?))); + push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?; } 0x19 => { let kind = t.u8()?; @@ -255,34 +278,34 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } if kind & 0x10 != 0 { // PtgAttrSum: SUM of the single operand. - let x = pop(&mut stack)?; - stack.push(Expr::Call("SUM".into(), vec![x])); + 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()?; - stack.push(Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)); + push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; } - 0x1D => stack.push(Expr::Bool(t.u8()? != 0)), - 0x1E => stack.push(Expr::Number(f64::from(t.u16()?))), + 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")); } - stack.push(Expr::Number(n)); + push(&mut stack, kids, Expr::Number(n))?; } 0x20 => { t.take(7)?; - stack.push(array_constant(&mut extra)?); + 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)?; - stack.push(Expr::Call(name.to_string(), args)); + 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()?); @@ -290,7 +313,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: if tab & 0x8000 != 0 { return Err(Unsupported("macro sheet commands")); } - let mut args = pop_n(&mut stack, n)?; + 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() { @@ -300,10 +323,10 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: Expr::Name(n) => function_name(&n), _ => return Err(Unsupported("function call through an expression")), }; - stack.push(Expr::Call(name, args)); + push(&mut stack, kids, Expr::Call(name, args))?; } else { let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; - stack.push(Expr::Call(name.to_string(), args)); + push(&mut stack, kids, Expr::Call(name.to_string(), args))?; } } 0x23 => { @@ -314,17 +337,17 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: .and_then(|i| links.names.get(i)) .filter(|n| !n.is_empty()) .ok_or(Unsupported("reference to a missing defined name"))?; - stack.push(Expr::Name(name.clone())); + 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); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) })); + 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); - stack.push(Expr::Ref(Reference { sheet: SheetSel::Current, kind })); + 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. @@ -340,42 +363,50 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: } 0x2A => { t.take(4)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x2B => { t.take(8)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x39 => { let ixti = t.u16()?; let i = t.u32()?; - stack.push(Expr::Name(links.extern_name(ixti, i)?)); + 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); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), - None => Expr::Error(CellError::Ref), - }); + 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); - stack.push(match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind }), - None => Expr::Error(CellError::Ref), - }); + 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)?; - stack.push(Expr::Error(CellError::Ref)); + push(&mut stack, kids, Expr::Error(CellError::Ref))?; } 0x3D => { t.take(10)?; - stack.push(Expr::Error(CellError::Ref)); + 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")), @@ -386,7 +417,7 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: return Err(Unsupported("formula too complex")); } } - let e = pop(&mut stack)?; + let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; if !stack.is_empty() { return Err(Unsupported("malformed formula (unused operands)")); } @@ -493,4 +524,25 @@ mod tests { 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/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index b070478..c9aee66 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -31,7 +31,9 @@ const MAX_SHEETS: usize = 4096; const MAX_NAMES: usize = 65_536; const MAX_XFS: usize = 65_536; const MAX_MERGES: usize = 100_000; -/// Records read from one substream at most (a 65,536 × 256 sheet has fewer). +/// Records read from the whole Workbook stream at most (a 65,536 × 256 sheet has fewer). One +/// allowance shared by every substream: many BoundSheet records pointing into one huge +/// substream must not each get their own. const MAX_RECORDS: usize = 40_000_000; const BOF: u16 = 0x0809; @@ -53,13 +55,32 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool { cf.has_root_stream("Workbook") || cf.has_root_stream("Book") } -#[derive(Default)] -struct Budget { +pub(crate) struct Budget { cells: usize, text: usize, + records: usize, + max_records: usize, +} + +impl Default for Budget { + fn default() -> Self { + Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS } + } } impl Budget { + /// A budget with a smaller record allowance (tests). + #[cfg(test)] + pub(crate) fn with_max_records(max_records: usize) -> Self { + Budget { max_records, ..Budget::default() } + } + fn record(&mut self) -> Result<(), IoError> { + self.records = self.records.saturating_add(1); + if self.records > self.max_records { + return Err(IoError::TooLarge("XLS: too many records".into())); + } + Ok(()) + } fn cell(&mut self) -> Result<(), IoError> { self.cells += 1; if self.cells > MAX_CELLS { @@ -106,6 +127,11 @@ struct Globals { /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { + read_xls_with(bytes, Budget::default()) +} + +/// [`read_xls`] with an explicit resource budget. +pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> { let cf = Compound::open(bytes).map_err(invalid)?; let stream = match cf.root_stream("Workbook").map_err(invalid)? { Some(s) => s, @@ -117,7 +143,6 @@ pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { } }; let mut report = ReadReport::default(); - let mut budget = Budget::default(); let g = globals(&stream, &mut budget)?; let mut wb = Workbook::new(); @@ -195,12 +220,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(invalid("the Workbook stream does not start with the workbook globals")); } let mut g = Globals::default(); - let mut count = 0usize; + let mut offsets = std::collections::HashSet::new(); while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records".into())); - } + budget.record()?; let mut c = Cursor::new(&rec.parts); match rec.id { EOF => break, @@ -210,6 +232,9 @@ fn globals(stream: &[u8], budget: &mut Budget) -> Result { return Err(IoError::TooLarge("XLS: 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()?; @@ -460,12 +485,8 @@ fn worksheet( unsupported: vec![], }; let mut depth = 1usize; - let mut count = 0usize; while let Some(rec) = recs.next_record()? { - count += 1; - if count > MAX_RECORDS { - return Err(IoError::TooLarge("XLS: too many records in a sheet".into())); - } + budget.record()?; match rec.id { BOF => { // An embedded chart's substream.