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) |