XLSX: form control VML shapes are also found by their o:spid

Some producers give a VML shape a named id (`id="Button 1"`) and keep
its number in `o:spid`. The form control reader looked for the number
only in `id`, so a control listed in <controls> lost its caption and
macro (they live in the VML), and a control known only from the VML was
skipped. Shapes are now matched by `id`, else by `o:spid`, as
pass-through already does when it leaves the modelled shapes out.
This commit is contained in:
prxnvt committed 2026-10-31 16:11:55 -04:00
1 parent 09a37a8f10
commit b0531aeb73
2 files changed
+37 -2

No files matched your search

+8 -2
View File
@@ -388,6 +388,12 @@ fn shape_number(id: &str) -> Option<u64> {
id.trim().trim_start_matches("_x0000_s").parse().ok()
}
/// A VML shape's number: from its `id` (`_x0000_s1025`), else from `o:spid`, where producers that
/// give the shape a named id (`id="Button 1"`) keep it.
fn number_of(shape: &El) -> Option<u64> {
shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number))
}
/// Reads a sheet's buttons, scroll bars and spin buttons: each `<control>` with its properties
/// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML).
/// Returns the shape numbers of the controls it read, so pass-through keeps only the others.
@@ -398,7 +404,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut S
None => None,
};
let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default();
let shape_of = |n: u64| shapes.iter().copied().find(|s| s.attr("id").and_then(shape_number) == Some(n));
let shape_of = |n: u64| shapes.iter().copied().find(|s| number_of(s) == Some(n));
// Every shape the sheet lists, read here or not.
let mut listed: HashSet<u64> = HashSet::new();
for ctl in controls.map(controls_in).unwrap_or_default() {
@@ -447,7 +453,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut S
}
// Controls known only from the VML.
for s in &shapes {
let Some(n) = s.attr("id").and_then(shape_number) else { continue };
let Some(n) = number_of(s) else { continue };
if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS {
continue;
}
+29
View File
@@ -181,6 +181,35 @@ fn vml_only_controls_are_read_and_clamped() {
assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None }));
}
/// Shapes with a named `id` and their number in `o:spid`, as some producers write them.
const VML_SPID: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
<v:shape id="Button 1" o:spid="_x0000_s1025" o:button="t"><v:textbox><div>Go</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>1, 0, 6, 0, 3, 0, 7, 0</x:Anchor><x:FmlaMacro>[0]!Tidy</x:FmlaMacro></x:ClientData></v:shape>
<v:shape id="Spinner 2" o:spid="_x0000_s1026"><x:ClientData ObjectType="Spin"><x:Anchor>1, 0, 1, 0, 1, 20, 3, 0</x:Anchor><x:Val>7</x:Val><x:Max>9</x:Max></x:ClientData></v:shape>
</xml>"##;
#[test]
fn vml_shapes_are_also_found_by_their_spid() {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
// The button is listed in <controls> without a properties part; the spin button is only in
// the VML.
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/><controls><control shapeId="1025" name="Go Button"/></controls>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_SPID)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let c = &wb.sheet(0).unwrap().controls;
assert_eq!(c.len(), 2, "{c:?}");
assert_eq!((c[0].kind, c[0].name.as_str(), c[0].text.as_str()), (ControlKind::Button, "Go Button", "Go"));
assert_eq!(c[0].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None }));
assert_eq!((c[1].kind, c[1].value, c[1].max), (ControlKind::Spin, 7, 9));
// Saved again, each is written once and nothing of them is kept twice.
let out = write_xlsx(&wb).unwrap();
let (back, _) = read_xlsx(&out).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), 2);
}
#[test]
fn the_properties_part_wins_over_the_vml() {
let mut wb = book();