mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* Add craft-xls: Excel 97-2003 (.xls, BIFF8) workbook reader Moved from GridCraft's gridcraft-xlsx crate (crates/xlsx/src/xls.rs and xls/ at 5cf52c5f), where it read into GridCraft's own workbook model. Here it reads into a model of the file's own terms: cells, formulas as A1 text, one CellFormat per XF, ColInfo and Row records in file units, defined names, print areas as formula text. The container is read with craft-cfb. RString cells and the fixture writer in craft_xls::testing come from WordCraft's mail-merge reader (crates/formats/src/sheet/xls.rs). * craft-xls: report the sheet the workbook was saved on Window1's itabCur, counted over every BoundSheet8 record, as Workbook::active_sheet (an index into the worksheets that were read). Whether to open on a hidden sheet is the caller's choice. Ported from storytold/gridcraft#3551. --------- Co-authored-by: Daniele Mori <3956663+Morzilla@users.noreply.github.com>
704 lines
36 KiB
Rust
704 lines
36 KiB
Rust
//! Reading BIFF8 workbooks: values, formulas, layout, names, limits and hostile input.
|
||
|
||
mod common;
|
||
|
||
use common::{
|
||
at, bof, cat, cell, compound, eof, exp, fmt, formula, ftext, num, ptg_int, ptg_ref, put32, read_limited, read_xls, rec, special, text, u16s, v,
|
||
xlstr,
|
||
};
|
||
use craft_xls::{CalcMode, CellError, Column, Error, Limits, Range, Row, Value, View, Workbook, is_xls};
|
||
|
||
fn range(a1: &str) -> Range {
|
||
let (a, b) = a1.split_once(':').unwrap_or((a1, a1));
|
||
Range::new(at(a), at(b))
|
||
}
|
||
|
||
fn text_limit(max_text: usize) -> Limits {
|
||
Limits { max_text, ..Limits::default() }
|
||
}
|
||
|
||
/// A workbook with two worksheets and a chart sheet covering the stage-1 features.
|
||
fn sample(date1904: bool) -> Vec<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])
|
||
}
|
||
|
||
#[test]
|
||
fn reads_values_formulas_formats_and_layout() {
|
||
for big in [false, true] {
|
||
let mut stream = sample(false);
|
||
if big {
|
||
// Past the mini stream cutoff: the stream lives in regular sectors.
|
||
stream.extend(std::iter::repeat_n(0u8, 5000));
|
||
}
|
||
let bytes = compound("Workbook", &stream);
|
||
assert!(is_xls(&bytes));
|
||
let (wb, report) = read_xls(&bytes).unwrap();
|
||
assert!(report.warnings.iter().any(|w| w.contains("chart sheets")));
|
||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1", "Data"]);
|
||
assert_values_roundtrip(&wb);
|
||
assert_formula_roundtrip(&wb);
|
||
assert_layout_roundtrip(&wb);
|
||
}
|
||
let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap();
|
||
assert!(wb.date_1904);
|
||
}
|
||
|
||
fn assert_values_roundtrip(wb: &Workbook) {
|
||
assert_eq!(v(wb, 0, "A1"), text("Hello"));
|
||
assert_eq!(v(wb, 0, "B1"), Value::Number(1.5));
|
||
assert_eq!(v(wb, 0, "C1"), Value::Number(42.0));
|
||
assert_eq!(v(wb, 0, "D1"), text("Wörld"));
|
||
assert_eq!(v(wb, 0, "A2"), Value::Bool(true));
|
||
assert_eq!(v(wb, 0, "B2"), Value::Error(CellError::Div0));
|
||
assert_eq!(v(wb, 0, "C2"), Value::Number(7.0));
|
||
assert_eq!(v(wb, 0, "D2"), Value::Number(1.5));
|
||
}
|
||
|
||
fn assert_formula_roundtrip(wb: &Workbook) {
|
||
// Formulas: text plus the cached result.
|
||
assert_eq!(ftext(wb, "A3").as_deref(), Some("B1*2"));
|
||
assert_eq!(v(wb, 0, "A3"), Value::Number(3.0));
|
||
assert_eq!(ftext(wb, "B3").as_deref(), Some("SUM(B1:C1)"));
|
||
assert_eq!(ftext(wb, "C3").as_deref(), Some("A1&\"!\""));
|
||
assert_eq!(v(wb, 0, "C3"), text("Hello!"));
|
||
assert_eq!(ftext(wb, "D3").as_deref(), Some("B1>1"));
|
||
assert_eq!(v(wb, 0, "D3"), Value::Bool(true));
|
||
assert_eq!(ftext(wb, "E3").as_deref(), Some("Rate*2"));
|
||
assert_eq!(ftext(wb, "F3").as_deref(), Some("ROUND(B1,0)"));
|
||
assert_eq!(ftext(wb, "G3").as_deref(), Some("Data!A1"));
|
||
assert_eq!(v(wb, 0, "H3"), Value::Error(CellError::NA));
|
||
// Shared formula: relative offsets resolve per cell.
|
||
assert_eq!(ftext(wb, "B5").as_deref(), Some("A5*10"));
|
||
assert_eq!(ftext(wb, "B6").as_deref(), Some("A6*10"));
|
||
// Array formula: the anchor holds it; the other cell's cached result is dropped so that
|
||
// the formula fills the range on recalculation .
|
||
let c5 = wb.sheets[0].cells[&at("C5")].formula.clone().unwrap();
|
||
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(range("C5:C6"))));
|
||
// The follower keeps its (unlocked, so not plain) format on a blank cell.
|
||
assert_eq!(wb.sheets[0].cells[&at("C6")], craft_xls::Cell { value: Value::Empty, format: Some(0), formula: None });
|
||
assert_eq!(ftext(wb, "C6"), None);
|
||
assert_eq!(v(wb, 0, "C6"), Value::Empty);
|
||
}
|
||
|
||
fn assert_layout_roundtrip(wb: &Workbook) {
|
||
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
|
||
let sh = &wb.sheets[0];
|
||
assert_eq!(fmt(wb, "A4"), "yyyy-mm-dd");
|
||
assert_eq!(fmt(wb, "B4"), "0.00%");
|
||
assert_eq!(fmt(wb, "A1"), "General");
|
||
assert_eq!(sh.merges, vec![range("A7:B7")]);
|
||
// Column A: 20 characters wide, set by the user, in XF 0 (unlocked here, so not plain). Row 1: 600 twips (30 pt), set by the user.
|
||
assert_eq!(sh.columns, [(0, Column { width: 20 * 256, custom_width: true, format: Some(0), ..Column::default() })].into());
|
||
assert_eq!(sh.rows, [(0, Row { height: 600, custom_height: true, ..Row::default() })].into());
|
||
assert_eq!(sh.default_row_height, Some(255));
|
||
assert_eq!(sh.view, None);
|
||
assert_eq!(sh.print_area.as_deref(), Some("Sheet1!$A$1:$B$2"));
|
||
assert_eq!(sh.print_titles, None);
|
||
assert_eq!(v(wb, 0, "J10"), Value::Empty);
|
||
assert_eq!(wb.names.len(), 1);
|
||
assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1"));
|
||
assert_eq!(v(wb, 1, "A1"), Value::Number(7.0));
|
||
assert!(!wb.date_1904);
|
||
}
|
||
|
||
#[test]
|
||
fn text_result_of_a_formula_kept_as_its_value() {
|
||
// A what-if data table cell ({=TABLE(B1,)}) can't be imported as a formula, so its saved result
|
||
// is kept. With the default XF the cell is blank until the String record brings the text.
|
||
let table = cat(&[&[0x02], &u16s(&[0, 0])]);
|
||
let sheet = cat(&[&bof(0x0010), &formula(0, 0, special(0, 0), &table), &rec(0x0207, &xlstr("103a")), &eof()]);
|
||
let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"]));
|
||
let len = cat(&[&bof(0x0005), &boundsheet(0), &eof()]).len() as u32;
|
||
let stream = cat(&[&bof(0x0005), &boundsheet(len), &eof(), &sheet]);
|
||
let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||
assert!(report.warnings.iter().any(|w| w.contains("what-if data tables")), "{:?}", report.warnings);
|
||
assert_eq!(ftext(&wb, "A1"), None);
|
||
assert_eq!(v(&wb, 0, "A1"), text("103a"));
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_encrypted_old_and_foreign_files() {
|
||
// FILEPASS in the globals: an encrypted workbook.
|
||
let mut stream = bof(0x0005);
|
||
stream.extend(rec(0x002F, &[1, 0, 1, 0]));
|
||
stream.extend(eof());
|
||
assert_eq!(read_xls(&compound("Workbook", &stream)).err(), Some(Error::Encrypted));
|
||
// A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream.
|
||
let book = compound("Book", &stream);
|
||
assert!(is_xls(&book));
|
||
assert_eq!(read_xls(&book).err(), Some(Error::Biff5));
|
||
// So does one whose Workbook stream starts with a BIFF5 BOF.
|
||
let mut old = rec(0x0809, &u16s(&[0x0500, 0x0005, 0, 0]));
|
||
old.extend(eof());
|
||
assert_eq!(read_xls(&compound("Workbook", &old)).err(), Some(Error::Biff5));
|
||
// Another compound file (a word processor document): not a workbook.
|
||
let doc = compound("WordDocument", &[0u8; 100]);
|
||
assert!(!is_xls(&doc));
|
||
assert!(matches!(read_xls(&doc), Err(Error::Invalid(_))));
|
||
assert!(!is_xls(b"PK\x03\x04") && !is_xls(&[]));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_xls_input_never_panics_or_hangs() {
|
||
let stream = sample(false);
|
||
let good = compound("Workbook", &stream);
|
||
// Truncation anywhere.
|
||
for cut in (0..good.len()).step_by(37) {
|
||
let _ = read_xls(&good[..cut]);
|
||
let _ = is_xls(&good[..cut]);
|
||
}
|
||
// Byte mutations in the container and the stream.
|
||
for at in (0..good.len()).step_by(7) {
|
||
for x in [0x00u8, 0x01, 0x7F, 0xFF] {
|
||
let mut b = good.clone();
|
||
b[at] = x;
|
||
let _ = read_xls(&b);
|
||
}
|
||
}
|
||
for at in 0..stream.len() {
|
||
let mut s = stream.clone();
|
||
s[at] ^= 0xA5;
|
||
let _ = read_xls(&compound("Workbook", &s));
|
||
}
|
||
// A FAT chain that loops on itself (big stream in regular sectors).
|
||
let mut big = stream.clone();
|
||
big.extend(std::iter::repeat_n(0u8, 5000));
|
||
let mut b = compound("Workbook", &big);
|
||
let fat_at = 512;
|
||
let first_data = 2usize; // FAT, directory, then data
|
||
put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32);
|
||
assert!(read_xls(&b).is_err());
|
||
// Huge declared counts: shared strings, merges, external sheets.
|
||
let mut huge = bof(0x0005);
|
||
huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()])));
|
||
huge.extend(eof());
|
||
assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(Error::TooLarge(_))));
|
||
let mut huge = bof(0x0005);
|
||
huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")])));
|
||
huge.extend(rec(0x0017, &u16s(&[0xFFFF])));
|
||
huge.extend(eof());
|
||
assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets
|
||
// A sheet pointing outside the stream and a stream size larger than the file.
|
||
let mut bad = bof(0x0005);
|
||
bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"])));
|
||
bad.extend(eof());
|
||
assert!(read_xls(&compound("Workbook", &bad)).is_err());
|
||
let mut b = compound("Workbook", &big);
|
||
put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF);
|
||
assert!(read_xls(&b).is_err());
|
||
// Random bytes behind a compound-file signature.
|
||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||
for _ in 0..200 {
|
||
let mut b = good[..512].to_vec();
|
||
for _ in 0..2048 {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
b.push(seed as u8);
|
||
}
|
||
let _ = read_xls(&b);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dialog_sheets_are_skipped_not_read_as_worksheets() {
|
||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let globals = |positions: [u32; 2]| cat(&[&bof(0x0005), &boundsheet(positions[0], "Dialog1"), &boundsheet(positions[1], "Sheet1"), &eof()]);
|
||
let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &1.0f64.to_le_bytes()])), &eof()]);
|
||
let worksheet = cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
|
||
let first = globals([0; 2]).len() as u32;
|
||
let stream = cat(&[&globals([first, first + dialog.len() as u32]), &dialog, &worksheet]);
|
||
let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1"]);
|
||
assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0));
|
||
assert!(report.warnings.iter().any(|w| w.contains("dialog sheets")), "{:?}", report.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn sheets_share_one_record_budget() {
|
||
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
|
||
// sheet would read the whole tail. The record allowance is shared, not per sheet.
|
||
let n = 300u32;
|
||
let tail: Vec<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!(read_limited(&file, Limits { max_records: 100_000, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||
assert!(read_limited(&file, Limits { max_records: 10_000_000, ..Limits::default() }).is_ok());
|
||
|
||
// Two sheets at the same offset are refused outright.
|
||
let mut dup = bof(0x0005);
|
||
let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32;
|
||
dup.extend(boundsheet(at, "A"));
|
||
dup.extend(boundsheet(at, "B"));
|
||
dup.extend(eof());
|
||
dup.extend(bof(0x0010));
|
||
dup.extend(eof());
|
||
let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string();
|
||
assert!(err.contains("same substream"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn formulas_using_a_skipped_name_keep_their_saved_results() {
|
||
// Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook),
|
||
// "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a
|
||
// name that uses it is still read as it was.
|
||
let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]);
|
||
let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]);
|
||
let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
|
||
let lbl = |name: &str, rgce: &[u8]| {
|
||
rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce]))
|
||
};
|
||
let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"]));
|
||
let globals = |pos: u32| {
|
||
cat(&[
|
||
&bof(0x0005),
|
||
&boundsheet(pos),
|
||
&rec(0x01AE, &u16s(&[1, 0x0401])),
|
||
&rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])),
|
||
&rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])),
|
||
&lbl("ExtRange", &ext),
|
||
&lbl("Local", &local),
|
||
&lbl("Twice", &twice),
|
||
&eof(),
|
||
])
|
||
};
|
||
let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]);
|
||
let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
|
||
let sheet = cat(&[
|
||
&bof(0x0010),
|
||
&rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])),
|
||
&formula(5, 1, num(60.0), &sum_ext),
|
||
&formula(6, 1, num(20.0), &local_x2),
|
||
&eof(),
|
||
]);
|
||
let pos = globals(0).len() as u32;
|
||
let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap();
|
||
let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect();
|
||
assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]);
|
||
assert_eq!(ftext(&wb, "B6"), None);
|
||
assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0));
|
||
assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2"));
|
||
assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn reads_iterative_calculation_settings() {
|
||
// CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only
|
||
// the first sheet carries them, so the sheet after it must not reset them to the defaults.
|
||
// CalcPrecision sits in the globals.
|
||
let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]);
|
||
let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]);
|
||
let sheet2 = cat(&[&bof(0x0010), &eof()]);
|
||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]);
|
||
let p1 = globals(0, 0).len() as u32;
|
||
let p2 = p1 + sheet1.len() as u32;
|
||
let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap();
|
||
assert_eq!(wb.calc.iterative, Some(true));
|
||
assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (Some(50), Some(0.01)));
|
||
assert_eq!(wb.calc.mode, Some(CalcMode::Manual));
|
||
assert_eq!((wb.sheets[0].calc, wb.sheets[1].calc), (wb.calc, craft_xls::Calc::default()));
|
||
assert!(wb.precision_as_displayed);
|
||
// Without the records nothing is set: the caller applies Excel's defaults.
|
||
let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap();
|
||
assert_eq!(wb.calc, craft_xls::Calc::default());
|
||
assert!(!wb.precision_as_displayed);
|
||
}
|
||
|
||
#[test]
|
||
fn a_format_shorter_than_its_count_keeps_its_characters() {
|
||
// A FORMAT whose cch is one more than the characters in the record (seen in real files):
|
||
// Excel opens them, the record's characters are the format code.
|
||
let short = rec(0x041E, &cat(&[&165u16.to_le_bytes(), &6u16.to_le_bytes(), &[0], b"0.000"]));
|
||
let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]]));
|
||
let globals = |pos: u32| {
|
||
cat(&[
|
||
&bof(0x0005),
|
||
&short,
|
||
&rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])),
|
||
&xf(0),
|
||
&xf(164),
|
||
&xf(165),
|
||
&rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])),
|
||
&eof(),
|
||
])
|
||
};
|
||
let sheet = cat(&[
|
||
&bof(0x0010),
|
||
&rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])),
|
||
&rec(0x0203, &cat(&[&cell(1, 0, 1), &0.0f64.to_le_bytes()])),
|
||
&rec(0x0203, &cat(&[&cell(2, 0, 2), &1.25f64.to_le_bytes()])),
|
||
&eof(),
|
||
]);
|
||
let stream = cat(&[&globals(globals(0).len() as u32), &sheet]);
|
||
let (wb, report) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||
let fmt = |a1: &str| fmt(&wb, a1);
|
||
assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0));
|
||
assert_eq!(v(&wb, 0, "A3"), Value::Number(1.25));
|
||
assert_eq!(fmt("A2"), "yyyy-mm-dd");
|
||
assert_eq!(fmt("A3"), "0.000");
|
||
assert!(report.warnings.iter().any(|w| w.contains("declared length")), "{:?}", report.warnings);
|
||
}
|
||
|
||
/// A workbook whose "Report" sheet holds legacy formulas over ranges of "Data": Data!A1 = 11,
|
||
/// B1:G1 = 1, A2 = 5, A3 = 7, and Report!B1:G1 filled, so a formula in Report!A1 that spilled
|
||
/// would be blocked.
|
||
fn legacy_formulas_book() -> Vec<u8> {
|
||
let area3d = |r0: u16, r1: u16, c0: u16, c1: u16| cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[r0, r1, c0, c1])]);
|
||
let a2a3 = area3d(1, 2, 0, 0);
|
||
let sum = |inner: &[u8]| cat(&[inner, &[0x22, 1], &4u16.to_le_bytes()]);
|
||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let globals = |p1: u32, p2: u32| {
|
||
cat(&[
|
||
&bof(0x0005),
|
||
&boundsheet(p1, "Data"),
|
||
&boundsheet(p2, "Report"),
|
||
&rec(0x01AE, &u16s(&[2, 0x0401])),
|
||
&rec(0x0017, &cat(&[&u16s(&[1]), &u16s(&[0, 0, 0])])),
|
||
&eof(),
|
||
])
|
||
};
|
||
let number = |r: u16, c: u16, n: f64| rec(0x0203, &cat(&[&cell(r, c, 0), &n.to_le_bytes()]));
|
||
let mut data = bof(0x0010);
|
||
data.extend(number(0, 0, 11.0));
|
||
for c in 1..7 {
|
||
data.extend(number(0, c, 1.0));
|
||
}
|
||
data.extend(number(1, 0, 5.0));
|
||
data.extend(number(2, 0, 7.0));
|
||
data.extend(eof());
|
||
let shared = cat(&[&a2a3, &ptg_int(10), &[0x05]]);
|
||
let array = cat(&[&a2a3, &ptg_int(2), &[0x05]]);
|
||
let single_array = sum(&cat(&[&a2a3, &ptg_int(2), &[0x05]]));
|
||
let mut report = bof(0x0010);
|
||
for c in 1..7 {
|
||
report.extend(number(0, c, 0.0));
|
||
}
|
||
report.extend(cat(&[
|
||
// A1 = Data!A1:G1, A2 = +Data!A1:G1 (a title row repeated from another sheet).
|
||
&formula(0, 0, num(11.0), &area3d(0, 0, 0xC000, 6 | 0xC000)),
|
||
&formula(1, 0, num(11.0), &cat(&[&area3d(0, 0, 0xC000, 6 | 0xC000), &[0x12]])),
|
||
// C2:C3 share Data!$A$2:$A$3*10.
|
||
&formula(1, 2, num(50.0), &exp(1, 2)),
|
||
&rec(0x04BC, &cat(&[&u16s(&[1, 2]), &[2, 2, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])),
|
||
&formula(2, 2, num(70.0), &exp(1, 2)),
|
||
// D2:D3 = {Data!$A$2:$A$3*2}, entered with Ctrl+Shift+Enter.
|
||
&formula(1, 3, num(10.0), &exp(1, 3)),
|
||
&rec(0x0221, &cat(&[&u16s(&[1, 2]), &[3, 3], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])),
|
||
&formula(2, 3, num(14.0), &exp(1, 3)),
|
||
// H1 = {SUM(Data!$A$2:$A$3*2)}, a one-cell Ctrl+Shift+Enter array.
|
||
&formula(0, 7, num(24.0), &exp(0, 7)),
|
||
&rec(0x0221, &cat(&[&u16s(&[0, 0]), &[7, 7], &[0; 6], &(single_array.len() as u16).to_le_bytes(), &single_array])),
|
||
// H2 = MAX(SUM(Data!$A$2:$A$3/12),0), H3 = SUM(Data!$A$2:$A$3), H4 = SQRT(Data!$A$2:$A$3).
|
||
&formula(1, 7, num(5.0 / 12.0), &cat(&[&sum(&cat(&[&a2a3, &ptg_int(12), &[0x06]])), &ptg_int(0), &[0x22, 2], &7u16.to_le_bytes()])),
|
||
&formula(2, 7, num(12.0), &sum(&a2a3)),
|
||
&formula(3, 7, special(2, 0x0F), &cat(&[&a2a3, &[0x21], &20u16.to_le_bytes()])),
|
||
&eof(),
|
||
]));
|
||
let p1 = globals(0, 0).len() as u32;
|
||
let p2 = p1 + data.len() as u32;
|
||
cat(&[&globals(p1, p2), &data, &report])
|
||
}
|
||
|
||
/// Every formula in an .xls file is a legacy one. They are read as stored: adding Excel's
|
||
/// implicit intersection (`@`) is the job of a caller whose formula language has dynamic arrays.
|
||
#[test]
|
||
fn legacy_formulas_are_read_as_stored() {
|
||
let bytes = compound("Workbook", &legacy_formulas_book());
|
||
let formula = |wb: &Workbook, a1: &str| {
|
||
let f = wb.sheets[1].cells[&at(a1)].formula.clone().unwrap();
|
||
(f.text, f.array)
|
||
};
|
||
let (wb, _) = read_xls(&bytes).unwrap();
|
||
assert_eq!(formula(&wb, "A1"), ("Data!A1:G1".into(), None));
|
||
assert_eq!(formula(&wb, "A2"), ("+Data!A1:G1".into(), None));
|
||
// Shared formulas, each cell on its own.
|
||
assert_eq!(formula(&wb, "C2"), ("Data!$A$2:$A$3*10".into(), None));
|
||
assert_eq!(formula(&wb, "C3"), ("Data!$A$2:$A$3*10".into(), None));
|
||
// Ctrl+Shift+Enter arrays, one cell or several, keep their range on the anchor.
|
||
assert_eq!(formula(&wb, "D2"), ("Data!$A$2:$A$3*2".into(), Some(range("D2:D3"))));
|
||
assert_eq!(formula(&wb, "H1"), ("SUM(Data!$A$2:$A$3*2)".into(), Some(range("H1"))));
|
||
assert_eq!(formula(&wb, "H2"), ("MAX(SUM(Data!$A$2:$A$3/12),0)".into(), None));
|
||
assert_eq!(formula(&wb, "H3"), ("SUM(Data!$A$2:$A$3)".into(), None));
|
||
assert_eq!(formula(&wb, "H4"), ("SQRT(Data!$A$2:$A$3)".into(), None));
|
||
assert_eq!(v(&wb, 1, "H4"), Value::Error(CellError::Value));
|
||
}
|
||
|
||
fn one_sheet_with(sheet_records: &[u8]) -> Vec<u8> {
|
||
let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &eof()]);
|
||
let sheet = cat(&[&bof(0x0010), sheet_records, &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
|
||
compound("Workbook", &cat(&[&globals(globals(0).len() as u32), &sheet]))
|
||
}
|
||
|
||
#[test]
|
||
fn xls_window2_right_to_left_sheet_opens_right_to_left() {
|
||
let window2 = |flags: u16| rec(0x023E, &u16s(&[flags, 0, 0, 64, 0, 0, 0, 0, 0]));
|
||
let view = |file: &[u8]| {
|
||
let (wb, _) = read_xls(file).unwrap();
|
||
let view = wb.sheets[0].view.unwrap();
|
||
(view.right_to_left, view.show_gridlines, view.show_headings, view.show_zeros, view.show_formulas)
|
||
};
|
||
assert_eq!(view(&one_sheet_with(&window2(0x0040 | 0x0004))), (true, false, true, false, false));
|
||
assert_eq!(view(&one_sheet_with(&window2(0x0001 | 0x0002 | 0x0010))), (false, true, false, true, true));
|
||
}
|
||
|
||
#[test]
|
||
fn xls_short_window2_is_ignored() {
|
||
for payload in [&[][..], &[0x40][..], &[0x40, 0][..], &[0x40, 0, 0, 0, 0, 0, 0, 0, 0][..]] {
|
||
let (wb, _) = read_xls(&one_sheet_with(&rec(0x023E, payload))).unwrap();
|
||
assert_eq!(wb.sheets[0].view, None::<View>);
|
||
assert_eq!(v(&wb, 0, "A1"), Value::Number(7.0));
|
||
}
|
||
}
|
||
|
||
/// Shared-formula followers each keep their own text, charged to the workbook's text allowance:
|
||
/// with no allowance left they keep their cached values, and the import still succeeds.
|
||
#[test]
|
||
fn shared_formula_text_counts_against_the_text_allowance() {
|
||
let file = compound("Workbook", &sample(false));
|
||
// The smallest allowance that reads every string: nothing is left for expanded formulas.
|
||
let left = (0..1000).find(|n| read_limited(&file, text_limit(*n)).is_ok()).unwrap();
|
||
let (wb, report) = read_limited(&file, text_limit(left)).unwrap();
|
||
assert_eq!(ftext(&wb, "B6"), None);
|
||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||
assert!(report.warnings.iter().any(|w| w.contains("shared-formula text")), "{:?}", report.warnings);
|
||
// an array anchor after the cut-off still gets its formula
|
||
assert!(ftext(&wb, "C5").is_some());
|
||
// With room, the followers keep their formulas.
|
||
let (wb, _) = read_xls(&file).unwrap();
|
||
assert!(ftext(&wb, "B6").is_some());
|
||
}
|
||
|
||
/// Shared-formula text is optional: an early sheet's expanded formulas must not use the allowance
|
||
/// a later sheet's inline strings need, or the whole import fails for text that fits.
|
||
#[test]
|
||
fn shared_formulas_do_not_starve_a_later_sheets_text() {
|
||
let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]);
|
||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let globals = |p: [u32; 2]| cat(&[&bof(0x0005), &boundsheet(p[0], "One"), &boundsheet(p[1], "Two"), &eof()]);
|
||
let one = cat(&[
|
||
&bof(0x0010),
|
||
&formula(4, 1, num(10.0), &exp(4, 1)),
|
||
&rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])),
|
||
&formula(5, 1, num(20.0), &exp(4, 1)),
|
||
&eof(),
|
||
]);
|
||
let two = cat(&[&bof(0x0010), &rec(0x0204, &cat(&[&cell(0, 0, 0), &xlstr("abcdefgh")])), &eof()]);
|
||
let p1 = globals([0; 2]).len() as u32;
|
||
let file = compound("Workbook", &cat(&[&globals([p1, p1 + one.len() as u32]), &one, &two]));
|
||
// The smallest allowance that reads every string leaves nothing for expanded formulas.
|
||
let left = (0..1000).find(|n| read_limited(&file, text_limit(*n)).is_ok()).unwrap();
|
||
let (wb, _) = read_limited(&file, text_limit(left)).unwrap();
|
||
assert_eq!(v(&wb, 1, "A1"), text("abcdefgh"));
|
||
assert_eq!(ftext(&wb, "B6"), None);
|
||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||
let (wb, _) = read_xls(&file).unwrap();
|
||
assert!(ftext(&wb, "B6").is_some());
|
||
}
|
||
|
||
/// Sheet names are returned as stored: two worksheets may share a name (a caller that needs
|
||
/// unique names checks them).
|
||
#[test]
|
||
fn duplicate_sheet_names_are_returned_as_stored() {
|
||
let sheet = cat(&[&bof(0x0010), &eof()]);
|
||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let globals = |p: u32| cat(&[&bof(0x0005), &boundsheet(p, "One"), &boundsheet(p + sheet.len() as u32, "one"), &eof()]);
|
||
let file = compound("Workbook", &cat(&[&globals(globals(0).len() as u32), &sheet, &sheet]));
|
||
let (wb, _) = read_xls(&file).unwrap();
|
||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["One", "one"]);
|
||
}
|
||
|
||
/// RString ([MS-XLS] 2.4.229): a label with formatting runs. The text is read, the runs skipped.
|
||
#[test]
|
||
fn rstring_cells_are_read() {
|
||
let rstring = rec(0x00D6, &cat(&[&cell(1, 0, 0), &xlstr("rich"), &u16s(&[1, 0, 5])]));
|
||
let (wb, _) = read_xls(&one_sheet_with(&rstring)).unwrap();
|
||
assert_eq!(v(&wb, 0, "A2"), text("rich"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_fixture_writer_reads_back() {
|
||
let long = "é".repeat(3000);
|
||
let rows =
|
||
vec![vec!["Name".to_string(), "Żółw".into(), String::new(), "2.5".into()], vec!["date:45000".into(), long.clone(), long.clone() + "x"]];
|
||
let bytes = craft_xls::testing::workbook(&[("Erste", rows), ("Second", vec![vec!["Name".into()]])]);
|
||
assert!(is_xls(&bytes));
|
||
let read = craft_xls::read(&bytes).unwrap();
|
||
assert_eq!(read.warnings, []);
|
||
let wb = read.workbook;
|
||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Erste", "Second"]);
|
||
assert_eq!((v(&wb, 0, "A1"), v(&wb, 0, "B1"), v(&wb, 0, "C1"), v(&wb, 0, "D1")), (text("Name"), text("Żółw"), Value::Empty, Value::Number(2.5)));
|
||
assert_eq!((v(&wb, 0, "A2"), fmt(&wb, "A2")), (Value::Number(45000.0), "m/d/yyyy".to_string()));
|
||
// Two 3,000-character strings do not fit one record: the table continues in a Continue record.
|
||
assert_eq!((v(&wb, 0, "B2"), v(&wb, 0, "C2")), (text(&long), text(&(long.clone() + "x"))));
|
||
assert_eq!(v(&wb, 1, "A1"), text("Name"));
|
||
}
|
||
|
||
#[test]
|
||
fn cell_sheet_and_merge_caps_are_enforced() {
|
||
let number = |r: u16| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]));
|
||
// `one_sheet_with` adds one cell of its own.
|
||
let file = one_sheet_with(&cat(&[&number(1), &number(2)]));
|
||
assert!(read_limited(&file, Limits { max_cells: 3, ..Limits::default() }).is_ok());
|
||
assert!(matches!(read_limited(&file, Limits { max_cells: 2, ..Limits::default() }), Err(Error::TooLarge(_))));
|
||
|
||
// 4,097 BoundSheet records.
|
||
let mut globals = bof(0x0005);
|
||
for i in 0..4097u32 {
|
||
globals.extend(rec(0x0085, &cat(&[&i.to_le_bytes(), &[0, 0, 1, 0], b"S"])));
|
||
}
|
||
globals.extend(eof());
|
||
assert!(matches!(read_xls(&compound("Workbook", &globals)), Err(Error::TooLarge(_))));
|
||
|
||
// 100,001 merged ranges, 1,000 to a record.
|
||
let merges = |n: u16| {
|
||
(0..n).fold(u16s(&[n]), |mut p, i| {
|
||
p.extend(u16s(&[i, i, 0, 1]));
|
||
p
|
||
})
|
||
};
|
||
let mut records: Vec<u8> = (0..100).flat_map(|_| rec(0x00E5, &merges(1000))).collect();
|
||
assert_eq!(read_xls(&one_sheet_with(&records)).unwrap().0.sheets[0].merges.len(), 100_000);
|
||
records.extend(rec(0x00E5, &merges(1)));
|
||
assert!(matches!(read_xls(&one_sheet_with(&records)), Err(Error::TooLarge(_))));
|
||
}
|
||
|
||
/// The sheet a workbook was saved on (Window1's `itabCur`) is counted over all the file's
|
||
/// sheets, skipped ones included.
|
||
#[test]
|
||
fn reports_the_sheet_it_was_saved_on() {
|
||
// BoundSheet: position, visibility (0 visible, 1 hidden), kind, name.
|
||
let boundsheet = |pos: u32, state: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[state, 0, name.len() as u8, 0], name.as_bytes()]));
|
||
let window1 = |tab: u16| rec(0x003D, &u16s(&[0, 0, 0x4000, 0x2000, 0x0038, tab, 0, 1, 600]));
|
||
let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &eof()]);
|
||
let worksheet = |n: f64| cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &n.to_le_bytes()])), &eof()]);
|
||
// Dialog1 (skipped), Sheet1, Sheet2, Hidden (hidden).
|
||
let book = |window: &[u8]| {
|
||
let globals = |p: [u32; 4]| {
|
||
cat(&[
|
||
&bof(0x0005),
|
||
window,
|
||
&boundsheet(p[0], 0, "Dialog1"),
|
||
&boundsheet(p[1], 0, "Sheet1"),
|
||
&boundsheet(p[2], 0, "Sheet2"),
|
||
&boundsheet(p[3], 1, "Hidden"),
|
||
&eof(),
|
||
])
|
||
};
|
||
let (d, s1, s2, h) = (dialog.clone(), worksheet(1.0), worksheet(2.0), worksheet(3.0));
|
||
let base = globals([0; 4]).len() as u32;
|
||
let p = [base, base + d.len() as u32, base + (d.len() + s1.len()) as u32, base + (d.len() + s1.len() + s2.len()) as u32];
|
||
let stream = cat(&[&globals(p), &d, &s1, &s2, &h]);
|
||
let (wb, _) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||
wb.active_sheet.map(|i| wb.sheets[i].name.clone())
|
||
};
|
||
assert_eq!(book(&window1(2)).as_deref(), Some("Sheet2"), "itabCur 2 is Sheet2, counting the skipped dialog sheet");
|
||
assert_eq!(book(&window1(1)).as_deref(), Some("Sheet1"));
|
||
assert_eq!(book(&window1(3)).as_deref(), Some("Hidden"), "a hidden sheet is reported; the caller falls back");
|
||
assert_eq!(book(&window1(0)), None, "a skipped sheet");
|
||
assert_eq!(book(&window1(99)), None, "an index past the sheets");
|
||
assert_eq!(book(&[]), None, "no window record");
|
||
assert_eq!(book(&rec(0x003D, &u16s(&[0, 0, 0, 0, 0]))), None, "a window record too short for the index");
|
||
// Only the first window counts.
|
||
assert_eq!(book(&cat(&[&window1(1), &window1(2)])).as_deref(), Some("Sheet1"));
|
||
}
|