Browse / github.com/storytold/wordcraft / patch
DOCX: keep ActiveX controls on save and draw a placeholder box (#3597)
DOCX: ActiveX checkbox controls (w:object/w:control) are not drawn and are removed on save **Version:** wordcraft 0.9.0 (2e6db0bd), Linux x86_64 (.deb) A DOCX that contains ActiveX checkbox controls loses them without any warning: 1. **Render / PDF export:** the checkboxes are not drawn. Table cells that hold only a checkbox come out empty. 2. **Save as DOCX:** every control is removed. The file goes from 155 `w:control` elements and 155 `word/activeX/*.xml` parts to 0 of each. Point 2 is data loss: opening and saving the file in WordCraft deletes the controls for good. This is related to #1174 (legacy `FORMCHECKBOX` fields), but it affects a different element. ### Markup Each
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #4615, open)
- Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc- Size
- +84 −4 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
90b8c7dc0dee54440e218596
How it went
No other operator has reported using it yet.
Diff
raw · crates/doc/src/connector.rs crates/docx/src/read/story/vml.rs crates/docx/src/write/story/object.rs crates/docx/tests/it/fixtures.rs
@@ -83,6 +83,11 @@ pub struct ShapeExtra { /// main colour (the shape's `stroke`). #[serde(skip_serializing_if = "Option::is_none")] pub outline_fill: Option<crate::fill::Fill>,+ /// An ActiveX control read from a file (`w:object` holding a `w:control`): the shape is its+ /// placeholder, and saving writes the control back with its parts. See+ /// [`crate::graphic::Embedded`].+ #[serde(skip_serializing_if = "Option::is_none")]+ pub ole: Option<std::sync::Arc<crate::graphic::Embedded>>, } fn is_zero(v: &u32) -> bool {@@ -332,10 +332,10 @@ impl Reader<'_> { pub(super) fn read_object(&mut self, sc: &mut StoryCtx, obj: &El, rels: &Rels) -> Option<InlineObject> { let mut o = match self.read_vml(sc, obj, rels) { Some(o) => o,- None => {- let d = obj.child("w:drawing")?;- self.read_drawing(sc, d, rels)?- }+ None => match obj.child("w:drawing") {+ Some(d) => self.read_drawing(sc, d, rels)?,+ None => return self.read_control(sc, obj, rels),+ }, }; if let InlineObject::Image { ole, .. } = &mut o { *ole = self.keep_embedded(obj, rels, &[]);@@ -343,6 +343,30 @@ impl Reader<'_> { Some(o) } + /// An ActiveX control (`w:object` with a `w:control`, ECMA-376 §17.3.3.19): usually it has no+ /// picture, so it is drawn as an empty box of its VML shape's size, and kept whole so saving+ /// writes the control and its `activeX` parts back.+ fn read_control(&mut self, sc: &mut StoryCtx, obj: &El, rels: &Rels) -> Option<InlineObject> {+ obj.child("w:control")?;+ let shape = obj.child("v:shape")?;+ let style = shape.attr("style").unwrap_or("");+ let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);+ let ole = self.keep_embedded(obj, rels, &[])?;+ Some(InlineObject::Shape {+ kind: ShapeKind::Rectangle,+ w: len("width"),+ h: len("height"),+ fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),+ stroke: Some(Rgb::BLACK),+ stroke_width: 0.75,+ float: vml_float(shape, style, sc.in_hf),+ story: None,+ freeform: None,+ effects: Default::default(),+ extra: ShapeExtra { ole: Some(ole), ..Default::default() },+ })+ }+ /// A Word signature line (Insert › Signature Line): a `w:pict` whose one VML picture carries /// an `o:signatureline` (the suggested signer, title, e-mail and instructions). Shown as its /// picture, and kept whole like an OLE object so saving writes the signature line back,@@ -210,6 +210,18 @@ impl Writer<'_> { self.rev_close(w, props); } InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => {+ // An ActiveX control read from a file: the control itself, at its current size.+ if let Some(src) = extra.ole.as_deref()+ && let Some(obj) = self.embedded_xml(src, rels, Some((*sw, *sh, float, o.alt_text())))+ {+ self.rev_open(w, props);+ w.open("w:r", &[]);+ self.rpr(w, props);+ w.raw(&obj);+ w.close("w:r");+ self.rev_close(w, props);+ return;+ } self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props);@@ -1734,6 +1734,45 @@ fn ole_object_round_trips() { assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. }))); } +/// #3597: an ActiveX control has no picture; it is drawn as a box and written back with its parts.+#[test]+fn activex_control_round_trips() {+ let body = r##"<w:p><w:r><w:object><v:shape id="control_shape_1" o:allowincell="t" style="width:11.05pt;height:7.3pt" type="#_x0000_t75"></v:shape><w:control r:id="rId3" w:name="CheckBox1" w:shapeid="control_shape_1"/></w:object></w:r></w:p>"##;+ let ax = r#"<ax:ocx xmlns:ax="http://schemas.microsoft.com/office/2006/activeX" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ax:classid="{8BD21D40-EC42-11CE-9E0D-00AA006002F3}" ax:persistence="persistStorage" r:id="rId1"/>"#;+ let ax_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/activeXControlBinary" Target="activeX1.bin"/></Relationships>"#;+ let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="bin" ContentType="application/vnd.ms-office.activeX"/><Override PartName="/word/activeX/activeX1.xml" ContentType="application/vnd.ms-office.activeX+xml"/></Types>"#;+ let bytes = docx(+ body,+ &[("rId3", "control", "activeX/activeX1.xml")],+ &[+ ("[Content_Types].xml", types),+ ("word/activeX/activeX1.xml", ax),+ ("word/activeX/_rels/activeX1.xml.rels", ax_rels),+ ("word/activeX/activeX1.bin", "opaque control state"),+ ],+ );+ let d = wordcraft_docx::read(&bytes).unwrap();+ let Some(InlineObject::Shape { w, h, extra, .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };+ assert_eq!((*w, *h), (11.05, 7.3), "drawn at the shape's size");+ assert!(extra.ole.is_some());++ let out = wordcraft_docx::write(&d).unwrap();+ let files = unzip(&out);+ let body = text(&files, "word/document.xml");+ assert_eq!(body.matches("<w:control ").count(), 1, "{body}");+ let [id] = attr_values(&body, "w:control", "r:id").try_into().unwrap();+ let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);+ assert!(kind.ends_with("/control"), "{kind}");+ let part = part_at("word", &target);+ assert_eq!(files.get(&part).map(Vec::as_slice), Some(ax.as_bytes()));+ let bin = files.keys().find(|k| k.ends_with(".bin")).expect("the control's binary part");+ assert_eq!(files.get(bin).map(Vec::as_slice), Some(b"opaque control state".as_slice()));+ assert!(text(&files, "[Content_Types].xml").contains("application/vnd.ms-office.activeX+xml"));++ let back = wordcraft_docx::read(&out).unwrap();+ assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Shape { extra, .. }) if extra.ole.is_some()));+}+ #[test] fn an_unqualified_relationship_attribute_round_trips() { // A producer that left the relationship prefix out: the VML picture names its image with an