Merge pull request #822 from marcoarnulfo:fix/workbook-fileversion-calcfeatures

This commit is contained in:
Brandon Thomas 2026-10-12 23:49:47 -04:00
commit a76759cba6
4 changed files with 83 additions and 3 deletions

View File

@ -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,

View File

@ -6,7 +6,8 @@
//! their `<externalReferences>`, data connections) and each sheet (query tables, form controls,
//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's
//! `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id` of `<pageSetup>` and the legacy
//! VML shapes those controls need).
//! VML shapes those controls need). The workbook also keeps its `<fileVersion>` 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 `<fileVersion>` (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 `<legacyDrawing>`, before `<tableParts>`).
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("<fileVersion");
for k in FILE_VERSION_ATTRS {
if let Some(a) = v.attr(k) {
let _ = write!(x, " {k}=\"{}\"", esc_attr(a));
}
}
x.push_str("/>");
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!("<ext uri=\"{CALC_FEATURES_URI}\" xmlns:xcalcf=\"{NS_CALC_FEATURES}\"><xcalcf:calcFeatures>");
for name in c.kids("feature").filter_map(|f| f.attr("name")) {
let _ = write!(x, "<xcalcf:feature name=\"{}\"/>", esc_attr(name));
}
x.push_str("</xcalcf:calcFeatures></ext>");
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) {

View File

@ -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 <fileVersion> and the
// calcFeatures extension; without them it recalculates everything on open (#799).
let calc_features = r#"<ext uri="{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}" xmlns:xcalcf="http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"><xcalcf:calcFeatures><xcalcf:feature name="microsoft.com:RD"/><xcalcf:feature name="microsoft.com:Single"/><xcalcf:feature name="microsoft.com:FV"/><xcalcf:feature name="microsoft.com:CNMTM"/><xcalcf:feature name="microsoft.com:LET_WF"/><xcalcf:feature name="microsoft.com:ARRAYTEXT_WF"/></xcalcf:calcFeatures></ext>"#;
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><fileVersion appName="xl" lastEdited="7" lowestEdited="7" rupBuild="11008"/><workbookPr/><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets><calcPr calcId="191029"/><extLst><ext uri="{{140A7094-0E35-4892-8432-C4D2E57EDEB5}}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:workbookPr chartTrackingRefBase="1"/></ext>{calc_features}</extLst></workbook>"#
);
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData><row r="1"><c r="A1"><v>1</v></c><c r="B1"><f>A1*2</f><v>2</v></c></row></sheetData></worksheet>"#
);
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 <workbook>, 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#"<fileVersion appName="xl" lastEdited="7" lowestEdited="7" rupBuild="11008"/>"#), "{text}");
assert!(text.contains(&format!("<extLst>{calc_features}</extLst>")), "{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}");
}

View File

@ -295,6 +295,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
}
let mut w = format!("{XML_DECL}<workbook xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
w.push_str(&crate::passthrough::workbook_elements(wb, "fileVersion"));
w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "<workbookPr date1904=\"1\"/>" } else { "<workbookPr/>" });
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<Vec<u8>, 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, "<extLst>{exts}</extLst>");
}
w.push_str("</workbook>");
// Workbook-level parts.