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
