Merge pull request #332 from vangel76/feat/xls-read

XLSX/IO: read legacy .xls (BIFF8) workbooks (#121)

# Conflicts:
#	docs/gaps.md
This commit is contained in:
Brandon Thomas 2026-10-11 13:51:48 -04:00
commit c379da6ac9
19 changed files with 2747 additions and 23 deletions

View File

@ -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.

View File

@ -132,6 +132,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-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |

View File

@ -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 {

View File

@ -150,8 +150,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"])

View File

@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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),
@ -56,8 +56,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
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 {

View File

@ -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<String>)> {
"'{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<Vec<u8>> {
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}");
}
}

View File

@ -1590,6 +1590,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<u8> {
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
[&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<u8> = 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();

View File

@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
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,
),

View File

@ -97,6 +97,222 @@ fn directory_names(b: &[u8]) -> Option<Vec<String>> {
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<u32>,
mini_fat: Vec<u32>,
entries: Vec<DirEntry>,
}
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<Compound<'a>, 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<u32> = (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<u16> = (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<Vec<u8>, 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<Vec<u8>, 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<Option<&DirEntry>, 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<Option<Vec<u8>>, 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::*;

View File

@ -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.
@ -29,6 +30,7 @@ mod styles;
mod tables;
mod theme;
mod write;
mod xls;
mod xlsb;
mod xml;
@ -40,6 +42,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.
@ -106,7 +109,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,
}
@ -123,9 +128,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;

View File

@ -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;

View File

