From 21df49c636bfc59dbf5daf007ed6fde0c0021edd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 00:58:43 +0000 Subject: [PATCH] xlsx: read .xls workbooks through craft-xls The BIFF8 reader moved to storytold/craft-libs as craft-xls (#878). GridCraft keeps the mapping onto its model in xls.rs: formulas parsed, Excel's implicit @ for legacy formulas, pixel widths and heights, styles interned per XF, sheet-name checks, print areas and titles, calculation settings and GridCraft's warning text. The workbook now opens on the sheet it was saved on when that sheet is visible, else on the first visible sheet (from #3551 by @Morzilla, whose test is kept). RString cells are read as text. Every craft-libs crate moves to one revision, 119415f4. --- Cargo.lock | 37 +- Cargo.toml | 11 +- apps/gridcraft-web/Cargo.toml | 4 +- apps/gridcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/xlsx/Cargo.toml | 1 + crates/xlsx/src/cfb.rs | 5 - crates/xlsx/src/tests/xls_tests.rs | 66 +- crates/xlsx/src/xls.rs | 1214 ++++++++-------------------- crates/xlsx/src/xls/formula.rs | 551 ------------- crates/xlsx/src/xls/ftab.rs | 376 --------- crates/xlsx/src/xls/records.rs | 223 ----- crates/xlsx/src/xls/styles.rs | 406 ---------- docs/file-format-parity.md | 3 +- 14 files changed, 416 insertions(+), 2485 deletions(-) delete mode 100644 crates/xlsx/src/xls/formula.rs delete mode 100644 crates/xlsx/src/xls/ftab.rs delete mode 100644 crates/xlsx/src/xls/records.rs delete mode 100644 crates/xlsx/src/xls/styles.rs diff --git a/Cargo.lock b/Cargo.lock index 92451aa83..ba1bf3641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -907,15 +907,20 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-cfb" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" + [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "fixed_decimal", "icu_decimal", @@ -938,7 +943,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "craft-winprint-gdi", "log", @@ -949,10 +954,15 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-sheet" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" + [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -972,7 +982,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "chrono", "dunce", @@ -999,7 +1009,7 @@ dependencies = [ [[package]] name = "craft-web-api" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "js-sys", "serde_json", @@ -1010,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "wasm-bindgen", "web-sys", @@ -1019,7 +1029,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" dependencies = [ "egui", "objc2 0.6.5", @@ -1035,6 +1045,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xls" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e" +dependencies = [ + "craft-cfb", + "craft-sheet", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -2241,6 +2261,7 @@ name = "gridcraft-xlsx" version = "0.19.1" dependencies = [ "aes", + "craft-xls", "gridcraft-core", "gridcraft-formula", "gridcraft-functions", diff --git a/Cargo.toml b/Cargo.toml index f244db53f..ad9ae0f1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,10 +26,13 @@ gridcraft-ui-egui = { path = "crates/ui-egui" } gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } gridcraft-math = { path = "crates/math" } -# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in -# the app) comes from one craft-libs revision. +# Every craft-libs crate (craft-update, craft-window and the file-format crates here, craft-ui in +# ui-egui, craft-print in the app, craft-web-api and craft-web-host in the web app) comes from one +# craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "=0.1.0", features = ["egui"] } +# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model. +craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" } # foundation thiserror = "2" @@ -72,7 +75,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 09c6d7e2a..49d897607 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -18,8 +18,8 @@ gridcraft-engine = { workspace = true } gridcraft-ui-egui = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. # The page command API (storytold/craft-libs#413): window.gridcraft over the control channel. -craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 8704a6e39..0e5497e4e 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -35,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1a1c2a9ab..bd8ecdace 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every # craft-libs dependency uses (see the root Cargo.toml). -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 60a993680..ab322412d 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -18,6 +18,7 @@ gridcraft-core = { workspace = true } gridcraft-formula = { workspace = true } gridcraft-math = { workspace = true } gridcraft-model = { workspace = true } +craft-xls = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } diff --git a/crates/xlsx/src/cfb.rs b/crates/xlsx/src/cfb.rs index 5f85e4301..012f2ef7b 100644 --- a/crates/xlsx/src/cfb.rs +++ b/crates/xlsx/src/cfb.rs @@ -298,11 +298,6 @@ impl<'a> Compound<'a> { Ok(None) } - /// Whether the root storage holds a stream called `name`, without reading it. - pub(crate) fn has_root_stream(&self, name: &str) -> bool { - matches!(self.root_entry(name), Ok(Some(_))) - } - /// The contents of the stream called `name` (case-insensitive) directly under the root /// storage, or `None` when there is no such stream. pub(crate) fn root_stream(&self, name: &str) -> Result>, String> { diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index d12cc7961..11ac0b51e 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -484,6 +484,45 @@ fn dialog_sheets_are_skipped_not_read_as_worksheets() { assert!(report.warnings.iter().any(|w| w.contains("Dialog sheets")), "{:?}", report.warnings); } +/// A workbook opens on the sheet it was saved on (WINDOW1's `itabCur`), as Excel does: counted +/// over all the file's sheets, skipped ones included. A hidden or skipped sheet, or no window +/// record, falls back to the first visible sheet. +#[test] +fn opens_on_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().map(|s| s.name.clone()).unwrap_or_default() + }; + assert_eq!(book(&window1(2)), "Sheet2", "itabCur 2 is Sheet2, counting the skipped dialog sheet"); + assert_eq!(book(&window1(1)), "Sheet1"); + assert_eq!(book(&window1(3)), "Sheet1", "a hidden sheet falls back to the first visible one"); + assert_eq!(book(&window1(0)), "Sheet1", "a skipped sheet falls back to the first visible one"); + assert_eq!(book(&window1(99)), "Sheet1", "an index past the sheets falls back too"); + assert_eq!(book(&[]), "Sheet1", "no window record"); + assert_eq!(book(&rec(0x003D, &u16s(&[0, 0, 0, 0, 0]))), "Sheet1", "a window record too short for the index"); +} + #[test] fn sheets_share_one_record_budget() { // Many BoundSheet records point into one long substream (at successive nested BOFs), so each @@ -509,10 +548,13 @@ fn sheets_share_one_record_budget() { 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_budgeted(&file, crate::xls::Budget::with_max_records(100_000), crate::FunctionInfo::default()), + crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 100_000, ..Default::default() }, crate::FunctionInfo::default()), Err(IoError::TooLarge(_)) )); - assert!(crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(10_000_000), crate::FunctionInfo::default()).is_ok()); + assert!( + crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 10_000_000, ..Default::default() }, crate::FunctionInfo::default()) + .is_ok() + ); // Two sheets at the same offset are refused outright. let mut dup = bof(0x0005); @@ -768,15 +810,17 @@ fn xls_short_window2_is_ignored() { 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| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap(); - let (wb, report) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap(); + let left = (0..1000) + .find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok()) + .unwrap(); + let (wb, report) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).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, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap(); + let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap(); assert!(ftext(&wb, "B6").is_some()); } @@ -798,12 +842,14 @@ fn shared_formulas_do_not_starve_a_later_sheets_text() { 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| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap(); - let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap(); + let left = (0..1000) + .find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok()) + .unwrap(); + let (wb, _) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).unwrap(); assert_eq!(v(&wb, 1, "A1"), Value::text("abcdefgh")); assert_eq!(ftext(&wb, "B6"), None); assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0)); - let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap(); + let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap(); assert!(ftext(&wb, "B6").is_some()); } @@ -826,7 +872,7 @@ fn xls_duplicate_sheet_names_are_refused() { g.extend(&sheet); compound("Workbook", &g) }; - let err = crate::xls::read_xls_budgeted(&file("One", "one"), crate::xls::Budget::default(), Default::default()).unwrap_err().to_string(); + let err = crate::xls::read_xls_budgeted(&file("One", "one"), Default::default(), Default::default()).unwrap_err().to_string(); assert!(err.contains("already taken"), "{err}"); - assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), crate::xls::Budget::default(), Default::default()).is_ok()); + assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), Default::default(), Default::default()).is_ok()); } diff --git a/crates/xlsx/src/xls.rs b/crates/xlsx/src/xls.rs index 9049c6b12..5428e909e 100644 --- a/crates/xlsx/src/xls.rs +++ b/crates/xlsx/src/xls.rs @@ -1,54 +1,24 @@ -//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public -//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream): -//! . -//! -//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including -//! shared and array formulas), cell formatting (number formats, fonts, fills, borders, -//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date -//! system, merged cells, column widths, row heights, defined names, print areas, print titles -//! and the calculation settings (mode, iteration, precision as displayed). Everything else -//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a -//! warning. -//! Encrypted workbooks and pre-BIFF8 versions are refused. +//! Legacy Excel 97-2003 `.xls` (BIFF8) import. `craft-xls` reads the file (the compound file, +//! the Workbook stream, cells, formulas as text, formats, layout and names); this module maps +//! its workbook onto GridCraft's model: formulas parsed, Excel's implicit `@` for legacy +//! formulas, widths and heights in pixels, formats interned as styles, sheet-name rules, the +//! sheet the workbook opens on, print areas and titles, and the warnings worded for GridCraft. //! //! Every formula in the file is a legacy (pre-dynamic-array) one, so it is read with Excel's //! implicit `@` (see `array_formula.rs`), like an `.xlsx` formula saved without `cm`. -mod formula; -mod ftab; -mod records; -mod styles; - -use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook}; +use craft_xls as x; +use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value}; +use gridcraft_model::style::Protection; +use gridcraft_model::{ + Alignment, BorderLine, BorderStyle, Borders, CalcMode, CalcSettings, Cell, Color, DefinedName, Fill, Font, Formula, HAlign, LineInfo, NumFmt, + PatternType, Sheet, Style, StyleId, StyleTable, Underline, VAlign, VertAlign, Visibility, Workbook, +}; -use crate::cfb::Compound; use crate::tables::{col_width_to_px, pt_to_px}; use crate::{FunctionInfo, 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; -/// WsBool (MS-XLS 2.4.351): its first byte's `fDialog` bit marks a dialog sheet, whose BOF says worksheet. -const WSBOOL: u16 = 0x0081; -const WSBOOL_DIALOG: u8 = 0x10; pub(crate) fn invalid(msg: impl Into) -> IoError { IoError::Format(format!("XLS: {}", msg.into())) @@ -61,197 +31,105 @@ const ENCRYPTED: &str = /// 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") -} - -/// Most shared formulas one sheet expands, and the longest template text one attach may -/// produce: a template shared by its followers was costing every one of them a full decode and -/// print however often that could never succeed, so each anchor is tried once and failed ones -/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their -/// cached values. -const MAX_SHARED_ATTACHES: usize = 262_144; -const MAX_SHARED_TEMPLATE: usize = 8192; - -pub(crate) struct Budget { - cells: usize, - text: usize, - records: usize, - max_records: usize, - /// A shared formula's text was refused: no later shared formula, in any sheet, is expanded. - shared_text_spent: bool, -} - -impl Default for Budget { - fn default() -> Self { - Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS, shared_text_spent: false } - } -} - -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() } - } - /// A budget with only `left` bytes of text allowance (tests). - #[cfg(test)] - pub(crate) fn with_text_left(left: usize) -> Self { - Budget { text: MAX_TEXT.saturating_sub(left), ..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(()) - } - /// Charges `len` bytes of text if they fit the allowance, leaving it untouched otherwise: - /// for text the import can do without (expanded shared formulas keep their cached values). - fn try_text(&mut self, len: usize) -> bool { - let total = self.text.saturating_add(len); - let fits = total <= MAX_TEXT; - if fits { - self.text = total; - } - fits - } - /// [`try_text`](Self::try_text) for a shared formula's text. The first refusal ends shared - /// expansion for the whole workbook, so a shorter formula on a later sheet doesn't fit in the - /// room the refused one left, and refused formulas aren't decoded again on each sheet. - fn try_shared_text(&mut self, len: usize) -> bool { - self.shared_text_spent = self.shared_text_spent || !self.try_text(len); - !self.shared_text_spent - } - fn text(&mut self, len: usize) -> Result<(), IoError> { - self.text = self.text.saturating_add(len); - if self.text > MAX_TEXT { - return Err(IoError::TooLarge("XLS: more than 256 MB of text".into())); - } - Ok(()) - } -} - -struct BoundSheet { - pos: usize, - name: String, - visibility: Visibility, - kind: u8, -} - -struct Lbl { - name: String, - builtin: Option, - hidden: bool, - macro_name: bool, - itab: u16, - rgce: Vec, - rgcb: Vec, -} - -#[derive(Default)] -struct Globals { - sheets: Vec, - date1904: bool, - formats: HashMap, - styles: styles::StyleRecords, - sst: Vec>, - links: Links, - names: Vec, - precision_as_displayed: bool, + x::is_xls(bytes) } /// Reads an Excel 97-2003 workbook, with the default [`FunctionInfo`]. Inspect the report's /// warnings for what was skipped. pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { - read_xls_budgeted(bytes, Budget::default(), FunctionInfo::default()) + read_xls_budgeted(bytes, x::Limits::default(), FunctionInfo::default()) } /// [`read_xls`] with the function library's facts, which decide where the legacy formulas get /// Excel's implicit `@`. pub fn read_xls_with(bytes: &[u8], functions: FunctionInfo) -> Result<(Workbook, ReadReport), IoError> { - read_xls_budgeted(bytes, Budget::default(), functions) + read_xls_budgeted(bytes, x::Limits::default(), functions) } -/// [`read_xls_with`] with an explicit resource budget. -pub(crate) fn read_xls_budgeted(bytes: &[u8], mut budget: Budget, functions: FunctionInfo) -> 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")); - } - }; +fn error(e: x::Error) -> IoError { + match e { + x::Error::Encrypted => invalid(ENCRYPTED), + x::Error::Biff5 => invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again."), + x::Error::TooLarge(why) => IoError::TooLarge(format!("XLS: {why}")), + x::Error::Invalid(why) => invalid(why), + e => invalid(e.to_string()), + } +} + +/// GridCraft's wording of what the read skipped. +fn warning(w: &x::Warning) -> String { + use x::Warning as W; + match w { + W::ShortFormatting => "Some cell formatting records were shorter than expected; the formatting present was kept.".into(), + W::ShortNumberFormat => "Some number formats were shorter than their declared length; the characters present were kept.".into(), + W::ChartSheet => "Chart sheets in .xls files are not imported yet and were skipped.".into(), + W::MacroSheet => "Excel 4.0 macro sheets were skipped.".into(), + W::OtherSheet => "VBA modules and other non-worksheet sheets were skipped.".into(), + W::DialogSheet => "Dialog sheets and unreadable sheets were skipped.".into(), + W::MissingSharedString => "Some cells refer to missing shared strings and were left empty.".into(), + W::DataTable => "What-if data tables are not supported; their values were kept.".into(), + W::Comments => "Cell comments in .xls files are not imported yet.".into(), + W::Drawings => "Pictures, charts and shapes in .xls files are not imported yet.".into(), + W::ConditionalFormatting => "Conditional formatting in .xls files is not imported yet.".into(), + W::DataValidation => "Data validation in .xls files is not imported yet.".into(), + W::Hyperlinks => "Hyperlinks in .xls files are not imported yet.".into(), + W::PivotTables => "PivotTables in .xls files are not imported yet; their values were kept.".into(), + W::CellsOutsideGrid(n) => format!("{n} cells outside the grid were skipped."), + W::FormulaKept(why) => format!("Some formulas use {why}; their saved results were kept as values."), + W::NameSkipped(why) => format!("Some defined names use {why} and were skipped."), + W::MacroNames => "Macro names were skipped.".into(), + w => format!("{w}."), + } +} + +/// [`read_xls_with`] with explicit limits. +pub(crate) fn read_xls_budgeted(bytes: &[u8], limits: x::Limits, functions: FunctionInfo) -> Result<(Workbook, ReadReport), IoError> { + let read = x::read_with(bytes, limits).map_err(error)?; + let book = read.workbook; let mut report = ReadReport::default(); - let g = globals(&stream, &mut budget, &mut report)?; + for w in &read.warnings { + report.warn(warning(w)); + } let mut wb = Workbook::new(); wb.sheets.clear(); - wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; - // One model style per XF. - let styles = styles::build(&g.styles, &g.formats, &mut wb); - if g.styles.short { - report.warn("Some cell formatting records were shorter than expected; the formatting present was kept."); + wb.date_system = if book.date_1904 { DateSystem::D1904 } else { DateSystem::D1900 }; + // One model style per XF, in file order, as the cells refer to them. + if let Some(font) = &book.normal_font { + wb.styles = StyleTable::with_default(Style { font: font_of(font), ..Style::default() }); } + let styles: Vec = book.formats.iter().map(|f| wb.styles.intern(style_of(f))).collect(); + wb.cell_styles = book.cell_styles.iter().map(|(name, f)| (name.clone(), style_of(f))).collect(); - // BoundSheet8 index → model sheet index. - let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); - let mut parsed = Vec::new(); - // Names of the readable sheets so far: `wb.sheets` is only filled once every sheet is parsed. let mut names: Vec<&str> = Vec::new(); - 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)?; - if names.iter().any(|n| n.eq_ignore_ascii_case(&bs.name)) { + for s in &book.sheets { + wb.check_sheet_name(&s.name, None).map_err(invalid)?; + if names.iter().any(|n| n.eq_ignore_ascii_case(&s.name)) { return Err(invalid("That name is already taken. Try a different one.")); } - let Some(st) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { - report.warn("Dialog sheets and unreadable sheets were skipped."); - model_index.push(None); - continue; - }; - names.push(&bs.name); - model_index.push(Some(parsed.len())); - parsed.push((bs.visibility, st)); + names.push(&s.name); } - // Every sheet's mandatory text is charged by now; shared formulas take what is left. - for (visibility, st) in parsed { - let (mut sheet, calc) = finish_sheet(st, &mut budget, &mut report); - sheet.visibility = visibility; - // Every worksheet repeats the workbook's calculation settings; Excel applies the first. - if wb.sheets.is_empty() { - wb.calc = calc; - } + let mut unparsed = false; + for s in &book.sheets { + let sheet = sheet_of(s, &styles, &mut unparsed); wb.sheets.push(Arc::new(sheet)); } - if wb.sheets.is_empty() { - return Err(invalid("the workbook has no worksheets GridCraft can read")); + if unparsed { + report.warn("Some formulas use syntax GridCraft can't parse yet; their saved results were kept as values."); + } + wb.calc = calc_of(&book.calc); + wb.calc.precision_as_displayed = book.precision_as_displayed; + for n in &book.names { + wb.names.push(DefinedName { name: n.name.clone(), scope: n.scope, formula: n.formula.clone(), comment: String::new(), hidden: n.hidden }); } - wb.calc.precision_as_displayed = g.precision_as_displayed; - defined_names(&g, &model_index, &mut wb, &mut report); // Every .xls formula is a legacy (pre-dynamic-array) one: read with Excel's implicit `@`, like // an .xlsx formula without `cm`. Ctrl+Shift+Enter arrays are read as they are. let legacy = wb.sheets.iter().enumerate().map(|(si, s)| (si, legacy_cells(s))).collect(); crate::array_formula::apply_legacy(&mut wb, legacy, functions); - wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); + // The sheet the file was saved on, as Excel opens it, when it was read and is visible; else the + // first visible sheet. + let saved = book.active_sheet.filter(|&i| wb.sheets.get(i).is_some_and(|s| s.visibility == Visibility::Visible)); + wb.set_active_sheet(saved.or_else(|| wb.sheets.iter().position(|s| s.visibility == Visibility::Visible)).unwrap_or(0)); // The overview comes first. report.warnings.insert( 0, @@ -265,711 +143,253 @@ fn legacy_cells(sheet: &Sheet) -> Vec { sheet.cells.iter().filter(|(_, c)| c.formula.as_ref().is_some_and(|f| f.array.is_none())).map(|(at, _)| at).collect() } -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 cell_ref(c: x::CellRef) -> CellRef { + CellRef::new(c.row, c.col) } -fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result { - let mut recs = Records::new(stream, 0); - let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?; - let (vers, dt) = bof(&first)?; - if vers != BIFF8 { - return Err(invalid(if vers == 0x0500 { - "Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again." - } else { - "unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)" - })); - } - if dt != 0x0005 { - return Err(invalid("the Workbook stream does not start with the workbook globals")); - } - let mut g = Globals::default(); - let mut 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, - 0x000E => g.precision_as_displayed = c.u16()? == 0, - 0x041E => { - let ifmt = c.u16()?; - let cch = usize::from(c.u16()?); - let high = c.u8()? & 1 != 0; - // Some writers count one character more than the record holds; Excel opens - // those files, so keep the characters that are there. - let fit = c.remaining() / if high { 2 } else { 1 }; - if cch > fit { - report.warn("Some number formats were shorter than their declared length; the characters present were kept."); - } - let code = c.chars(cch.min(fit), high)?; - budget.text(code.len())?; - g.formats.insert(ifmt, code); - } - 0x00E0 => g.styles.xf(&mut c, MAX_XFS), - 0x0031 => g.styles.font(&mut c), - 0x0092 => g.styles.palette(&mut c), - 0x0293 => g.styles.style(&mut c), - 0x00FC => g.sst = shared_strings(&mut c, budget)?, - 0x01AE => { - let _ctab = c.u16()?; - let cch = c.u16()?; - g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] }); - } - 0x0023 => { - // ExternName: flags, then an AddinUdf or ExternDocName body; both start with - // four bytes before the name. - c.u16()?; - c.skip(4)?; - let name = c.short_string()?; - if let Some(book) = g.links.books.last_mut() - && book.names.len() < MAX_NAMES - { - book.names.push(name); - } - } - 0x0017 => { - let n = usize::from(c.u16()?); - for _ in 0..n.min(c.remaining() / 6) { - let book = c.u16()?; - let first = c.u16()? as i16; - let last = c.u16()? as i16; - g.links.xti.push(Xti { book, first, last }); - } - } - 0x0018 => { - if g.names.len() >= MAX_NAMES { - continue; - } - let lbl = label(&mut c)?; - g.links.names.push(lbl.name.clone()); - g.names.push(lbl); - } - _ => {} - } - } - // The same test `defined_names` applies, before any cell formula is decoded. - let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect(); - g.links.skipped = skipped; - Ok(g) +fn range(r: x::Range) -> RangeRef { + RangeRef::new(cell_ref(r.first), cell_ref(r.last)) } -/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name). -const BUILTIN_NAMES: [&str; 14] = [ - "Consolidate_Area", - "Auto_Open", - "Auto_Close", - "Extract", - "Database", - "Criteria", - "Print_Area", - "Print_Titles", - "Recorder", - "Data_Form", - "Auto_Activate", - "Auto_Deactivate", - "Sheet_Title", - "_FilterDatabase", -]; - -fn label(c: &mut Cursor<'_, '_>) -> Result { - let flags = c.u16()?; - let _key = c.u8()?; - let cch = usize::from(c.u8()?); - let cce = usize::from(c.u16()?); - c.skip(2)?; - let itab = c.u16()?; - c.skip(4)?; - let high = c.u8()? & 1 != 0; - let raw = c.chars(cch, high)?; - let builtin = flags & 0x0020 != 0; - let (name, builtin) = if builtin { - let code = raw.chars().next().map_or(0, |ch| ch as u32); - let code = u8::try_from(code).unwrap_or(u8::MAX); - (BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code)) - } else { - (raw, None) - }; - let rest = c.rest(); - let rgce = rest.get(..cce).unwrap_or(&rest).to_vec(); - let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec(); - Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb }) -} - -fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result>, IoError> { - let _total = c.u32()?; - let unique = c.u32()? as usize; - if unique > MAX_STRINGS { - return Err(IoError::TooLarge("XLS: too many shared strings".into())); - } - // Each string takes at least three bytes: the declared count can't exceed what's there. - let mut out = Vec::with_capacity(unique.min(c.remaining() / 3)); - for _ in 0..unique { - if c.remaining() == 0 { - break; - } - let s = c.rich_string()?; - budget.text(s.len())?; - out.push(Arc::from(s)); - } - Ok(out) -} - -/// A formula cell waiting for its shared or array formula (which follows it in the file). -struct Pending { - pos: CellRef, - anchor: CellRef, -} - -/// A parsed formula: tokens and extra data. -struct Parsed { - rgce: Vec, - rgcb: Vec, -} - -fn parsed(c: &mut Cursor<'_, '_>) -> Result { - let cce = usize::from(c.u16()?); - let rest = c.rest(); - if cce > rest.len() { - return Err(invalid("formula runs past the end of its record")); - } - Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() }) -} - -struct SheetState<'g> { - g: &'g Globals, - styles: &'g [StyleId], - sheet: Sheet, - pending: Vec, - shared: HashMap, - arrays: HashMap, - last_formula: Option, - /// The cell waiting for its String record, with its style: a blank cell isn't stored, so a - /// formula kept as its value may have no cell to receive the text. - pending_string: Option<(CellRef, StyleId)>, - dropped: usize, - unsupported: Vec<&'static str>, - calc: CalcSettings, -} - -impl SheetState<'_> { - /// Records why a formula was kept as its value (each reason once). - fn unsupported(&mut self, why: &'static str) { - if !self.unsupported.contains(&why) { - self.unsupported.push(why); - } - } - - fn style(&self, ixfe: u16) -> StyleId { - self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT) - } - - fn pos(&mut self, row: u16, col: u16) -> Option { - let (r, c) = (u32::from(row), u32::from(col)); - if r >= MAX_ROWS || c >= MAX_COLS.min(256) { - self.dropped += 1; - return None; - } - Some(CellRef::new(r, c)) - } - - fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result, IoError> { - let Some(pos) = self.pos(row, col) else { return Ok(None) }; - let style = self.style(ixfe); - if value.is_empty() && style == StyleId::DEFAULT { - return Ok(Some(pos)); - } - budget.cell()?; - self.sheet.cells.set(pos, Cell { value, formula: None, style, py: None }); - Ok(Some(pos)) - } - - /// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be. - fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option) { - match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) { - 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; - } - // A one-cell Ctrl+Shift+Enter array keeps its range, as when an .xlsx is read: - // it evaluates as an array, not with implicit intersection. - f.array = array; - 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, py: None }); - } - } - Err(Unsupported(why)) => self.unsupported(why), - } +fn value(v: &x::Value) -> Value { + match v { + x::Value::Number(n) => Value::number(*n), + x::Value::Text(s) => Value::Text(s.clone()), + x::Value::Bool(b) => Value::Bool(*b), + x::Value::Error(e) => Value::Error(CellError::parse(e.as_str()).unwrap_or(CellError::NA)), + _ => Value::Empty, } } -/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100. -fn rk(v: u32) -> Value { - let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) }; - Value::number(if v & 1 != 0 { n / 100.0 } else { n }) -} - -/// Attaches every pending formula to its anchor's template, returning how many shared -/// templates were expanded. An anchor whose template can't be imported is refused once: -/// its followers re-decode the same template for nothing, so they are skipped instead (they -/// keep their cached values). -fn resolve_pending( - st: &mut SheetState<'_>, - budget: &mut Budget, - arrays: &HashMap, - shared: &HashMap, - pending: &[Pending], -) -> usize { - let mut failed: HashSet = HashSet::new(); - let mut shared_attaches = 0usize; - for p in pending { - if let Some((range, f)) = arrays.get(&p.anchor) { - if p.pos == p.anchor { - st.attach(p.pos, f, Mode::Cell, Some(*range)); - } - } else if let Some(f) = shared.get(&p.anchor) { - if budget.shared_text_spent { - st.unsupported("more shared-formula text than the import allows"); - continue; - } - if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES { - continue; - } - shared_attaches += 1; - st.attach(p.pos, f, Mode::Shared, None); - let len = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).map(|f| f.text.len()); - // Every follower keeps its own text: charge it to the workbook's text allowance, and - // past it drop the formula again so the cell keeps its cached value. - if let Some(len) = len - && !budget.try_shared_text(len) - { - if let Some(cell) = st.sheet.cells.get_mut(p.pos) { - cell.formula = None; - } - st.unsupported("more shared-formula text than the import allows"); - continue; - } - let imported = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE); - if !imported { - failed.insert(p.anchor); - } - } else { - st.unsupported("a shared formula that is missing from the file"); - } - } - if shared_attaches >= MAX_SHARED_ATTACHES { - st.unsupported("more shared formulas than the expansion cap; some formulas kept their values"); - } - shared_attaches -} - -/// The other cells of a legacy array formula hold only cached results: dropped, as when an -/// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself -/// couldn't be imported, they keep their values. -fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) { - for p in pending { - let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos)); - if p.pos != p.anchor && filled { - let style = st.sheet.style_id(p.pos); - st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style, py: None }); - } - } -} - -fn worksheet<'g>( - stream: &[u8], - bs: &BoundSheet, - g: &'g Globals, - styles: &'g [StyleId], - budget: &mut Budget, - report: &mut ReadReport, -) -> Result>, IoError> { - if bs.pos >= stream.len() { - return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); - } - let mut recs = Records::new(stream, bs.pos); - let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?; - let (vers, dt) = bof(&first)?; - if vers != BIFF8 || dt != 0x0010 { - return Ok(None); - } - let mut st = SheetState { - g, - styles, - sheet: Sheet::new(&bs.name), - pending: vec![], - shared: HashMap::new(), - arrays: HashMap::new(), - last_formula: None, - pending_string: None, - dropped: 0, - unsupported: vec![], - calc: CalcSettings::default(), - }; - let mut depth = 1usize; - while let Some(rec) = recs.next_record()? { - budget.record()?; - match rec.id { - BOF => { - // An embedded chart's substream. - depth += 1; - continue; - } - EOF => { - depth -= 1; - if depth == 0 { - break; - } - continue; - } - _ if depth > 1 => continue, - WSBOOL if rec.parts.first().and_then(|p| p.first()).is_some_and(|b| b & WSBOOL_DIALOG != 0) => return Ok(None), - _ => {} - } - sheet_record(&rec, &mut st, budget, report)?; - } - Ok(Some(st)) -} - -/// Attaches a sheet's shared and array formulas (their definitions follow the first cell that -/// uses them). Run for every sheet only after all sheets' mandatory text is charged, so the -/// optional shared-formula text can't use up the allowance a later sheet's strings need. -fn finish_sheet(mut st: SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> (Sheet, CalcSettings) { - let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared)); - let pending = std::mem::take(&mut st.pending); - resolve_pending(&mut st, budget, &arrays, &shared, &pending); - blank_array_followers(&mut st, &pending); - if st.dropped > 0 { - report.warn(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.")); - } - (st.sheet, st.calc) -} - -/// The shortest WINDOW2 a file holds (the chart-sheet form); a shorter one is truncated. -const WINDOW2_MIN_LEN: usize = 10; - -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, style)) = 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); - } else { - st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style, py: None }); - } - } - } - 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)); - } - } - 0x000D => { - st.calc.mode = match c.u16()? as i16 { - 0 => CalcMode::Manual, - -1 => CalcMode::AutomaticExceptTables, - _ => CalcMode::Automatic, - } - } - 0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)), - 0x0011 => st.calc.iterative = c.u16()? == 1, - 0x0010 => st.calc.set_max_change(c.f64()?), - 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); - } - } - 0x023E => { - let flags = if c.remaining() >= WINDOW2_MIN_LEN { c.u16().ok() } else { None }; - let Some(flags) = flags else { return Ok(()) }; - let on = |bit: u16| flags & bit != 0; - st.sheet.show_formulas = on(0x0001); - st.sheet.show_gridlines = on(0x0002); - st.sheet.show_headings = on(0x0004); - st.sheet.show_zeros = on(0x0010); - st.sheet.right_to_left = on(0x0040); - } - 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, py: None }); - st.last_formula = Some(pos); - if string_result { - st.pending_string = Some((pos, style)); - } - // PtgExp: part of a shared or array formula anchored at another cell. - if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) { - let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0); - let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)]))); - st.pending.push(Pending { pos, anchor }); - return Ok(()); - } - st.attach(pos, &f, Mode::Cell, None); - Ok(()) -} - -fn defined_names(g: &Globals, model_index: &[Option], wb: &mut Workbook, report: &mut ReadReport) { - let scope_of = |itab: u16| -> Option> { - if itab == 0 { - return Some(None); - } - model_index.get(usize::from(itab) - 1).copied().flatten().map(Some) - }; - for lbl in &g.names { - let Some(scope) = scope_of(lbl.itab) else { continue }; - let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) { - Ok(e) => gridcraft_formula::print(&e), - Err(_) if lbl.rgce.is_empty() => continue, - Err(Unsupported(why)) => { - report.warn(format!("Some defined names use {why} and were skipped.")); - continue; - } +fn calc_of(c: &x::Calc) -> CalcSettings { + let mut calc = CalcSettings::default(); + if let Some(mode) = c.mode { + calc.mode = match mode { + x::CalcMode::Manual => CalcMode::Manual, + x::CalcMode::AutomaticExceptTables => CalcMode::AutomaticExceptTables, + _ => CalcMode::Automatic, }; - 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; - } + } + if let Some(n) = c.max_iterations { + calc.set_max_iterations(u32::from(n)); + } + if let Some(on) = c.iterative { + calc.iterative = on; + } + if let Some(d) = c.max_change { + calc.set_max_change(d); + } + calc +} + +/// One worksheet. `unparsed` is set when a formula's text is one GridCraft can't parse: the cell +/// keeps its saved result. +fn sheet_of(s: &x::Sheet, styles: &[StyleId], unparsed: &mut bool) -> Sheet { + let style = |f: Option| f.and_then(|i| styles.get(usize::from(i)).copied()).unwrap_or(StyleId::DEFAULT); + let mut sheet = Sheet::new(&s.name); + sheet.visibility = match s.visibility { + x::Visibility::Hidden => Visibility::Hidden, + x::Visibility::VeryHidden => Visibility::VeryHidden, + _ => Visibility::Visible, + }; + for (at, c) in &s.cells { + let formula = c.formula.as_ref().and_then(|f| { + let mut parsed = Formula::new(&f.text); + if parsed.expr.is_none() { + *unparsed = true; + return None; } - 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 }), + // A one-cell Ctrl+Shift+Enter array keeps its range, as when an .xlsx is read: it + // evaluates as an array, not with implicit intersection. + parsed.array = f.array.map(range); + Some(Arc::new(parsed)) + }); + sheet.cells.set(cell_ref(*at), Cell { value: value(&c.value), formula, style: style(c.format), py: None }); + } + sheet.merges = s.merges.iter().map(|r| range(*r)).collect(); + if let Some(h) = s.default_row_height { + sheet.default_row_height = pt_to_px(f64::from(h) / 20.0); + } + for (&col, c) in &s.columns { + let px = col_width_to_px(f64::from(c.width) / 256.0); + let info = LineInfo { + size: (c.custom_width || px != sheet.default_col_width).then_some(px), + hidden: c.hidden, + outline: c.outline, + collapsed: c.collapsed, + style: c.format.map(|f| style(Some(f))).filter(|&id| id != StyleId::DEFAULT), + ..LineInfo::default() + }; + if info != LineInfo::default() { + sheet.cols.insert(u32::from(col), info); } } + for (&r, row) in &s.rows { + let px = pt_to_px(f64::from(row.height) / 20.0); + let info = LineInfo { + custom: row.custom_height, + hidden: row.hidden, + size: (row.custom_height || (px - sheet.default_row_height).abs() > 0.5).then_some(px), + outline: row.outline, + collapsed: row.collapsed, + style: row.format.map(|f| style(Some(f))).filter(|&id| id != StyleId::DEFAULT), + }; + if info != LineInfo::default() { + sheet.rows.insert(r, info); + } + } + if let Some(v) = s.view { + sheet.show_formulas = v.show_formulas; + sheet.show_gridlines = v.show_gridlines; + sheet.show_headings = v.show_headings; + sheet.show_zeros = v.show_zeros; + sheet.right_to_left = v.right_to_left; + } + if let Some(text) = &s.print_area { + sheet.print.print_area = crate::read::parse_print_area(text); + } + if let Some(text) = &s.print_titles { + let (rows, cols) = crate::read::parse_print_titles(text); + sheet.print.title_rows = rows; + sheet.print.title_cols = cols; + } + sheet } -#[cfg(test)] -mod budget_tests { - use super::*; - - #[test] - fn a_refused_shared_formula_ends_shared_expansion_for_later_sheets() { - let mut budget = Budget::with_text_left(10); - assert!(budget.try_shared_text(8)); - assert!(!budget.try_shared_text(8), "over the allowance"); - // a shorter formula would fit the 2 bytes left, but the cut-off holds - assert!(!budget.try_shared_text(1)); - assert!(budget.try_text(1), "text the import needs is still charged normally"); +fn color(c: x::Color) -> Color { + match c { + x::Color::Rgb(rgb) => Color::Rgb(rgb), + _ => Color::Auto, + } +} + +fn font_of(f: &x::Font) -> Font { + let mut font = Font::default(); + if let Some(name) = &f.name { + font.name = name.clone(); + } + if let Some(size) = f.size { + font.size = size; + } + font.bold = f.bold; + font.italic = f.italic; + font.strike = f.strike; + font.underline = match f.underline { + x::Underline::Single => Underline::Single, + x::Underline::Double => Underline::Double, + x::Underline::SingleAccounting => Underline::SingleAccounting, + x::Underline::DoubleAccounting => Underline::DoubleAccounting, + _ => Underline::None, + }; + font.vert = match f.script { + x::Script::Superscript => VertAlign::Superscript, + x::Script::Subscript => VertAlign::Subscript, + _ => VertAlign::Baseline, + }; + font.color = color(f.color); + font.family = f.family; + font.charset = f.charset; + font +} + +fn border_style(s: x::BorderStyle) -> BorderStyle { + use x::BorderStyle as B; + match s { + B::Thin => BorderStyle::Thin, + B::Medium => BorderStyle::Medium, + B::Dashed => BorderStyle::Dashed, + B::Dotted => BorderStyle::Dotted, + B::Thick => BorderStyle::Thick, + B::Double => BorderStyle::Double, + B::Hair => BorderStyle::Hair, + B::MediumDashed => BorderStyle::MediumDashed, + B::DashDot => BorderStyle::DashDot, + B::MediumDashDot => BorderStyle::MediumDashDot, + B::DashDotDot => BorderStyle::DashDotDot, + B::MediumDashDotDot => BorderStyle::MediumDashDotDot, + B::SlantDashDot => BorderStyle::SlantDashDot, + _ => BorderStyle::None, + } +} + +fn line(l: x::BorderLine) -> BorderLine { + let style = border_style(l.style); + if style == BorderStyle::None { + return BorderLine::default(); + } + BorderLine { style, color: color(l.color) } +} + +fn pattern(p: x::Pattern) -> PatternType { + use x::Pattern as P; + match p { + P::Solid => PatternType::Solid, + P::Gray50 => PatternType::Gray50, + P::Gray75 => PatternType::Gray75, + P::Gray25 => PatternType::Gray25, + P::DarkHorizontal => PatternType::DarkHorizontal, + P::DarkVertical => PatternType::DarkVertical, + P::DarkDown => PatternType::DarkDown, + P::DarkUp => PatternType::DarkUp, + P::DarkGrid => PatternType::DarkGrid, + P::DarkTrellis => PatternType::DarkTrellis, + P::LightHorizontal => PatternType::LightHorizontal, + P::LightVertical => PatternType::LightVertical, + P::LightDown => PatternType::LightDown, + P::LightUp => PatternType::LightUp, + P::LightGrid => PatternType::LightGrid, + P::LightTrellis => PatternType::LightTrellis, + P::Gray125 => PatternType::Gray125, + P::Gray0625 => PatternType::Gray0625, + _ => PatternType::None, + } +} + +/// The model style of one XF. +fn style_of(f: &x::CellFormat) -> Style { + let a = f.align; + let b = f.borders; + Style { + font: font_of(&f.font), + num_fmt: if f.number_format.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(&f.number_format) }, + protection: Protection { locked: f.locked, hidden: f.hidden }, + align: Alignment { + h: match a.h { + x::HAlign::Left => HAlign::Left, + x::HAlign::Center => HAlign::Center, + x::HAlign::Right => HAlign::Right, + x::HAlign::Fill => HAlign::Fill, + x::HAlign::Justify => HAlign::Justify, + x::HAlign::CenterAcross => HAlign::CenterAcross, + x::HAlign::Distributed => HAlign::Distributed, + _ => HAlign::General, + }, + v: match a.v { + x::VAlign::Top => VAlign::Top, + x::VAlign::Center => VAlign::Center, + x::VAlign::Justify => VAlign::Justify, + x::VAlign::Distributed => VAlign::Distributed, + _ => VAlign::Bottom, + }, + wrap: a.wrap, + shrink: a.shrink, + indent: a.indent, + rotation: a.rotation, + reading_order: None, + }, + borders: Borders { + left: line(b.left), + right: line(b.right), + top: line(b.top), + bottom: line(b.bottom), + diag_down: line(b.diag_down), + diag_up: line(b.diag_up), + }, + fill: match pattern(f.fill.pattern) { + PatternType::None => Fill::default(), + PatternType::Solid => Fill::solid(color(f.fill.fg)), + p => Fill { pattern: p, fg: color(f.fill.fg), bg: color(f.fill.bg) }, + }, + ..Style::default() } } diff --git a/crates/xlsx/src/xls/formula.rs b/crates/xlsx/src/xls/formula.rs deleted file mode 100644 index 3de860707..000000000 --- a/crates/xlsx/src/xls/formula.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an -//! expression tree. Anything outside the common token set is reported as unsupported, and the -//! caller keeps the cell's cached value instead. - -use std::sync::Arc; - -use gridcraft_core::{CellError, CellRef}; -use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp}; - -use super::ftab::FTAB; - -/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula). -const MAX_RGCE: usize = 16 * 1024; -/// Values in one array constant at most. -const MAX_ARRAY: usize = 65_536; - -/// Why a formula could not be turned back into text. -#[derive(Debug, PartialEq)] -pub(super) struct Unsupported(pub &'static str); - -/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store -/// row/column offsets for relative parts; names store offsets for 3-D refs too. -#[derive(Clone, Copy, PartialEq)] -pub(super) enum Mode { - Cell, - Shared, - Name, -} - -/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function -/// names) are resolved; external workbooks are never followed. -#[derive(Default)] -pub(super) struct SupBook { - pub internal: bool, - pub addin: bool, - pub names: Vec, -} - -/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets. -#[derive(Clone, Copy)] -pub(super) struct Xti { - pub book: u16, - pub first: i16, - pub last: i16, -} - -/// Workbook-level tables formulas refer to. -#[derive(Default)] -pub(super) struct Links { - /// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets). - pub sheets: Vec, - /// Defined names (Lbl records), in order; `None` for names that can't be referenced. - pub names: Vec, - /// Per defined name: whether the import skips it (its formula can't be decoded). - pub skipped: Vec, - pub books: Vec, - pub xti: Vec, -} - -pub(super) fn function(iftab: u16) -> Option<(&'static str, i8)> { - FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a)) -} - -fn error_code(code: u8) -> Option { - Some(match code { - 0x00 => CellError::Null, - 0x07 => CellError::Div0, - 0x0F => CellError::Value, - 0x17 => CellError::Ref, - 0x1D => CellError::Name, - 0x24 => CellError::Num, - 0x2A => CellError::NA, - 0x2B => CellError::GettingData, - _ => return None, - }) -} - -pub(super) fn cell_error(code: u8) -> CellError { - error_code(code).unwrap_or(CellError::NA) -} - -struct Bytes<'a> { - b: &'a [u8], - pos: usize, -} - -const TRUNC: Unsupported = Unsupported("truncated formula"); - -impl Bytes<'_> { - fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> { - let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?; - self.pos += n; - Ok(s) - } - fn u8(&mut self) -> Result { - self.take(1)?.first().copied().ok_or(TRUNC) - } - fn u16(&mut self) -> Result { - let s = self.take(2)?; - Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?])) - } - fn u32(&mut self) -> Result { - Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16)) - } - fn f64(&mut self) -> Result { - let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?; - Ok(f64::from_le_bytes(a)) - } - fn chars(&mut self, cch: usize, high: bool) -> Result { - let units: Vec = if high { - self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() - } else { - self.take(cch)?.iter().map(|&b| u16::from(b)).collect() - }; - Ok(String::from_utf16_lossy(&units)) - } -} - -/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags. -fn col_field(v: u16) -> (u16, bool, bool) { - (v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0) -} - -/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets -/// from `base`, wrapped into the BIFF8 grid (65,536 × 256). -fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor { - let (c, col_rel, row_rel) = col_field(col); - let (r, c) = if relative { - let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) }; - // A 14-bit signed offset; adding modulo 256 gives the same column either way. - let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF }; - (r, c) - } else { - (u32::from(row), u32::from(c) & 0xFF) - }; - Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel } -} - -/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref. -fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind { - let a = anchor(r0, c0, base, relative); - let b = anchor(r1, c1, base, relative); - if a.row == 0 && b.row == 0xFFFF { - return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs); - } - if a.col == 0 && b.col == 0xFF { - return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs); - } - RefKind::Range(a, b) -} - -impl Links { - fn sheet_sel(&self, ixti: u16) -> Result, Unsupported> { - let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?; - let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; - if !book.internal { - return Err(Unsupported("references to other workbooks (external links)")); - } - let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned(); - Ok(match (name(x.first), name(x.last)) { - (Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)), - (Some(a), Some(b)) => Some(SheetSel::Span(a, b)), - // A deleted sheet: the reference is #REF!. - _ => None, - }) - } - - fn extern_name(&self, ixti: u16, index: u32) -> Result { - let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?; - let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?; - if !book.internal && !book.addin { - return Err(Unsupported("names in other workbooks (external links)")); - } - let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?; - book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name")) - } -} - -/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`). -fn function_name(name: &str) -> String { - let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name); - n.to_ascii_uppercase() -} - -/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack -/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens). -const MAX_DEPTH: usize = 256; - -/// Operand stack entries: an expression and its nesting depth. -type Stack = Vec<(Expr, usize)>; - -/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed). -fn pop(stack: &mut Stack, kids: &mut usize) -> Result { - let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; - *kids = (*kids).max(d); - Ok(e) -} - -fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result, Unsupported> { - let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?; - let popped = stack.split_off(at); - *kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max); - Ok(popped.into_iter().map(|(e, _)| e).collect()) -} - -/// Pushes the result of a token one level above the operands it consumed. -fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> { - let depth = kids.saturating_add(1); - if depth > MAX_DEPTH { - return Err(Unsupported("formula nested too deeply")); - } - stack.push((e, depth)); - Ok(()) -} - -/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at -/// `base`. Returns the expression or why it can't be represented. -pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result { - if rgce.len() > MAX_RGCE { - return Err(Unsupported("formula too long")); - } - let mut t = Bytes { b: rgce, pos: 0 }; - let mut extra = Bytes { b: rgcb, pos: 0 }; - let mut stack: Stack = Vec::new(); - while t.pos < rgce.len() { - let ptg = t.u8()?; - let mut kids = 0usize; - // Operand classes (reference / value / array) share one token number. - let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg }; - match id { - 0x03..=0x11 => { - let op = match id { - 0x03 => BinOp::Add, - 0x04 => BinOp::Sub, - 0x05 => BinOp::Mul, - 0x06 => BinOp::Div, - 0x07 => BinOp::Pow, - 0x08 => BinOp::Concat, - 0x09 => BinOp::Lt, - 0x0A => BinOp::Le, - 0x0B => BinOp::Eq, - 0x0C => BinOp::Ge, - 0x0D => BinOp::Gt, - 0x0E => BinOp::Ne, - 0x0F => BinOp::Intersect, - 0x10 => BinOp::Union, - _ => BinOp::Range, - }; - let b = pop(&mut stack, &mut kids)?; - let a = pop(&mut stack, &mut kids)?; - push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?; - } - 0x12..=0x14 => { - let op = match id { - 0x12 => UnOp::Plus, - 0x13 => UnOp::Neg, - _ => UnOp::Percent, - }; - let x = pop(&mut stack, &mut kids)?; - push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?; - } - 0x15 => { - let x = pop(&mut stack, &mut kids)?; - push(&mut stack, kids, Expr::Paren(Box::new(x)))?; - } - 0x16 => push(&mut stack, kids, Expr::Missing)?, - 0x17 => { - let cch = usize::from(t.u8()?); - let high = t.u8()? & 1 != 0; - push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?; - } - 0x19 => { - let kind = t.u8()?; - if kind & 0x04 != 0 { - // PtgAttrChoose: jump table of cOffset + 1 entries. - let n = usize::from(t.u16()?) + 1; - t.take(n * 2)?; - } else { - t.take(2)?; - } - if kind & 0x10 != 0 { - // PtgAttrSum: SUM of the single operand. - let x = pop(&mut stack, &mut kids)?; - push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?; - } - // Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression. - } - 0x1C => { - let e = t.u8()?; - push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?; - } - 0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?, - 0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?, - 0x1F => { - let n = t.f64()?; - if !n.is_finite() { - return Err(Unsupported("invalid number constant")); - } - push(&mut stack, kids, Expr::Number(n))?; - } - 0x20 => { - t.take(7)?; - push(&mut stack, kids, array_constant(&mut extra)?)?; - } - 0x21 => { - let iftab = t.u16()?; - let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?; - let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?; - let args = pop_n(&mut stack, n, &mut kids)?; - push(&mut stack, kids, Expr::Call(name.to_string(), args))?; - } - 0x22 => { - let n = usize::from(t.u8()?); - let tab = t.u16()?; - if tab & 0x8000 != 0 { - return Err(Unsupported("macro sheet commands")); - } - let mut args = pop_n(&mut stack, n, &mut kids)?; - if tab & 0x7FFF == 0x00FF { - // A function called by name (add-in or newer function): the name comes first. - if args.is_empty() { - return Err(Unsupported("malformed function call")); - } - let name = match args.remove(0) { - Expr::Name(n) => function_name(&n), - _ => return Err(Unsupported("function call through an expression")), - }; - push(&mut stack, kids, Expr::Call(name, args))?; - } else { - let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?; - push(&mut stack, kids, Expr::Call(name.to_string(), args))?; - } - } - 0x23 => { - let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1)); - let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?; - // A cell using a name the import drops would calculate to #NAME?: keep its - // saved result instead, as for references to other workbooks. - if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) { - return Err(Unsupported("defined names GridCraft skipped")); - } - push(&mut stack, kids, Expr::Name(name.clone()))?; - } - 0x24 | 0x2C => { - let (r, c) = (t.u16()?, t.u16()?); - let a = anchor(r, c, base, id == 0x2C); - push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?; - } - 0x25 | 0x2D => { - let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); - let kind = area(r0, r1, c0, c1, base, id == 0x2D); - push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?; - } - 0x26 => { - // PtgMemArea: a cache of the sub-expression that follows; skip its extra data. - t.take(6)?; - let n = usize::from(extra.u16()?); - extra.take(n.checked_mul(8).ok_or(TRUNC)?)?; - } - 0x27 | 0x28 => { - t.take(6)?; - } - 0x29 => { - t.take(2)?; - } - 0x2A => { - t.take(4)?; - push(&mut stack, kids, Expr::Error(CellError::Ref))?; - } - 0x2B => { - t.take(8)?; - push(&mut stack, kids, Expr::Error(CellError::Ref))?; - } - 0x39 => { - let ixti = t.u16()?; - let i = t.u32()?; - push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?; - } - 0x3A => { - let ixti = t.u16()?; - let (r, c) = (t.u16()?, t.u16()?); - let a = anchor(r, c, base, mode == Mode::Name); - push( - &mut stack, - kids, - match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }), - None => Expr::Error(CellError::Ref), - }, - )?; - } - 0x3B => { - let ixti = t.u16()?; - let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?); - let kind = area(r0, r1, c0, c1, base, mode == Mode::Name); - push( - &mut stack, - kids, - match links.sheet_sel(ixti)? { - Some(sheet) => Expr::Ref(Reference { sheet, kind }), - None => Expr::Error(CellError::Ref), - }, - )?; - } - 0x3C => { - t.take(6)?; - push(&mut stack, kids, Expr::Error(CellError::Ref))?; - } - 0x3D => { - t.take(10)?; - push(&mut stack, kids, Expr::Error(CellError::Ref))?; - } - 0x01 => return Err(Unsupported("nested shared or array formula reference")), - 0x02 => return Err(Unsupported("what-if data tables")), - 0x18 => return Err(Unsupported("natural-language formulas")), - _ => return Err(Unsupported("unknown formula token")), - } - if stack.len() > MAX_RGCE { - return Err(Unsupported("formula too complex")); - } - } - let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?; - if !stack.is_empty() { - return Err(Unsupported("malformed formula (unused operands)")); - } - Ok(e) -} - -/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order. -fn array_constant(b: &mut Bytes<'_>) -> Result { - let cols = usize::from(b.u8()?) + 1; - let rows = usize::from(b.u16()?) + 1; - if cols.saturating_mul(rows) > MAX_ARRAY { - return Err(Unsupported("array constant too large")); - } - let mut out = Vec::with_capacity(rows); - for _ in 0..rows { - let mut row = Vec::with_capacity(cols); - for _ in 0..cols { - let kind = b.u8()?; - row.push(match kind { - 0x00 => { - b.take(8)?; - Expr::Missing - } - 0x01 => { - let n = b.f64()?; - if !n.is_finite() { - return Err(Unsupported("invalid number constant")); - } - Expr::Number(n) - } - 0x02 => { - let cch = usize::from(b.u16()?); - let high = b.u8()? & 1 != 0; - Expr::Text(Arc::from(b.chars(cch, high)?)) - } - 0x04 => { - let v = b.u8()?; - b.take(7)?; - Expr::Bool(v != 0) - } - 0x10 => { - let e = b.u8()?; - b.take(7)?; - Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?) - } - _ => return Err(Unsupported("unknown array constant value")), - }); - } - out.push(row); - } - Ok(Expr::Array(out)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn links() -> Links { - Links { - sheets: vec!["Sheet1".into(), "My Data".into()], - names: vec!["Rate".into()], - skipped: vec![false], - books: vec![ - SupBook { internal: true, addin: false, names: vec![] }, - SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] }, - SupBook::default(), - ], - xti: vec![ - Xti { book: 0, first: 0, last: 1 }, - Xti { book: 1, first: -2, last: -2 }, - Xti { book: 2, first: 0, last: 0 }, - Xti { book: 0, first: -1, last: -1 }, - ], - } - } - - fn text(rgce: &[u8], rgcb: &[u8]) -> Result { - decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| 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()); - } -} diff --git a/crates/xlsx/src/xls/ftab.rs b/crates/xlsx/src/xls/ftab.rs deleted file mode 100644 index d796419f6..000000000 --- a/crates/xlsx/src/xls/ftab.rs +++ /dev/null @@ -1,376 +0,0 @@ -// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument -// count or -1 when the function takes a variable number of arguments). -pub(super) const FTAB: &[(u16, &str, i8)] = &[ - (0, "COUNT", -1), - (1, "IF", -1), - (2, "ISNA", 1), - (3, "ISERROR", 1), - (4, "SUM", -1), - (5, "AVERAGE", -1), - (6, "MIN", -1), - (7, "MAX", -1), - (8, "ROW", -1), - (9, "COLUMN", -1), - (10, "NA", 0), - (11, "NPV", -1), - (12, "STDEV", -1), - (13, "DOLLAR", -1), - (14, "FIXED", -1), - (15, "SIN", 1), - (16, "COS", 1), - (17, "TAN", 1), - (18, "ATAN", 1), - (19, "PI", 0), - (20, "SQRT", 1), - (21, "EXP", 1), - (22, "LN", 1), - (23, "LOG10", 1), - (24, "ABS", 1), - (25, "INT", 1), - (26, "SIGN", 1), - (27, "ROUND", 2), - (28, "LOOKUP", -1), - (29, "INDEX", -1), - (30, "REPT", 2), - (31, "MID", 3), - (32, "LEN", 1), - (33, "VALUE", 1), - (34, "TRUE", 0), - (35, "FALSE", 0), - (36, "AND", -1), - (37, "OR", -1), - (38, "NOT", 1), - (39, "MOD", 2), - (40, "DCOUNT", 3), - (41, "DSUM", 3), - (42, "DAVERAGE", 3), - (43, "DMIN", 3), - (44, "DMAX", 3), - (45, "DSTDEV", 3), - (46, "VAR", -1), - (47, "DVAR", 3), - (48, "TEXT", 2), - (49, "LINEST", -1), - (50, "TREND", -1), - (51, "LOGEST", -1), - (52, "GROWTH", -1), - (53, "GOTO", 1), - (54, "HALT", -1), - (55, "RETURN", -1), - (56, "PV", -1), - (57, "FV", -1), - (58, "NPER", -1), - (59, "PMT", -1), - (60, "RATE", -1), - (61, "MIRR", 3), - (62, "IRR", -1), - (63, "RAND", 0), - (64, "MATCH", -1), - (65, "DATE", 3), - (66, "TIME", 3), - (67, "DAY", 1), - (68, "MONTH", 1), - (69, "YEAR", 1), - (70, "WEEKDAY", -1), - (71, "HOUR", 1), - (72, "MINUTE", 1), - (73, "SECOND", 1), - (74, "NOW", 0), - (75, "AREAS", 1), - (76, "ROWS", 1), - (77, "COLUMNS", 1), - (78, "OFFSET", -1), - (79, "ABSREF", 2), - (80, "RELREF", 2), - (81, "ARGUMENT", -1), - (82, "SEARCH", -1), - (83, "TRANSPOSE", 1), - (84, "ERROR", -1), - (85, "STEP", 0), - (86, "TYPE", 1), - (87, "ECHO", -1), - (88, "SET.NAME", -1), - (89, "CALLER", 0), - (90, "DEREF", 1), - (91, "WINDOWS", -1), - (92, "SERIES", -1), - (93, "DOCUMENTS", -1), - (94, "ACTIVE.CELL", 0), - (95, "SELECTION", 0), - (96, "RESULT", -1), - (97, "ATAN2", 2), - (98, "ASIN", 1), - (99, "ACOS", 1), - (100, "CHOOSE", -1), - (101, "HLOOKUP", -1), - (102, "VLOOKUP", -1), - (103, "LINKS", -1), - (104, "INPUT", -1), - (105, "ISREF", 1), - (106, "GET.FORMULA", 1), - (107, "GET.NAME", -1), - (108, "SET.VALUE", 2), - (109, "LOG", -1), - (110, "EXEC", -1), - (111, "CHAR", 1), - (112, "LOWER", 1), - (113, "UPPER", 1), - (114, "PROPER", 1), - (115, "LEFT", -1), - (116, "RIGHT", -1), - (117, "EXACT", 2), - (118, "TRIM", 1), - (119, "REPLACE", 4), - (120, "SUBSTITUTE", -1), - (121, "CODE", 1), - (122, "NAMES", -1), - (123, "DIRECTORY", -1), - (124, "FIND", -1), - (125, "CELL", -1), - (126, "ISERR", 1), - (127, "ISTEXT", 1), - (128, "ISNUMBER", 1), - (129, "ISBLANK", 1), - (130, "T", 1), - (131, "N", 1), - (132, "FOPEN", -1), - (133, "FCLOSE", 1), - (134, "FSIZE", 1), - (135, "FREADLN", 1), - (136, "FREAD", 2), - (137, "FWRITELN", 2), - (138, "FWRITE", 2), - (139, "FPOS", -1), - (140, "DATEVALUE", 1), - (141, "TIMEVALUE", 1), - (142, "SLN", 3), - (143, "SYD", 4), - (144, "DDB", -1), - (145, "GET.DEF", -1), - (146, "REFTEXT", -1), - (147, "TEXTREF", -1), - (148, "INDIRECT", -1), - (149, "REGISTER", -1), - (150, "CALL", -1), - (151, "ADD.BAR", -1), - (152, "ADD.MENU", -1), - (153, "ADD.COMMAND", -1), - (154, "ENABLE.COMMAND", -1), - (155, "CHECK.COMMAND", -1), - (156, "RENAME.COMMAND", -1), - (157, "SHOW.BAR", -1), - (158, "DELETE.MENU", -1), - (159, "DELETE.COMMAND", -1), - (160, "GET.CHART.ITEM", -1), - (161, "DIALOG.BOX", 1), - (162, "CLEAN", 1), - (163, "MDETERM", 1), - (164, "MINVERSE", 1), - (165, "MMULT", 2), - (166, "FILES", -1), - (167, "IPMT", -1), - (168, "PPMT", -1), - (169, "COUNTA", -1), - (170, "CANCEL.KEY", -1), - (171, "FOR", -1), - (172, "WHILE", 1), - (173, "BREAK", 0), - (174, "NEXT", 0), - (175, "INITIATE", 2), - (176, "REQUEST", 2), - (177, "POKE", 3), - (178, "EXECUTE", 2), - (179, "TERMINATE", 1), - (180, "RESTART", -1), - (181, "HELP", -1), - (182, "GET.BAR", -1), - (183, "PRODUCT", -1), - (184, "FACT", 1), - (185, "GET.CELL", -1), - (186, "GET.WORKSPACE", 1), - (187, "GET.WINDOW", -1), - (188, "GET.DOCUMENT", -1), - (189, "DPRODUCT", 3), - (190, "ISNONTEXT", 1), - (191, "GET.NOTE", -1), - (192, "NOTE", -1), - (193, "STDEVP", -1), - (194, "VARP", -1), - (195, "DSTDEVP", 3), - (196, "DVARP", 3), - (197, "TRUNC", -1), - (198, "ISLOGICAL", 1), - (199, "DCOUNTA", 3), - (200, "DELETE.BAR", 1), - (201, "UNREGISTER", 1), - (204, "USDOLLAR", -1), - (205, "FINDB", -1), - (206, "SEARCHB", -1), - (207, "REPLACEB", 4), - (208, "LEFTB", -1), - (209, "RIGHTB", -1), - (210, "MIDB", 3), - (211, "LENB", 1), - (212, "ROUNDUP", 2), - (213, "ROUNDDOWN", 2), - (214, "ASC", 1), - (215, "DBCS", 1), - (216, "RANK", -1), - (219, "ADDRESS", -1), - (220, "DAYS360", -1), - (221, "TODAY", 0), - (222, "VDB", -1), - (223, "ELSE", 0), - (224, "ELSE.IF", 1), - (225, "END.IF", 0), - (226, "FOR.CELL", -1), - (227, "MEDIAN", -1), - (228, "SUMPRODUCT", -1), - (229, "SINH", 1), - (230, "COSH", 1), - (231, "TANH", 1), - (232, "ASINH", 1), - (233, "ACOSH", 1), - (234, "ATANH", 1), - (235, "DGET", 3), - (236, "CREATE.OBJECT", -1), - (237, "VOLATILE", -1), - (238, "LAST.ERROR", 0), - (239, "CUSTOM.UNDO", -1), - (240, "CUSTOM.REPEAT", -1), - (241, "FORMULA.CONVERT", -1), - (242, "GET.LINK.INFO", -1), - (243, "TEXT.BOX", -1), - (244, "INFO", 1), - (245, "GROUP", 0), - (246, "GET.OBJECT", -1), - (247, "DB", -1), - (248, "PAUSE", -1), - (251, "RESUME", -1), - (252, "FREQUENCY", 2), - (253, "ADD.TOOLBAR", -1), - (254, "DELETE.TOOLBAR", 1), - (256, "RESET.TOOLBAR", 1), - (257, "EVALUATE", 1), - (258, "GET.TOOLBAR", -1), - (259, "GET.TOOL", -1), - (260, "SPELLING.CHECK", -1), - (261, "ERROR.TYPE", 1), - (262, "APP.TITLE", -1), - (263, "WINDOW.TITLE", -1), - (264, "SAVE.TOOLBAR", -1), - (265, "ENABLE.TOOL", 3), - (266, "PRESS.TOOL", 3), - (267, "REGISTER.ID", -1), - (268, "GET.WORKBOOK", -1), - (269, "AVEDEV", -1), - (270, "BETADIST", -1), - (271, "GAMMALN", 1), - (272, "BETAINV", -1), - (273, "BINOMDIST", 4), - (274, "CHIDIST", 2), - (275, "CHIINV", 2), - (276, "COMBIN", 2), - (277, "CONFIDENCE", 3), - (278, "CRITBINOM", 3), - (279, "EVEN", 1), - (280, "EXPONDIST", 3), - (281, "FDIST", 3), - (282, "FINV", 3), - (283, "FISHER", 1), - (284, "FISHERINV", 1), - (285, "FLOOR", 2), - (286, "GAMMADIST", 4), - (287, "GAMMAINV", 3), - (288, "CEILING", 2), - (289, "HYPGEOMDIST", 4), - (290, "LOGNORMDIST", 3), - (291, "LOGINV", 3), - (292, "NEGBINOMDIST", 3), - (293, "NORMDIST", 4), - (294, "NORMSDIST", 1), - (295, "NORMINV", 3), - (296, "NORMSINV", 1), - (297, "STANDARDIZE", 3), - (298, "ODD", 1), - (299, "PERMUT", 2), - (300, "POISSON", 3), - (301, "TDIST", 3), - (302, "WEIBULL", 4), - (303, "SUMXMY2", 2), - (304, "SUMX2MY2", 2), - (305, "SUMX2PY2", 2), - (306, "CHITEST", 2), - (307, "CORREL", 2), - (308, "COVAR", 2), - (309, "FORECAST", 3), - (310, "FTEST", 2), - (311, "INTERCEPT", 2), - (312, "PEARSON", 2), - (313, "RSQ", 2), - (314, "STEYX", 2), - (315, "SLOPE", 2), - (316, "TTEST", 4), - (317, "PROB", -1), - (318, "DEVSQ", -1), - (319, "GEOMEAN", -1), - (320, "HARMEAN", -1), - (321, "SUMSQ", -1), - (322, "KURT", -1), - (323, "SKEW", -1), - (324, "ZTEST", -1), - (325, "LARGE", 2), - (326, "SMALL", 2), - (327, "QUARTILE", 2), - (328, "PERCENTILE", 2), - (329, "PERCENTRANK", -1), - (330, "MODE", -1), - (331, "TRIMMEAN", 2), - (332, "TINV", 2), - (334, "MOVIE.COMMAND", -1), - (335, "GET.MOVIE", -1), - (336, "CONCATENATE", -1), - (337, "POWER", 2), - (338, "PIVOT.ADD.DATA", -1), - (339, "GET.PIVOT.TABLE", -1), - (340, "GET.PIVOT.FIELD", -1), - (341, "GET.PIVOT.ITEM", -1), - (342, "RADIANS", 1), - (343, "DEGREES", 1), - (344, "SUBTOTAL", -1), - (345, "SUMIF", -1), - (346, "COUNTIF", 2), - (347, "COUNTBLANK", 1), - (348, "SCENARIO.GET", -1), - (349, "OPTIONS.LISTS.GET", 1), - (350, "ISPMT", 4), - (351, "DATEDIF", 3), - (352, "DATESTRING", 1), - (353, "NUMBERSTRING", 2), - (354, "ROMAN", -1), - (355, "OPEN.DIALOG", -1), - (356, "SAVE.DIALOG", -1), - (357, "VIEW.GET", -1), - (358, "GETPIVOTDATA", -1), - (359, "HYPERLINK", -1), - (360, "PHONETIC", 1), - (361, "AVERAGEA", -1), - (362, "MAXA", -1), - (363, "MINA", -1), - (364, "STDEVPA", -1), - (365, "VARPA", -1), - (366, "STDEVA", -1), - (367, "VARA", -1), - (368, "BAHTTEXT", 1), - (369, "THAIDAYOFWEEK", 1), - (370, "THAIDIGIT", 1), - (371, "THAIMONTHOFYEAR", 1), - (372, "THAINUMSOUND", 1), - (373, "THAINUMSTRING", 1), - (374, "THAISTRINGLENGTH", 1), - (375, "ISTHAIDIGIT", 1), - (376, "ROUNDBAHTDOWN", 1), - (377, "ROUNDBAHTUP", 1), - (378, "THAIYEAR", 1), - (379, "RTD", -1), -]; diff --git a/crates/xlsx/src/xls/records.rs b/crates/xlsx/src/xls/records.rs deleted file mode 100644 index 047170d58..000000000 --- a/crates/xlsx/src/xls/records.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240, -//! 2.5.293, 2.5.294) that may continue across `Continue` records. - -use super::invalid; -use crate::IoError; - -/// `Continue` record type. -pub(super) const CONTINUE: u16 = 0x003C; -/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer). -const MAX_CHARS: usize = 1 << 20; -/// `Continue` records gathered for one record at most. -const MAX_PARTS: usize = 1 << 16; - -/// One record and the payloads of the `Continue` records that follow it. -pub(super) struct Record<'a> { - pub id: u16, - pub parts: Vec<&'a [u8]>, -} - -/// Iterates over the records of a stream starting at `pos`. -pub(super) struct Records<'a> { - stream: &'a [u8], - pos: usize, -} - -impl<'a> Records<'a> { - pub fn new(stream: &'a [u8], pos: usize) -> Self { - Records { stream, pos } - } - - fn raw(&mut self) -> Result, IoError> { - if self.pos >= self.stream.len() { - return Ok(None); - } - let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?; - let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]); - let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)])); - let start = self.pos + 4; - let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| invalid("truncated record"))?; - self.pos = start + len; - Ok(Some((id, data))) - } - - fn peek_id(&self) -> Option { - let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?; - Some(u16::from_le_bytes([*h.first()?, *h.get(1)?])) - } - - /// The next record with its `Continue` records, or `None` at the end of the stream. - pub fn next_record(&mut self) -> Result>, IoError> { - let Some((id, data)) = self.raw()? else { return Ok(None) }; - let mut parts = vec![data]; - while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS { - if let Some((_, more)) = self.raw()? { - parts.push(more); - } - } - Ok(Some(Record { id, parts })) - } -} - -/// Reads a record's payload, crossing into its `Continue` records when needed. -pub(super) struct Cursor<'a, 'b> { - parts: &'b [&'a [u8]], - part: usize, - pos: usize, - /// Bytes in every part after the current one, kept up to date as parts advance, so - /// `remaining()` is constant-time however many `Continue` records a string session crosses. - after: usize, -} - -impl<'a, 'b> Cursor<'a, 'b> { - pub fn new(parts: &'b [&'a [u8]]) -> Self { - let after = parts.iter().skip(1).map(|p| p.len()).sum::(); - Cursor { parts, part: 0, pos: 0, after } - } - - /// Skips to the next part when the current one is used up. Returns whether a new part began. - fn settle(&mut self) -> bool { - let mut moved = false; - while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() { - self.after = self.after.saturating_sub(self.parts[self.part + 1].len()); - self.part += 1; - self.pos = 0; - moved = true; - } - moved - } - - pub fn u8(&mut self) -> Result { - self.settle(); - let b = self.parts.get(self.part).and_then(|p| p.get(self.pos)).copied().ok_or_else(|| invalid("record is shorter than its contents"))?; - self.pos += 1; - Ok(b) - } - - pub fn u16(&mut self) -> Result { - Ok(u16::from_le_bytes([self.u8()?, self.u8()?])) - } - - pub fn u32(&mut self) -> Result { - Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?])) - } - - pub fn f64(&mut self) -> Result { - let mut b = [0u8; 8]; - for x in b.iter_mut() { - *x = self.u8()?; - } - Ok(f64::from_le_bytes(b)) - } - - pub fn skip(&mut self, n: usize) -> Result<(), IoError> { - for _ in 0..n { - self.u8()?; - } - Ok(()) - } - - /// Bytes left in the record (all parts), constant-time in the part count. - pub fn remaining(&self) -> usize { - let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos)); - here.saturating_add(self.after) - } - - /// The rest of the record as one buffer. - pub fn rest(&mut self) -> Vec { - let mut out = Vec::with_capacity(self.remaining()); - if let Some(p) = self.parts.get(self.part) { - out.extend_from_slice(p.get(self.pos..).unwrap_or(&[])); - } - for p in self.parts.iter().skip(self.part + 1) { - out.extend_from_slice(p); - } - self.part = self.parts.len(); - self.pos = 0; - self.after = 0; - out - } - - /// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into - /// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293). - pub fn chars(&mut self, cch: usize, mut high: bool) -> Result { - if cch > MAX_CHARS { - return Err(invalid("string too long")); - } - let mut units: Vec = Vec::with_capacity(cch.min(self.remaining())); - while units.len() < cch { - let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len()); - if at_end && self.settle() { - high = self.u8()? & 1 != 0; - } - units.push(if high { self.u16()? } else { u16::from(self.u8()?) }); - } - Ok(String::from_utf16_lossy(&units)) - } - - /// ShortXLUnicodeString: 1-byte count, flags, characters. - pub fn short_string(&mut self) -> Result { - let cch = usize::from(self.u8()?); - let high = self.u8()? & 1 != 0; - self.chars(cch, high) - } - - /// XLUnicodeString: 2-byte count, flags, characters. - pub fn string(&mut self) -> Result { - let cch = usize::from(self.u16()?); - let high = self.u8()? & 1 != 0; - self.chars(cch, high) - } - - /// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped. - pub fn rich_string(&mut self) -> Result { - let cch = usize::from(self.u16()?); - let flags = self.u8()?; - let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 }; - let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 }; - let text = self.chars(cch, flags & 1 != 0)?; - let extra = runs.saturating_mul(4).saturating_add(ext); - if extra > self.remaining() { - return Err(invalid("string formatting runs past the end of the record")); - } - self.skip(extra)?; - Ok(text) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn strings_continue_with_a_new_compression_flag() { - // "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part. - let first: &[u8] = &[4, 0, 0, b'a', b'b']; - let second: &[u8] = &[1, 0xE7, 0, b'd', 0]; - let parts = [first, second]; - let mut c = Cursor::new(&parts); - assert_eq!(c.rich_string().unwrap(), "abçd"); - assert_eq!(c.remaining(), 0); - let mut c = Cursor::new(&parts); - assert!(c.chars(MAX_CHARS + 1, false).is_err()); - } - - #[test] - fn remaining_counts_every_part_wherever_the_cursor_is() { - let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]]; - let mut c = Cursor::new(&parts); - assert_eq!(c.remaining(), 13); - for b in 1..=3u64 { - assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order"); - } - assert_eq!(c.remaining(), 10, "after the first part is used up"); - for _ in 0..4 { - c.u8().unwrap(); - } - assert_eq!(c.remaining(), 6, "into the Continue parts"); - let mut c = Cursor::new(&parts); - assert_eq!(u64::from(c.u8().unwrap()), 1); - assert_eq!(c.rest().len(), 12); - assert_eq!(c.remaining(), 0); - } -} diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs deleted file mode 100644 index a9f624bcb..000000000 --- a/crates/xlsx/src/xls/styles.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with -//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is -//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether -//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`. - -use std::collections::HashMap; - -use gridcraft_model::style::Protection; -use gridcraft_model::{ - Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign, - VertAlign, Workbook, -}; - -use super::records::Cursor; -use crate::tables::{INDEXED, builtin_format}; - -/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). -const MAX_FONTS: usize = 1024; -/// Style records kept at most. -const MAX_STYLES: usize = 65_536; -/// Characters kept from a style name (Excel allows 255). -const MAX_STYLE_NAME: usize = 255; - -/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours. -const ICV_FONT_AUTO: u16 = 0x7FFF; -const ICV_DEFAULT_FG: u16 = 0x40; -const ICV_DEFAULT_BG: u16 = 0x41; - -/// One XF record, decoded as far as its bytes allow. -#[derive(Clone, Debug, Default)] -pub(super) struct Xf { - ifnt: u16, - ifmt: u16, - flags: u16, - /// The CellXF/StyleXF data: 14 bytes when complete. - data: [u8; 14], - /// Whether all 20 bytes were present. - complete: bool, -} - -/// A Style record: the XF it names and its name. -#[derive(Clone, Debug)] -pub(super) struct NamedStyle { - ixf: u16, - /// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style. - builtin: Option, - /// A user style's name (empty for built-in styles). - name: String, -} - -/// Formatting records collected from the globals substream. -#[derive(Debug, Default)] -pub(super) struct StyleRecords { - fonts: Vec, - font_colors: Vec, - xfs: Vec, - /// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present. - palette: Option>, - styles: Vec, - /// Records that were shorter than their structure; the parts present were used. - pub(super) short: bool, -} - -impl StyleRecords { - /// A Font record. The colour is resolved later, once the palette is known. - pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) { - if self.fonts.len() >= MAX_FONTS { - return; - } - match read_font(c) { - Some((font, icv)) => { - self.fonts.push(font); - self.font_colors.push(icv); - } - None => { - // Keep the numbering of the fonts after it. - self.short = true; - self.fonts.push(Font::default()); - self.font_colors.push(ICV_FONT_AUTO); - } - } - } - - /// An XF record. A short record keeps the fields it has. - pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) { - if self.xfs.len() >= max { - return; - } - let mut xf = Xf::default(); - let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))(); - match head { - Some((ifnt, ifmt, flags)) => { - xf.ifnt = ifnt; - xf.ifmt = ifmt; - xf.flags = flags; - xf.complete = true; - for b in xf.data.iter_mut() { - match c.u8() { - Ok(v) => *b = v, - Err(_) => { - xf.complete = false; - break; - } - } - } - } - None => { - // Not even the font and format: an unformatted, locked cell. - xf.flags = 1; - } - } - if !xf.complete { - self.short = true; - } - self.xfs.push(xf); - } - - /// A Palette record: colours for indexes 8 onwards. - pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) { - let Ok(ccv) = c.u16() else { - self.short = true; - return; - }; - let n = usize::from(ccv).min(56); - if usize::from(ccv) > c.remaining() / 4 { - self.short = true; - } - let mut colors = Vec::with_capacity(n); - for _ in 0..n { - let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break }; - colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)); - } - self.palette = Some(colors); - } - - /// A Style record. - pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) { - if self.styles.len() >= MAX_STYLES { - return; - } - let Ok(word) = c.u16() else { - self.short = true; - return; - }; - let ixf = word & 0x0FFF; - if word & 0x8000 != 0 { - let Ok(id) = c.u8() else { - self.short = true; - return; - }; - // BuiltInStyle: the style's number (0 is Normal) and outline level. - self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() }); - } else { - match c.string() { - Ok(name) => { - let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect(); - if !name.is_empty() { - self.styles.push(NamedStyle { ixf, builtin: None, name }); - } - } - Err(_) => self.short = true, - } - } - } - - /// The 64-entry colour table: the built-in eight, then the palette (or the default one). - fn color_table(&self) -> [u32; 64] { - let mut table = INDEXED; - if let Some(p) = &self.palette { - for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) { - *slot = *c; - } - } - table - } -} - -/// Font record fields up to the name; `None` if the record is too short for them. -fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { - let height = c.u16().ok()?; - let flags = c.u16().ok()?; - let icv = c.u16().ok()?; - let weight = c.u16().ok()?; - let script = c.u16().ok()?; - let underline = c.u8().ok()?; - let family = c.u8().ok()?; - let charset = c.u8().ok()?; - c.skip(1).ok()?; // reserved - let name = c.short_string().ok()?; - let mut f = Font::default(); - let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); - if !name.trim().is_empty() { - f.name = name; - } - if height > 0 { - f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0); - } - f.italic = flags & 0x0002 != 0; - f.strike = flags & 0x0008 != 0; - // Weights run 100-1000; Excel writes 400 for normal and 700 for bold. - f.bold = weight >= 600; - f.vert = match script { - 1 => VertAlign::Superscript, - 2 => VertAlign::Subscript, - _ => VertAlign::Baseline, - }; - f.underline = match underline { - 0x01 => Underline::Single, - 0x02 => Underline::Double, - 0x21 => Underline::SingleAccounting, - 0x22 => Underline::DoubleAccounting, - _ => Underline::None, - }; - f.family = Some(family); - f.charset = Some(charset); - Some((f, icv)) -} - -/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system -/// and automatic colours as automatic. -fn color(icv: u16, table: &[u32; 64]) -> Color { - match icv { - ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto, - _ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)), - } -} - -/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border. -fn border_style(dg: u32) -> BorderStyle { - match dg { - 1 => BorderStyle::Thin, - 2 => BorderStyle::Medium, - 3 => BorderStyle::Dashed, - 4 => BorderStyle::Dotted, - 5 => BorderStyle::Thick, - 6 => BorderStyle::Double, - 7 => BorderStyle::Hair, - 8 => BorderStyle::MediumDashed, - 9 => BorderStyle::DashDot, - 10 => BorderStyle::MediumDashDot, - 11 => BorderStyle::DashDotDot, - 12 => BorderStyle::MediumDashDotDot, - 13 => BorderStyle::SlantDashDot, - _ => BorderStyle::None, - } -} - -/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern. -fn pattern(fls: u16) -> PatternType { - match fls { - 0x01 => PatternType::Solid, - 0x02 => PatternType::Gray50, - 0x03 => PatternType::Gray75, - 0x04 => PatternType::Gray25, - 0x05 => PatternType::DarkHorizontal, - 0x06 => PatternType::DarkVertical, - 0x07 => PatternType::DarkDown, - 0x08 => PatternType::DarkUp, - 0x09 => PatternType::DarkGrid, - 0x0A => PatternType::DarkTrellis, - 0x0B => PatternType::LightHorizontal, - 0x0C => PatternType::LightVertical, - 0x0D => PatternType::LightDown, - 0x0E => PatternType::LightUp, - 0x0F => PatternType::LightGrid, - 0x10 => PatternType::LightTrellis, - 0x11 => PatternType::Gray125, - 0x12 => PatternType::Gray0625, - _ => PatternType::None, - } -} - -/// One border side: its style and colour; a side without a style has no colour. -fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { - let style = border_style(dg); - if style == BorderStyle::None { - return BorderLine::default(); - } - // Colour 0 means "not specified": automatic. - let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) }; - BorderLine { style, color } -} - -/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one). -fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { - let i = match ifnt { - 0..=3 => usize::from(ifnt), - 4 => return fonts.first().cloned().unwrap_or_default(), - _ => usize::from(ifnt) - 1, - }; - fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() -} - -/// The number format an XF names: a custom format, else a built-in one, else General. -fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { - let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); - if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } -} - -/// The model style of one XF. -fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap, table: &[u32; 64]) -> Style { - let mut s = Style { - font: xf_font(xf.ifnt, fonts), - num_fmt: num_fmt(xf.ifmt, formats), - protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 }, - ..Style::default() - }; - if !xf.complete { - return s; - } - let d = &xf.data; - let byte = |i: usize| d.get(i).copied().unwrap_or(0); - let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]); - let (a0, rot, a2) = (byte(0), byte(1), byte(2)); - s.align = Alignment { - h: match a0 & 0x07 { - 1 => HAlign::Left, - 2 => HAlign::Center, - 3 => HAlign::Right, - 4 => HAlign::Fill, - 5 => HAlign::Justify, - 6 => HAlign::CenterAcross, - 7 => HAlign::Distributed, - _ => HAlign::General, - }, - wrap: a0 & 0x08 != 0, - v: match (a0 >> 4) & 0x07 { - 0 => VAlign::Top, - 1 => VAlign::Center, - 3 => VAlign::Justify, - 4 => VAlign::Distributed, - _ => VAlign::Bottom, - }, - rotation: match rot { - 0..=90 => i16::from(rot), - 91..=180 => -(i16::from(rot) - 90), - 255 => 255, - _ => 0, - }, - indent: a2 & 0x0F, - shrink: a2 & 0x10 != 0, - reading_order: None, - }; - let b1 = word32(4); - let b2 = word32(8); - let mut borders = Borders { - left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table), - right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table), - top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table), - bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table), - ..Borders::default() - }; - let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table); - let which = (b1 >> 30) & 0x03; - if which & 1 != 0 { - borders.diag_down = diag; - } - if which & 2 != 0 { - borders.diag_up = diag; - } - s.borders = borders; - let fill = u16::from_le_bytes([byte(12), byte(13)]); - let fls = ((b2 >> 26) & 0x3F) as u16; - let fg = color(fill & 0x7F, table); - let bg = color((fill >> 7) & 0x7F, table); - s.fill = match pattern(fls) { - PatternType::None => Fill::default(), - PatternType::Solid => Fill::solid(fg), - p => Fill { pattern: p, fg, bg }, - }; - s -} - -/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and -/// returns the model style of every XF in file order. -pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut Workbook) -> Vec { - let table = r.color_table(); - let fonts: Vec = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect(); - // The Normal style's font is what every unformatted cell shows. - let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf)); - let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned()); - if let Some(font) = normal_font { - wb.styles = StyleTable::with_default(Style { font, ..Style::default() }); - } - let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect(); - // User-defined named styles. Excel 2007 and later write every built-in style into each .xls - // (about fifty, their colours approximated to the palette); GridCraft defines those itself. - let mut named: Vec<(String, Style)> = Vec::new(); - // Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear. - let mut seen: std::collections::HashSet = std::collections::HashSet::from(["normal".to_string()]); - for ns in &r.styles { - if ns.builtin.is_some() { - continue; - } - let key = ns.name.to_ascii_lowercase(); - if seen.contains(&key) { - continue; - } - if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { - seen.insert(key); - named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); - } - } - wb.cell_styles = named; - out -} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 40942a539..d06341711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **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** @@ -161,6 +161,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped | | 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) | | 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) |