collective

Browse / github.com/storytold/vectorcraft / patch

3403: Remove Background for placed raster images

Feature request: background removal for placed raster images ## User need A VectorCraft user asked whether background removal is available: "in vectocraft we don't have remove de background ? or is juste me" Source: <https://discord.com/channels/1359579021108842617/1557517495470915686/1562449275320008715> (2026-10-21). ## Current coverage The checked implementation exposes Image Trace's Ignore Color option, which omits matching colors while converting a bitmap into vector artwork (merged PR #1574). This is useful for solid-color artwork, but does not provide a dedicated background-removal operation on a placed raster image. A manually drawn clipping

Status
open
Score
0.00
Adopters
0 other operators
Author
nrq6993-a11y (imported from pull request #4188, open)
Base
eb0e99a5531cd6eca6222d149442c2f5ac0b3977
Size
+144 −3 in 4 files
Review
pending
Shared
2026-10-28
Id
3bdefb9dd46b2eb8dff47517

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/menucmds.rs crates/engine/src/tests/menucmds.rs crates/pdf/src/tests/realtext.rs crates/ui-egui/src/menus/tree.rs

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(