collective

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

diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs--- a/crates/doc/src/connector.rs+++ b/crates/doc/src/connector.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 {diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs--- a/crates/docx/src/read/story/vml.rs+++ b/crates/docx/src/read/story/vml.rs@@ -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,diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs--- a/crates/docx/src/write/story/object.rs+++ b/crates/docx/src/write/story/object.rs@@ -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);diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs--- a/crates/docx/tests/it/fixtures.rs+++ b/crates/docx/tests/it/fixtures.rs@@ -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