From dd97b29dec2bd1d0d8554de74322d7b8a56e0d4b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:39:08 -0400 Subject: [PATCH] XLSX: form controls are read from the drawing names The reader took the sheet's first VML drawing relationship. A sheet with pictures in its header or footer has a second VML drawing (), and when that relationship came first the control shapes were not found: VML-only controls were skipped, and listed controls lost their caption and macro. The drawing is now the one names, as pass-through already does. --- crates/xlsx/src/controls.rs | 5 +++-- crates/xlsx/src/sheet_read.rs | 16 +++++++++------- crates/xlsx/src/tests/controls_tests.rs | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 0a3d57bf3..e45c98fe3 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -397,10 +397,11 @@ fn number_of(shape: &El) -> Option { /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties /// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). +/// The shapes come from the VML drawing with relationship id `vml_rid` (``). /// Returns the shape numbers of the controls it read, so pass-through keeps only the others. -pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { let mut read = HashSet::new(); - let vml = match rels.iter().find(|r| r.kind == "vmlDrawing") { + let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && r.kind == "vmlDrawing")) { Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(), None => None, }; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index f2f7de668..1cf875524 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -141,7 +141,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha let mut fit_to_page = false; let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS; cx.cf_ext_ids.clear(); - let mut controls: Option<&El> = None; for e in &root.children { match e.name.as_str() { "sheetPr" => { @@ -263,12 +262,11 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha } } "extLst" => read_ext(cx, e, &mut sheet), - "controls" | "AlternateContent" => controls = crate::controls::controls_of(e).or(controls), "protectedRanges" => read_protected_ranges(e, &mut sheet), _ => {} } } - let read_controls = read_form_controls(cx, controls, &rels, &mut sheet); + let read_controls = read_form_controls(cx, &root, &rels, &mut sheet); // Comments are related to the sheet without an element in it. for rel in rels.iter().filter(|r| r.kind == "comments") { if let Some(x) = cx.optional_xml(&rel.target)? { @@ -293,10 +291,14 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha Ok((sheet, read_controls)) } -/// Buttons, scroll bars and spin buttons: the `` list with its properties parts, else -/// the VML shapes. Returns the VML shape numbers read, so pass-through keeps only the others. -fn read_form_controls(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { - if controls.is_some() || rels.iter().any(|r| r.kind == "vmlDrawing") { crate::controls::read(cx, controls, rels, sheet) } else { HashSet::new() } +/// Buttons, scroll bars and spin buttons: the `` list (also inside +/// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that +/// `` names (not the header and footer drawing of ``). +/// Returns the VML shape numbers read, so pass-through keeps only the others. +fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> HashSet { + let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back(); + let vml = root.child("legacyDrawing").and_then(|e| e.attr("id")); + if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { HashSet::new() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index f94be0e3a..b58d1f200 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -315,3 +315,21 @@ fn the_properties_parts_use_the_ctrl_prop_relationship_type() { assert_eq!(types.len(), 4, "{rels}"); assert!(types.iter().all(|t| *t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"), "{types:?}"); } + +/// A sheet with a header and footer drawing and a drawing for its controls: the controls come +/// from the drawing `` names, whichever relationship is listed first. +#[test] +fn controls_come_from_the_legacy_drawing_not_the_header_and_footer_drawing() { + let hf = r##""##; + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawingHF1.vml", hf), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 3, "{c:?}"); + assert_eq!(c[2].text, "Go"); +}