From 7202d1cf7c90df03a920ba96b029a4c26ef9ea94 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:33:10 -0500 Subject: [PATCH] XLSX: a modelled control joins the Choice branch's element of a kept AlternateContent The pass-through writer put GridCraft's control entries before the last close tag of the kept fragment, so a kept mc:AlternateContent with separate elements in its Choice and Fallback got them in the Fallback, which the reader (and an application that knows x14) doesn't read: on reopen the control came back from its VML shape alone, without its name and its properties part. The entries now go into the element the reader reads: the Choice's when the Choice holds one, else the first one (a plain fragment, or a Fallback alone); a Fallback's element is left as the file had it. Tags are matched by local name, with any namespace prefix. Test: a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content (the saved Choice element holds the kept list box and the modelled slider, the Fallback only the list box; the file reads back with the slider's name, link and value from its worksheet entry and properties part, and the list box kept). --- crates/xlsx/src/passthrough/rebase.rs | 40 +++++++++++++++---- crates/xlsx/src/tests/controls_tests.rs | 53 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs index 87fe715c3..e9232658d 100644 --- a/crates/xlsx/src/passthrough/rebase.rs +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -69,7 +69,7 @@ impl<'a> SheetKept<'a> { if let Some(mut x) = remap(&e.xml, &self.ids) { if *slot == "controls" && !placed - && let Some(i) = last_close_tag(&x, "controls") + && let Some(i) = controls_insert_at(&x) { x.insert_str(i, controls); placed = true; @@ -87,13 +87,39 @@ impl<'a> SheetKept<'a> { } } -/// The byte offset of the last close tag of `local` in `x`, whatever namespace prefix the file -/// gives it (``, ``). -fn last_close_tag(x: &str, local: &str) -> Option { - x.rmatch_indices("` fragment: just before the close +/// tag of the `` element the reader reads (`controls::controls_of`), whatever its +/// namespace prefix. In an `mc:AlternateContent` with separate `` elements in its +/// Choice and Fallback branches that is the Choice's, the one an application that knows the +/// x14 extensions reads; the Fallback's is left as the file had it. With no Choice, or a Choice +/// without a `` element, it is the first `` element (a plain fragment, or a +/// Fallback alone). +fn controls_insert_at(x: &str) -> Option { + let from = open_tag(x, "Choice").unwrap_or(0); + close_tag_from(x, from, "controls") +} + +/// The local name of the tag whose text starts at `name` (`x:controls` → `controls`). +fn local_name(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// The byte offset of the first open tag of `local` in `x`, with any namespace prefix. +fn open_tag(x: &str, local: &str) -> Option { + x.match_indices('<').find_map(|(i, _)| { + let rest = x.get(i + 1..)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; + (local_name(rest.get(..end)?) == local).then_some(i) + }) +} + +/// The byte offset of the first close tag of `local` at or after `from` in `x`, with any +/// namespace prefix (``, ``). +fn close_tag_from(x: &str, from: usize, local: &str) -> Option { + x.get(from..)?.match_indices("')?)?.trim_end(); - (name.rsplit(':').next().unwrap_or(name) == local).then_some(i) + (local_name(name) == local).then_some(from + i) }) } diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 252942296..a3e2c214d 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -328,6 +328,59 @@ fn a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix() { assert_eq!(back.sheet(0).unwrap().controls.len(), 1, "the slider reads back; the list box stays kept"); } +/// A kept `mc:AlternateContent` with separate `` elements in its Choice and Fallback: +/// a modelled control is written into the Choice's element, the one the reader (and an +/// application that knows x14) reads, so it comes back with its name and its properties part; +/// the Fallback's element is left as the file had it. +#[test] +fn a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let rels = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let list = r#"20605090"#; + let controls = format!( + r#"{list}"# + ); + let bytes = minimal( + &format!(r#"{controls}"#), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() }; + wb.sheet_mut(0).unwrap().controls.push(FormControl { + id: 900, + kind: ControlKind::Slider, + anchor, + name: "Speed".into(), + link: Some("$B$1".into()), + value: 40, + ..Default::default() + }); + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + // The Choice's comes first in the sheet, the Fallback's last. + let (first, last) = (sheet.find("
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy