XLSX: a modelled control joins the Choice branch's <controls> element of a kept AlternateContent

The pass-through writer put GridCraft's control entries before the last <controls> close tag of
the kept fragment, so a kept mc:AlternateContent with separate <controls> 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 <controls> 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).
This commit is contained in:
prxnvt committed 2026-11-02 12:33:10 -05:00
1 parent c1249e3ad1
commit 7202d1cf7c
2 files changed
+86 -7

No files matched your search

+33 -7
View File
@@ -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 (`</controls>`, `</x:controls>`).
fn last_close_tag(x: &str, local: &str) -> Option<usize> {
x.rmatch_indices("</").find_map(|(i, _)| {
let rest = x.get(i + 2..)?;
/// Where GridCraft's control entries go in a kept `<controls>` fragment: just before the close
/// tag of the `<controls>` element the reader reads (`controls::controls_of`), whatever its
/// namespace prefix. In an `mc:AlternateContent` with separate `<controls>` 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 `<controls>` element, it is the first `<controls>` element (a plain fragment, or a
/// Fallback alone).
fn controls_insert_at(x: &str) -> Option<usize> {
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<usize> {
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 (`</controls>`, `</x:controls>`).
fn close_tag_from(x: &str, from: usize, local: &str) -> Option<usize> {
x.get(from..)?.match_indices("</").find_map(|(i, _)| {
let rest = x.get(from + i + 2..)?;
let name = rest.get(..rest.find('>')?)?.trim_end();
(name.rsplit(':').next().unwrap_or(name) == local).then_some(i)
(local_name(name) == local).then_some(from + i)
})
}
+53
View File
@@ -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 `<controls>` 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#"<?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"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/b.xml"/></Relationships>"#;
let prop_b = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="List" fmlaRange="$D$1:$D$3" sel="2"/>"#;
let vml = 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="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="List"><x:Anchor>2, 0, 6, 0, 5, 0, 9, 0</x:Anchor><x:FmlaRange>$D$1:$D$3</x:FmlaRange><x:Sel>2</x:Sel></x:ClientData></v:shape></xml>"##;
let list = r#"<control shapeId="1026" r:id="rIdB" name="List 1"><controlPr><anchor><from><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>6</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>5</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>9</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr></control>"#;
let controls = format!(
r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><mc:Choice Requires="x14"><controls>{list}</controls></mc:Choice><mc:Fallback><controls><control shapeId="1026" r:id="rIdB" name="List 1"/></controls></mc:Fallback></mc:AlternateContent>"#
);
let bytes = minimal(
&format!(r#"<sheetData/><legacyDrawing r:id="rId9"/>{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 <controls> comes first in the sheet, the Fallback's last.
let (first, last) = (sheet.find("<controls").unwrap(), sheet.rfind("<controls").unwrap());
assert!(first < last, "two controls elements, the kept Choice's and Fallback's: {sheet}");
let choice = &sheet[first..last];
assert!(choice.contains("name=\"Speed\"") && choice.contains("name=\"List 1\""), "the Choice holds both: {sheet}");
assert!(!sheet[last..].contains("name=\"Speed\""), "the Fallback is as the file had it: {sheet}");
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
assert_eq!(
(sh.controls[0].name.as_str(), sh.controls[0].link.as_deref(), sh.controls[0].value),
("Speed", Some("$B$1"), 40),
"read from its worksheet entry and properties part"
);
let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default();
assert!(kept.contains("List 1") && !kept.contains("Speed"), "{kept}");
}
/// 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>