Crop Image: trim an image to its pixels that aren't fully transparent

object.cropImage {trim: true} cuts the selected image to the box of its pixels
with any alpha, copying them as they are (no re-render, no resampling) and
placing them where they were. An image with nothing to cut is left as it is
and isn't an error (trimmed: false), so a script can trim every image it
opens. Print-ready PNGs usually carry transparent padding; with this, Fit to
Artwork Bounds can size to the art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fastmikey99
2026-10-09 17:24:04 +13:00
co-authored by Claude Opus 5.5
parent fc74389b5a
commit f767403673
3 changed files with 80 additions and 2 deletions
+47 -1
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Image",
["Object"],
None,
"{rect?: [x, y, width, height]} crop the selected image (default: to the artboard it sits on) → {id, width, height}",
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
has_image,
crop_image
),
@@ -541,6 +541,9 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.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")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
return trim_image(s, id, im.clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
@@ -594,6 +597,49 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "width": w, "height": h }))
}
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
/// pixels copied as they are (no resampling) and placed where they were.
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
const C: &str = "object.cropImage";
let st = s.doc()?;
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
let (w, h) = px.dimensions();
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (x, y, p) in px.enumerate_pixels() {
if p[3] > 0 {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
}
if x1 <= x0 || y1 <= y0 {
return Err(bad(C, "the image is fully transparent"));
}
if (x0, y0, x1, y1) == (0, 0, w, h) {
// Already trimmed: not an error, so a script can trim every image it opens.
return Ok(json!({ "id": id.0, "width": w, "height": h, "trimmed": false }));
}
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
let mut png = Vec::new();
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
let (cw, ch) = (x1 - x0, y1 - y0);
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Image(ImageObject {
key,
width: cw,
height: ch,
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
link: None,
placement: Default::default(),
});
}
Ok(())
})?;
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
}
/// 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> {
+32
View File
@@ -1159,3 +1159,35 @@ fn saved_selection_names_are_trimmed_and_capped() {
let list = s.execute("select.savedList", &json!({})).unwrap();
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
}
#[test]
fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
// 100 × 80 px, transparent but for a block at (20, 10)–(50, 40) px; placed at 2 pt a pixel.
let mut px = image::RgbaImage::new(100, 80);
for y in 10..40 {
for x in 20..50 {
px.put_pixel(x, y, image::Rgba([x as u8, y as u8, 7, if x == 20 { 1 } else { 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": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let img = selected(&s)[0];
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap();
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 30), "{r}");
// Placed where those pixels were: (40, 20) pt, 60 × 60 pt at 2 pt a pixel.
let b = bounds(&s, img);
assert!(close(b.x0, 40.0) && close(b.y0, 20.0) && close(b.width(), 60.0) && close(b.height(), 60.0), "{b:?}");
// Pixel for pixel, a barely visible (alpha 1) edge included.
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, image::imageops::crop_imm(&px, 20, 10, 30, 30).to_image());
assert_eq!(r["trimmed"], true);
// Nothing transparent left to cut: left as it is, and not an error (scripts trim every image).
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
assert_eq!(bounds(&s, img), b);
}