@ -0,0 +1,478 @@
//! 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<u16> = 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<u8> {
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<u8>> = 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<u8> {
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<u8> {
parts.concat()
}
fn u16s(v: &[u16]) -> Vec<u8> {
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<u8> {
u16s(&[r, c, xf])
}
fn bof(dt: u16) -> Vec<u8> {
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
}
fn eof() -> Vec<u8> {
rec(0x000A, &[])
}
/// XLUnicodeString with compressed characters.
fn xlstr(s: &str) -> Vec<u8> {
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<u8> {
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<u8> {
cat(&[&[0x44], &u16s(&[r, c | 0xC000])])
}
fn ptg_int(n: u16) -> Vec<u8> {
cat(&[&[0x1E], &n.to_le_bytes()])
}
fn exp(r: u16, c: u16) -> Vec<u8> {
cat(&[&[0x01], &u16s(&[r, c])])
}
/// A workbook with two worksheets and a chart sheet covering the stage-1 features.
fn sample(date1904: bool) -> Vec<u8> {
// 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<String> {
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::<Vec<_>>(), ["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);
}
}
#[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<u8> = (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}");
}

View File

@ -0,0 +1,548 @@
//! 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<String>,
}
/// 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<String>,
/// Defined names (Lbl records), in order; `None` for names that can't be referenced.
pub names: Vec<String>,
pub books: Vec<SupBook>,
pub xti: Vec<Xti>,
}
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<CellError> {
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<u8, Unsupported> {
self.take(1)?.first().copied().ok_or(TRUNC)
}
fn u16(&mut self) -> Result<u16, Unsupported> {
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<u32, Unsupported> {
Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16))
}
fn f64(&mut self) -> Result<f64, Unsupported> {
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<String, Unsupported> {
let units: Vec<u16> = 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<Option<SheetSel>, 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<String, Unsupported> {
let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?;
let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?;
if !book.internal && !book.addin {
return Err(Unsupported("names in other workbooks (external links)"));
}
let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?;
book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name"))
}
}
/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`).
fn function_name(name: &str) -> String {
let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name);
n.to_ascii_uppercase()
}
/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack
/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens).
const MAX_DEPTH: usize = 256;
/// Operand stack entries: an expression and its nesting depth.
type Stack = Vec<(Expr, usize)>;
/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed).
fn pop(stack: &mut Stack, kids: &mut usize) -> Result<Expr, Unsupported> {
let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
*kids = (*kids).max(d);
Ok(e)
}
fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result<Vec<Expr>, Unsupported> {
let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?;
let popped = stack.split_off(at);
*kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max);
Ok(popped.into_iter().map(|(e, _)| e).collect())
}
/// Pushes the result of a token one level above the operands it consumed.
fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> {
let depth = kids.saturating_add(1);
if depth > MAX_DEPTH {
return Err(Unsupported("formula nested too deeply"));
}
stack.push((e, depth));
Ok(())
}
/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at
/// `base`. Returns the expression or why it can't be represented.
pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result<Expr, Unsupported> {
if rgce.len() > MAX_RGCE {
return Err(Unsupported("formula too long"));
}
let mut t = Bytes { b: rgce, pos: 0 };
let mut extra = Bytes { b: rgcb, pos: 0 };
let mut stack: Stack = Vec::new();
while t.pos < rgce.len() {
let ptg = t.u8()?;
let mut kids = 0usize;
// Operand classes (reference / value / array) share one token number.
let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg };
match id {
0x03..=0x11 => {
let op = match id {
0x03 => BinOp::Add,
0x04 => BinOp::Sub,
0x05 => BinOp::Mul,
0x06 => BinOp::Div,
0x07 => BinOp::Pow,
0x08 => BinOp::Concat,
0x09 => BinOp::Lt,
0x0A => BinOp::Le,
0x0B => BinOp::Eq,
0x0C => BinOp::Ge,
0x0D => BinOp::Gt,
0x0E => BinOp::Ne,
0x0F => BinOp::Intersect,
0x10 => BinOp::Union,
_ => BinOp::Range,
};
let b = pop(&mut stack, &mut kids)?;
let a = pop(&mut stack, &mut kids)?;
push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?;
}
0x12..=0x14 => {
let op = match id {
0x12 => UnOp::Plus,
0x13 => UnOp::Neg,
_ => UnOp::Percent,
};
let x = pop(&mut stack, &mut kids)?;
push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?;
}
0x15 => {
let x = pop(&mut stack, &mut kids)?;
push(&mut stack, kids, Expr::Paren(Box::new(x)))?;
}
0x16 => push(&mut stack, kids, Expr::Missing)?,
0x17 => {
let cch = usize::from(t.u8()?);
let high = t.u8()? & 1 != 0;
push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?;
}
0x19 => {
let kind = t.u8()?;
if kind & 0x04 != 0 {
// PtgAttrChoose: jump table of cOffset + 1 entries.
let n = usize::from(t.u16()?) + 1;
t.take(n * 2)?;
} else {
t.take(2)?;
}
if kind & 0x10 != 0 {
// PtgAttrSum: SUM of the single operand.
let x = pop(&mut stack, &mut kids)?;
push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?;
}
// Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression.
}
0x1C => {
let e = t.u8()?;
push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?;
}
0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?,
0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?,
0x1F => {
let n = t.f64()?;
if !n.is_finite() {
return Err(Unsupported("invalid number constant"));
}
push(&mut stack, kids, Expr::Number(n))?;
}
0x20 => {
t.take(7)?;
push(&mut stack, kids, array_constant(&mut extra)?)?;
}
0x21 => {
let iftab = t.u16()?;
let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?;
let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?;
let args = pop_n(&mut stack, n, &mut kids)?;
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
}
0x22 => {
let n = usize::from(t.u8()?);
let tab = t.u16()?;
if tab & 0x8000 != 0 {
return Err(Unsupported("macro sheet commands"));
}
let mut args = pop_n(&mut stack, n, &mut kids)?;
if tab & 0x7FFF == 0x00FF {
// A function called by name (add-in or newer function): the name comes first.
if args.is_empty() {
return Err(Unsupported("malformed function call"));
}
let name = match args.remove(0) {
Expr::Name(n) => function_name(&n),
_ => return Err(Unsupported("function call through an expression")),
};
push(&mut stack, kids, Expr::Call(name, args))?;
} else {
let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?;
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
}
}
0x23 => {
let i = 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"))?;
push(&mut stack, kids, Expr::Name(name.clone()))?;
}
0x24 | 0x2C => {
let (r, c) = (t.u16()?, t.u16()?);
let a = anchor(r, c, base, id == 0x2C);
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?;
}
0x25 | 0x2D => {
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
let kind = area(r0, r1, c0, c1, base, id == 0x2D);
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?;
}
0x26 => {
// PtgMemArea: a cache of the sub-expression that follows; skip its extra data.
t.take(6)?;
let n = usize::from(extra.u16()?);
extra.take(n.checked_mul(8).ok_or(TRUNC)?)?;
}
0x27 | 0x28 => {
t.take(6)?;
}
0x29 => {
t.take(2)?;
}
0x2A => {
t.take(4)?;
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
}
0x2B => {
t.take(8)?;
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
}
0x39 => {
let ixti = t.u16()?;
let i = t.u32()?;
push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?;
}
0x3A => {
let ixti = t.u16()?;
let (r, c) = (t.u16()?, t.u16()?);
let a = anchor(r, c, base, mode == Mode::Name);
push(
&mut stack,
kids,
match links.sheet_sel(ixti)? {
Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }),
None => Expr::Error(CellError::Ref),
},
)?;
}
0x3B => {
let ixti = t.u16()?;
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
let kind = area(r0, r1, c0, c1, base, mode == Mode::Name);
push(
&mut stack,
kids,
match links.sheet_sel(ixti)? {
Some(sheet) => Expr::Ref(Reference { sheet, kind }),
None => Expr::Error(CellError::Ref),
},
)?;
}
0x3C => {
t.take(6)?;
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
}
0x3D => {
t.take(10)?;
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
}
0x01 => return Err(Unsupported("nested shared or array formula reference")),
0x02 => return Err(Unsupported("what-if data tables")),
0x18 => return Err(Unsupported("natural-language formulas")),
_ => return Err(Unsupported("unknown formula token")),
}
if stack.len() > MAX_RGCE {
return Err(Unsupported("formula too complex"));
}
}
let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
if !stack.is_empty() {
return Err(Unsupported("malformed formula (unused operands)"));
}
Ok(e)
}
/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order.
fn array_constant(b: &mut Bytes<'_>) -> Result<Expr, Unsupported> {
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<String, Unsupported> {
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());
}
#[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());
}
}

376
crates/xlsx/src/xls/ftab.rs Normal file
View File

@ -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),
];

786
crates/xlsx/src/xls/mod.rs Normal file
View File

@ -0,0 +1,786 @@
//! 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):
//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/cd03cb5f-ca02-4934-a391-bb674cb8aa06>.
//!
//! 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 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;
const EOF: u16 = 0x000A;
const BIFF8: u16 = 0x0600;
pub(crate) fn invalid(msg: impl Into<String>) -> 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")
}
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 {
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<u8>,
hidden: bool,
macro_name: bool,
itab: u16,
rgce: Vec<u8>,
rgcb: Vec<u8>,
}
#[derive(Default)]
struct Globals {
sheets: Vec<BoundSheet>,
date1904: bool,
formats: HashMap<u16, String>,
xf_formats: Vec<u16>,
sst: Vec<Arc<str>>,
links: Links,
names: Vec<Lbl>,
}
/// 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,
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 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<StyleId> = 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<Option<usize>> = 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<Globals, IoError> {
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 offsets = std::collections::HashSet::new();
while let Some(rec) = recs.next_record()? {
budget.record()?;
let mut c = Cursor::new(&rec.parts);
match rec.id {
EOF => break,
0x002F => return Err(invalid(ENCRYPTED)),
0x0085 => {
if g.sheets.len() >= MAX_SHEETS {
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()?;
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<Lbl, IoError> {
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<Vec<Arc<str>>, 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<u8>,
rgcb: Vec<u8>,
}
fn parsed(c: &mut Cursor<'_, '_>) -> Result<Parsed, IoError> {
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<Pending>,
shared: HashMap<CellRef, Parsed>,
arrays: HashMap<CellRef, (RangeRef, Parsed)>,
last_formula: Option<CellRef>,
pending_string: Option<CellRef>,
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<CellRef> {
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<Option<CellRef>, 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<RangeRef>) {
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<Option<Sheet>, 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;
while let Some(rec) = recs.next_record()? {
budget.record()?;
match rec.id {
BOF => {
// An embedded chart's substream.
depth += 1;
continue;
}
EOF => {
depth -= 1;
if depth == 0 {
break;
}
continue;
}
_ if depth > 1 => continue,
_ => {}
}
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<usize>], wb: &mut Workbook, report: &mut ReadReport) {
let scope_of = |itab: u16| -> Option<Option<usize>> {
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 }),
}
}
}

View File

@ -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<Option<(u16, &'a [u8])>, 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<u16> {
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<Option<Record<'a>>, 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<u8, IoError> {
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<u16, IoError> {
Ok(u16::from_le_bytes([self.u8()?, self.u8()?]))
}
pub fn u32(&mut self) -> Result<u32, IoError> {
Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?]))
}
pub fn f64(&mut self) -> Result<f64, IoError> {
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::<usize>()
}
/// The rest of the record as one buffer.
pub fn rest(&mut self) -> Vec<u8> {
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<String, IoError> {
if cch > MAX_CHARS {
return Err(invalid("string too long"));
}
let mut units: Vec<u16> = 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<String, IoError> {
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<String, IoError> {
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<String, IoError> {
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());
}
}

View File

@ -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.

View File

@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (XLSX threaded comments and persons written; note visibility read, #300) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **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
@ -21,8 +21,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` |
@ -65,7 +65,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 |
@ -75,6 +75,7 @@ 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-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) |
| 2026-10-11 | minor | XLSX: Allow Edit Ranges (`protectedRanges`) read and written with their password hashes (#307) |
| 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) |

View File

@ -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 items, PivotCharts; unverified in Excel (F)
- **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,
@ -155,6 +158,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) |
| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) |
| 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) |
| 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) |
| 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) |