#[cfg(feature = "image-ops")]
pub fn apply(bytes: &[u8], op: &cuttlefish_abi::ImageOperation) -> Result<Vec<u8>, anyhow::Error> {
use cuttlefish_abi::ImageOperation;
use image::GenericImageView as _;
let decoded =
image::load_from_memory(bytes).map_err(|e| anyhow::anyhow!("decoding the image: {e}"))?;
let out = match op {
ImageOperation::Resize {
max_width,
max_height,
} => {
if *max_width == 0 || *max_height == 0 {
anyhow::bail!("resize bounds must both be non-zero");
}
let (w, h) = decoded.dimensions();
decoded.thumbnail((*max_width).min(w), (*max_height).min(h))
}
ImageOperation::Crop {
x,
y,
width,
height,
} => {
if *width == 0 || *height == 0 {
anyhow::bail!("crop width and height must both be non-zero");
}
let (w, h) = decoded.dimensions();
if x.saturating_add(*width) > w || y.saturating_add(*height) > h {
anyhow::bail!(
"crop region {width}x{height}+{x}+{y} does not fit inside the \
image's {w}x{h}"
);
}
decoded.crop_imm(*x, *y, *width, *height)
}
};
let mut encoded = std::io::Cursor::new(Vec::new());
out.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|e| anyhow::anyhow!("re-encoding the image: {e}"))?;
Ok(encoded.into_inner())
}
#[cfg(not(feature = "image-ops"))]
pub fn apply(
_bytes: &[u8],
_op: &cuttlefish_abi::ImageOperation,
) -> Result<Vec<u8>, anyhow::Error> {
anyhow::bail!(
"this cuttlefish was built without the `image-ops` feature, so it cannot decode \
images — rebuild with `--features image-ops`. Image *metadata* (dimensions, EXIF, \
chunk structure) needs no feature and is available to any script via the \
`dimensions`/`exif`/`png_chunks` builtins."
)
}
#[cfg(all(test, feature = "image-ops"))]
mod tests {
use super::*;
use cuttlefish_abi::ImageOperation;
fn png(width: u32, height: u32) -> Vec<u8> {
let img =
image::RgbImage::from_fn(width, height, |x, _| image::Rgb([(x % 256) as u8, 0, 0]));
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
}
fn size_of(bytes: &[u8]) -> (u32, u32) {
use image::GenericImageView as _;
image::load_from_memory(bytes).unwrap().dimensions()
}
#[test]
fn resize_fits_within_bounds_and_keeps_the_aspect_ratio() {
let out = apply(
&png(800, 400),
&ImageOperation::Resize {
max_width: 100,
max_height: 100,
},
)
.unwrap();
assert_eq!(size_of(&out), (100, 50));
}
#[test]
fn resize_never_enlarges() {
let out = apply(
&png(10, 10),
&ImageOperation::Resize {
max_width: 500,
max_height: 500,
},
)
.unwrap();
assert_eq!(size_of(&out), (10, 10));
}
#[test]
fn crop_returns_exactly_the_requested_region() {
let out = apply(
&png(100, 100),
&ImageOperation::Crop {
x: 10,
y: 20,
width: 30,
height: 40,
},
)
.unwrap();
assert_eq!(size_of(&out), (30, 40));
}
#[test]
fn a_crop_outside_the_image_is_refused_rather_than_clamped() {
let err = apply(
&png(50, 50),
&ImageOperation::Crop {
x: 40,
y: 40,
width: 30,
height: 30,
},
)
.expect_err("an out-of-bounds crop must be refused");
assert!(err.to_string().contains("does not fit"), "{err}");
}
#[test]
fn zero_sized_operations_are_refused() {
assert!(apply(
&png(10, 10),
&ImageOperation::Resize {
max_width: 0,
max_height: 10
}
)
.is_err());
assert!(apply(
&png(10, 10),
&ImageOperation::Crop {
x: 0,
y: 0,
width: 0,
height: 5
}
)
.is_err());
}
#[test]
fn undecodable_bytes_error_rather_than_panic() {
let err = apply(
b"not an image at all",
&ImageOperation::Resize {
max_width: 10,
max_height: 10,
},
)
.expect_err("garbage must not decode");
assert!(err.to_string().contains("decoding"), "{err}");
}
}