diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index aade443..cc6a9d9 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -494,6 +494,25 @@ fn sheets_share_one_record_budget() { } #[test] +fn reads_iterative_calculation_settings() { + // CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only + // the first sheet carries them, so the sheet after it must not reset them to the defaults. + // CalcPrecision sits in the globals. + let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]); + let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]); + let sheet2 = cat(&[&bof(0x0010), &eof()]); + let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()])); + let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]); + let p1 = globals(0, 0).len() as u32; + let p2 = p1 + sheet1.len() as u32; + let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap(); + assert!(wb.calc.iterative); + assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01)); + assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual); + assert!(wb.calc.precision_as_displayed); + // Without the records: Excel's defaults, iteration off. + let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap(); + assert_eq!(wb.calc, gridcraft_model::CalcSettings::default()); fn a_format_shorter_than_its_count_keeps_its_characters() { // A FORMAT whose cch is one more than the characters in the record (seen in real files): // Excel opens them, the record's characters are the format code. diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index 6d14583..9d859ea 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,10 @@ //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including //! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas and print titles. Everything else (fonts, -//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is -//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused. +//! widths, row heights, defined names, print areas, print titles and the calculation settings +//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, +//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; use crate::cfb::Compound; use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; @@ -123,6 +124,7 @@ struct Globals { sst: Vec>, links: Links, names: Vec, + precision_as_displayed: bool, } /// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped. @@ -175,18 +177,23 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo continue; } wb.check_sheet_name(&bs.name, None).map_err(invalid)?; - let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { + let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else { report.warn("Dialog sheets and unreadable sheets were skipped."); model_index.push(None); continue; }; sheet.visibility = bs.visibility; + // Every worksheet repeats the workbook's calculation settings; Excel applies the first. + if wb.sheets.is_empty() { + wb.calc = calc; + } model_index.push(Some(wb.sheets.len())); wb.sheets.push(Arc::new(sheet)); } if wb.sheets.is_empty() { return Err(invalid("the workbook has no worksheets GridCraft can read")); } + wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); // The overview comes first. @@ -247,6 +254,7 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul 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()?); @@ -403,6 +411,7 @@ struct SheetState<'g> { pending_string: Option<(CellRef, StyleId)>, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -472,7 +481,7 @@ fn worksheet( styles: &[StyleId], budget: &mut Budget, report: &mut ReadReport, -) -> Result, IoError> { +) -> Result, IoError> { if bs.pos >= stream.len() { return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name))); } @@ -493,6 +502,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -545,7 +555,7 @@ fn worksheet( for why in &st.unsupported { report.warn(format!("Some formulas use {why}; their saved results were kept as values.")); } - Ok(Some(st.sheet)) + Ok(Some((st.sheet, st.calc))) } fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> { @@ -642,6 +652,16 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, 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()?); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index db97678..b38416b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,5 +1,6 @@ # File-format parity +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (macOS Open With offers GridCraft for `.xlsb` and `.ods`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 @@ -31,7 +32,7 @@ and repair-free opening remain unverified. | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -87,6 +88,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls | | 2026-10-12 | minor | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files | | 2026-10-12 | minor | macOS: the app bundle declares `.xlsb` and `.ods`, so Finder's Open With offers GridCraft for them, as Linux and Windows already did (#321) |