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
@@ -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> {@@ -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.@@ -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:?}");@@ -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(