Merge pull request #822 from marcoarnulfo:fix/workbook-fileversion-calcfeatures
This commit is contained in:
commit
a76759cba6
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue