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:
commit
c379da6ac9
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
|
|
@ -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 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
10
docs/gaps.md
10
docs/gaps.md
|
|
@ -64,11 +64,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
- **Impact:** wrong numbers are worse than missing features.
|
||||
- **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language)
|
||||
|
||||
### 7. No legacy `.xls`; no password-protected workbooks (FF)
|
||||
- **Evidence:** #121. Since #17 (closed #11) both are recognised through a CFB sniff and refused with a clear message, but neither opens.
|
||||
### 7. Legacy `.xls` only partly read; no password-protected workbooks (FF)
|
||||
- **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number
|
||||
formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional
|
||||
formats, validation and BIFF5 files are not read, and `.xls` is never written. Encrypted
|
||||
workbooks (OOXML and `.xls`) are recognised and refused with a clear message since #17 (closed #11).
|
||||
- **Impact:** common in finance, government and anything older than 2007; encrypted files are
|
||||
routine in business.
|
||||
- **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md)
|
||||
- **Estimate:** 18–27 h (`.xls` styles and objects 10–15, encryption 8–12). [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 8. PivotTables: no slicers, calculated 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue