diff --git a/crates/model/src/preserved.rs b/crates/model/src/preserved.rs index 6d0b35d..eb8e2bb 100644 --- a/crates/model/src/preserved.rs +++ b/crates/model/src/preserved.rs @@ -33,10 +33,11 @@ pub struct PreservedPart { } /// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its -/// relationship ids rewritten. +/// relationship ids rewritten, or that Excel needs back unchanged (the workbook's `fileVersion`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PreservedXml { - /// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`). + /// Which slot of the owner it belongs in (`externalReferences`, `fileVersion`, `ext`, + /// `oleObjects`, `controls`). pub slot: String, /// The element, namespace declarations it needs included. pub xml: String, diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977..7db21fd 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -6,7 +6,8 @@ //! their ``, data connections) and each sheet (query tables, form controls, //! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's //! ``, `` and `` XML, the `r:id` of `` and the legacy -//! VML shapes those controls need). +//! VML shapes those controls need). The workbook also keeps its `` and the +//! calcFeatures extension, which Excel needs to trust the saved values. //! //! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in //! kept XML are rewritten to match. Kept content is conservative: @@ -83,6 +84,11 @@ const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; const SHEET_ROOTS: &[&str] = &["queryTable"]; /// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping. const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"]; +/// Attributes of `` (CT_FileVersion). +const FILE_VERSION_ATTRS: &[&str] = &["appName", "lastEdited", "lowestEdited", "rupBuild", "codeName"]; +/// The workbook extension listing the calculation features the saved values were computed with. +const CALC_FEATURES_URI: &str = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}"; +const NS_CALC_FEATURES: &str = "http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"; /// Kept sheet elements, in schema order (all after ``, before ``). const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"]; @@ -369,6 +375,30 @@ impl KeptReader { Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), } } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } (!p.is_empty()).then(|| Arc::new(p)) } @@ -853,6 +883,12 @@ pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> Strin p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect() } +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + /// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. pub fn write_package_rels(root: &mut Rels, out: &mut Out) { for (ty, target, external) in std::mem::take(&mut out.kept.root) { diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 4cde9d6..aac9b6b 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,41 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some()))); } + +#[test] +fn file_version_and_calc_features_survive_a_save() { + // Excel trusts the saved cell values only when workbook.xml carries both and the + // calcFeatures extension; without them it recalculates everything on open (#799). + let calc_features = r#""#; + let workbook = format!( + r#"{calc_features}"# + ); + let sheet = format!( + r#"1A1*22"# + ); + let bytes = make_zip(&[ + ("[Content_Types].xml", super::CT.as_bytes()), + ("_rels/.rels", super::ROOT_RELS.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")]).as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ]); + let (wb, _) = read_xlsx(&bytes).unwrap(); + // Twice: what was kept on the first save is read back and kept again. + let (again, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for saved in [write_xlsx(&wb).unwrap(), write_xlsx(&again).unwrap()] { + let u = Unpacked::new(&saved); + u.assert_valid(); + let text = u.text("xl/workbook.xml"); + let root = xml::parse(text.as_bytes()).unwrap(); + // Schema order: fileVersion is the first child of , extLst the last. + assert_eq!(root.children.first().map(|e| e.name.as_str()), Some("fileVersion"), "{text}"); + assert_eq!(root.children.last().map(|e| e.name.as_str()), Some("extLst"), "{text}"); + assert!(text.contains(r#""#), "{text}"); + assert!(text.contains(&format!("{calc_features}")), "{text}"); + } + + // A workbook that had neither gets neither. + let plain = Unpacked::new(&write_xlsx(&gridcraft_model::Workbook::new()).unwrap()).text("xl/workbook.xml"); + assert!(!plain.contains("fileVersion") && !plain.contains("extLst"), "{plain}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 5492756..dddba98 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -295,6 +295,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } let mut w = format!("{XML_DECL}"); + w.push_str(&crate::passthrough::workbook_elements(wb, "fileVersion")); w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "" } else { "" }); if wb.protected_structure { let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default(); @@ -331,6 +332,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { } w.push_str("/>"); w.push_str(&pivots.workbook_xml(&mut wb_rels)); + let exts = crate::passthrough::workbook_elements(wb, "ext"); + if !exts.is_empty() { + let _ = write!(w, "{exts}"); + } w.push_str(""); // Workbook-level parts.