diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs
--- a/crates/engine/src/cmd/menucmds.rs
+++ b/crates/engine/src/cmd/menucmds.rs
@@ -116,6 +116,15 @@ pub fn specs() -> Vec<CommandSpec> {
             has_image,
             crop_image
         ),
+        cmd!(
+            "image.removeBackground",
+            "Remove Background",
+            ["Object"],
+            None,
+            "{color?: \"#rrggbb\" (background to erase; default: the average of the image's four corners), tolerance?: 0-255 (max channel distance from that colour; default 32)} make matching pixels transparent on the selected image (RGB/PNG only; CMYK is unchanged) → {id, width, height, cleared}",
+            has_image,
+            remove_image_background
+        ),
         cmd!(
             "object.maskImage",
             "Mask",
@@ -800,6 +809,95 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
     Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
 }
 
+/// Pixels within `tol` of `bg` on every RGB channel (opaque pixels only).
+fn matches_background(p: image::Rgba<u8>, bg: [u8; 3], tol: u8) -> bool {
+    p[3] > 0 && (p[0].abs_diff(bg[0]) <= tol && p[1].abs_diff(bg[1]) <= tol && p[2].abs_diff(bg[2]) <= tol)
+}
+
+fn corner_background(px: &image::RgbaImage) -> [u8; 3] {
+    let (w, h) = px.dimensions();
+    if w == 0 || h == 0 {
+        return [255, 255, 255];
+    }
+    let corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)];
+    let mut sum = [0u32; 3];
+    for (x, y) in corners {
+        let p = px.get_pixel(x, y);
+        sum[0] += p[0] as u32;
+        sum[1] += p[1] as u32;
+        sum[2] += p[2] as u32;
+    }
+    [(sum[0] / 4) as u8, (sum[1] / 4) as u8, (sum[2] / 4) as u8]
+}
+
+fn remove_image_background(s: &mut Session, p: &Value) -> Result<Value> {
+    const C: &str = "image.removeBackground";
+    let st = s.doc()?;
+    let id = st
+        .selection
+        .objects
+        .iter()
+        .copied()
+        .find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_))))
+        .ok_or_else(|| bad(C, "select an image"))?;
+    let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
+    let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
+    let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
+    if blob.is_cmyk() {
+        return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "cleared": 0 }));
+    }
+    let reader = || {
+        image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
+            .with_guessed_format()
+            .map_err(|_| bad(C, "the image's pixels can't be read"))
+    };
+    let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
+    if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
+        return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
+    }
+    let mut reader = reader()?;
+    let mut limits = image::Limits::default();
+    limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
+    reader.limits(limits);
+    let mut px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
+    let bg = match p.get("color").and_then(Value::as_str) {
+        Some(s) => {
+            let c = Color::from_hex(s.trim()).ok_or_else(|| bad(C, format!("unknown colour {s}")))?;
+            let [r, g, b, _] = c.to_rgba8(1.0);
+            [r, g, b]
+        }
+        None => corner_background(&px),
+    };
+    let tol = p.get("tolerance").and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|x| x.round() as u64))).map(|t| t.min(255) as u8).unwrap_or(32);
+    let mut cleared = 0u64;
+    for p in px.pixels_mut() {
+        if matches_background(*p, bg, tol) {
+            p[3] = 0;
+            cleared += 1;
+        }
+    }
+    let mut png = Vec::new();
+    px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
+    let (w, h) = px.dimensions();
+    s.edit("Remove Background", |d, _| {
+        let key = unique_key(d, "bg");
+        d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
+        if let Some(n) = d.node_mut(id) {
+            n.kind = NodeKind::Image(Box::new(ImageObject {
+                key,
+                width: w,
+                height: h,
+                xf: im.xf,
+                link: im.link.clone(),
+                placement: im.placement,
+                pixels: None,
+            }));
+        }
+        Ok(())
+    })?;
+    Ok(json!({ "id": id.0, "width": w, "height": h, "cleared": cleared }))
+}
+
 /// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
 /// outline, with that path selected so dragging its handles crops the image.
 fn mask_image(s: &mut Session, _: &Value) -> Result<Value> {
diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs
--- a/crates/engine/src/tests/menucmds.rs
+++ b/crates/engine/src/tests/menucmds.rs
@@ -3155,6 +3155,48 @@ fn crop_image_trim_checks_the_rgba_size_before_decoding() {
     assert!(within.contains("the image's pixels can't be read"), "{within}");
 }
 
+#[test]
+fn remove_background_makes_corner_colour_transparent() {
+    let mut px = image::RgbaImage::from_pixel(40, 30, image::Rgba([240, 240, 240, 255]));
+    for y in 8..22 {
+        for x in 10..30 {
+            px.put_pixel(x, y, image::Rgba([200, 40, 40, 255]));
+        }
+    }
+    let mut png = Vec::new();
+    px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+    let mut s = Session::new();
+    s.execute("document.open", &json!({"name": "logo.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
+    s.execute("select.all", &json!({})).unwrap();
+    let img = selected(&s)[0];
+    let r = s.execute("image.removeBackground", &json!({"tolerance": 5})).unwrap();
+    assert_eq!(r["cleared"].as_u64(), Some(40 * 30 - 14 * 20));
+    let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") };
+    let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
+    assert_eq!(out.get_pixel(0, 0)[3], 0);
+    assert_eq!(out.get_pixel(15, 15)[3], 255);
+    assert_eq!(out.get_pixel(15, 15)[0], 200);
+}
+
+#[test]
+fn remove_background_uses_an_explicit_colour() {
+    let mut px = image::RgbaImage::new(4, 4);
+    for p in px.pixels_mut() {
+        *p = image::Rgba([10, 20, 30, 255]);
+    }
+    px.put_pixel(2, 2, image::Rgba([250, 250, 250, 255]));
+    let mut png = Vec::new();
+    px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+    let mut s = Session::new();
+    s.execute("document.open", &json!({"name": "chip.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
+    s.execute("select.all", &json!({})).unwrap();
+    s.execute("image.removeBackground", &json!({"color": "#0a141e", "tolerance": 0})).unwrap();
+    let NodeKind::Image(im) = &node(&s, selected(&s)[0]).kind else { panic!() };
+    let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
+    assert_eq!(out.get_pixel(0, 0)[3], 0);
+    assert_eq!(out.get_pixel(2, 2)[3], 255);
+}
+
 #[test]
 fn crop_image_trim_leaves_a_cmyk_image_alone() {
     // CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs
--- a/crates/pdf/src/tests/realtext.rs
+++ b/crates/pdf/src/tests/realtext.rs
@@ -228,13 +228,13 @@ fn kashidas_are_not_written_as_hyphens() {
     if back.is_empty() {
         return;
     }
-    assert!(!back.concat().contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");
-    // Every letter comes back, at least as often as the text holds it (the justified words may
-    // come back as type objects of their own).
     let all = back.concat();
     if all.is_empty() {
         return;
     }
+    assert!(!all.contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");
+    // Every letter comes back, at least as often as the text holds it (the justified words may
+    // come back as type objects of their own).
     let count = |s: &str, c: char| s.chars().filter(|&x| x == c).count();
     for c in text.chars().filter(|c| c.is_alphabetic()) {
         assert!(count(&all, c) >= count(text, c), "{c}: {back:?}");
diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs
--- a/crates/ui-egui/src/menus/tree.rs
+++ b/crates/ui-egui/src/menus/tree.rs
@@ -177,6 +177,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
                 c("Expand…", "ui.expandDialog"),
                 c("Expand Appearance", "effect.expandAppearance"),
                 c("Crop Image", "ui.cropImage"),
+                c("Remove Background", "image.removeBackground"),
                 c("Rasterize…", "object.rasterize"),
                 cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})),
                 cp(
