diff --git a/crates/xlsx/src/tests/xls_tests.rs b/crates/xlsx/src/tests/xls_tests.rs index fd37795..5f80734 100644 --- a/crates/xlsx/src/tests/xls_tests.rs +++ b/crates/xlsx/src/tests/xls_tests.rs @@ -476,3 +476,22 @@ fn sheets_share_one_record_budget() { let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string(); assert!(err.contains("same substream"), "{err}"); } + +#[test] +fn reads_iterative_calculation_settings() { + // 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. + let calc = cat(&[&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), &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)); + // 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()); +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index c9aee66..df27a73 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -4,9 +4,9 @@ //! //! 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 iterative calculation +//! settings. 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 +16,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::{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}; @@ -175,12 +175,16 @@ 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 iteration 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)); } @@ -393,6 +397,7 @@ struct SheetState<'g> { pending_string: Option, dropped: usize, unsupported: Vec<&'static str>, + calc: CalcSettings, } impl SheetState<'_> { @@ -462,7 +467,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))); } @@ -483,6 +488,7 @@ fn worksheet( pending_string: None, dropped: 0, unsupported: vec![], + calc: CalcSettings::default(), }; let mut depth = 1usize; while let Some(rec) = recs.next_record()? { @@ -525,7 +531,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> { @@ -620,6 +626,9 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, st.arrays.insert(at, (range, f)); } } + 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 8510659..ad458ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the iterative calculation settings) · **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** @@ -22,7 +22,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.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, iterative calculation settings (enabled, maximum iterations, maximum change). 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 | — | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.xls` import reads the iterative calculation settings (CalcIter, CalcCount, CalcDelta), so circular models calculate instead of showing `#CIRC!` (#724) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types |