Skip to content
159 changes: 135 additions & 24 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use web_sys::console;

use image::codecs::webp::WebPEncoder;
use image::{
imageops::resize,
imageops::FilterType,
GenericImageView,
ImageReader,
Expand All @@ -20,6 +19,41 @@ use std::io::Cursor;
use serde::Deserialize;
use serde_wasm_bindgen::from_value;

type ToolkitResult<T> = Result<T, ToolkitError>;

#[derive(Debug)]
enum ToolkitError {
InvalidOptions(String),
FormatGuessFailed(String),
DecodeFailed(String),
UnsupportedFormat,
JpegEncodeFailed(String),
PngEncodeFailed(String),
WebpEncodeFailed,
WriteFailed(String),
}

impl From<ToolkitError> for JsValue {
fn from(error: ToolkitError) -> Self {
match error {
ToolkitError::InvalidOptions(e) => JsValue::from_str(&format!("Invalid options: {}", e)),
ToolkitError::FormatGuessFailed(e) => {
JsValue::from_str(&format!("Format guess failed: {}", e))
}
ToolkitError::DecodeFailed(e) => JsValue::from_str(&format!("Decode failed: {}", e)),
ToolkitError::UnsupportedFormat => JsValue::from_str("Unsupported format"),
ToolkitError::JpegEncodeFailed(e) => {
JsValue::from_str(&format!("JPEG encode failed: {}", e))
}
ToolkitError::PngEncodeFailed(e) => {
JsValue::from_str(&format!("PNG encode failed: {}", e))
}
ToolkitError::WebpEncodeFailed => JsValue::from_str("Image encoding failed"),
ToolkitError::WriteFailed(e) => JsValue::from_str(&format!("Write failed: {}", e)),
}
}
}

#[derive(Deserialize)]
struct ResizeOptions {
width: Option<u32>,
Expand All @@ -32,17 +66,24 @@ struct ResizeOptions {

#[wasm_bindgen]
pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, JsValue> {
resize_image_impl(data, options).map_err(JsValue::from)
}

fn resize_image_impl(data: &[u8], options: JsValue) -> ToolkitResult<Box<[u8]>> {
let options: ResizeOptions = from_value(options).map_err(|e|
JsValue::from_str(&format!("Invalid options: {}", e))
ToolkitError::InvalidOptions(e.to_string())
)?;
resize_image_with_options(data, options)
}

fn resize_image_with_options(data: &[u8], options: ResizeOptions) -> ToolkitResult<Box<[u8]>> {
let value = map_brightness(options.brightness);

let img = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| JsValue::from_str(&format!("Format guess failed: {}", e)))?
.map_err(|e| ToolkitError::FormatGuessFailed(e.to_string()))?
.decode()
.map_err(|e| JsValue::from_str(&format!("Decode failed: {}", e)))?
.map_err(|e| ToolkitError::DecodeFailed(e.to_string()))?
.brighten(value);

let (orig_w, orig_h) = img.dimensions();
Expand All @@ -52,10 +93,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, JsValue>
let width = options.width.filter(|&w| w > 0);
let height = options.height.filter(|&h| h > 0);
let resized = match (width, height) {
(Some(w), Some(h)) => {
let buf = resize(&img.to_rgba8(), w, h, filter);
DynamicImage::ImageRgba8(buf)
}
(Some(w), Some(h)) => img.resize_exact(w, h, filter),
(Some(w), None) => {
let h = scaled_height_for_width(w, orig_w, orig_h);
img.resize(w, h, filter)
Expand All @@ -67,9 +105,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, JsValue>
(None, None) => img,
};

let format = parse_format(&options.format).ok_or_else(||
JsValue::from_str("Unsupported format")
)?;
let format = parse_format(&options.format).ok_or(ToolkitError::UnsupportedFormat)?;
let buffer = encode_image(&resized, &format, &options)?;
Ok(buffer.into_boxed_slice())
}
Expand All @@ -85,28 +121,31 @@ fn get_filter_type(level: u32) -> FilterType {
}

fn parse_format(fmt: &str) -> Option<ImageFormat> {
match fmt.to_lowercase().as_str() {
"png" => Some(ImageFormat::Png),
"jpeg" | "jpg" => Some(ImageFormat::Jpeg),
"webp" => Some(ImageFormat::WebP),
_ => None,
if fmt.eq_ignore_ascii_case("png") {
Some(ImageFormat::Png)
} else if fmt.eq_ignore_ascii_case("jpeg") || fmt.eq_ignore_ascii_case("jpg") {
Some(ImageFormat::Jpeg)
} else if fmt.eq_ignore_ascii_case("webp") {
Some(ImageFormat::WebP)
} else {
None
}
}

fn encode_image(
image: &DynamicImage,
format: &ImageFormat,
options: &ResizeOptions
) -> Result<Vec<u8>, JsValue> {
) -> ToolkitResult<Vec<u8>> {
let mut buffer = Vec::new();
let quality = (options.quality.unwrap_or(0.8) * 100.0).round().clamp(1.0, 100.0) as u8;
let quality = (options.quality.unwrap_or(0.7) * 100.0).round().clamp(1.0, 100.0) as u8;

match format {
ImageFormat::Jpeg => {
let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
encoder
.encode_image(image)
.map_err(|e| JsValue::from_str(&format!("JPEG encode failed: {}", e)))?;
.map_err(|e| ToolkitError::JpegEncodeFailed(e.to_string()))?;
}
ImageFormat::Png => {
encode_as_png(image, &mut buffer)?;
Expand All @@ -117,14 +156,14 @@ fn encode_image(
_ => {
image
.write_to(&mut Cursor::new(&mut buffer), *format)
.map_err(|e| JsValue::from_str(&format!("Write failed: {}", e)))?;
.map_err(|e| ToolkitError::WriteFailed(e.to_string()))?;
}
}

Ok(buffer)
}

fn encode_as_png(image: &DynamicImage, buffer: &mut Vec<u8>) -> Result<(), JsValue> {
fn encode_as_png(image: &DynamicImage, buffer: &mut Vec<u8>) -> ToolkitResult<()> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();

Expand All @@ -136,13 +175,13 @@ fn encode_as_png(image: &DynamicImage, buffer: &mut Vec<u8>) -> Result<(), JsVal

encoder
.write_image(&rgba, w, h, ExtendedColorType::Rgba8)
.map_err(|e| JsValue::from_str(&format!("PNG encode failed: {}", e)))
.map_err(|e| ToolkitError::PngEncodeFailed(e.to_string()))
}

fn encode_as_webp(
image: &DynamicImage,
buffer: &mut Vec<u8>
) -> Result<(), JsValue> {
) -> ToolkitResult<()> {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();

Expand All @@ -151,7 +190,7 @@ fn encode_as_webp(
.encode(&rgba, width, height, ExtendedColorType::Rgba8)
.map_err(|e| {
console::error_1(&JsValue::from_str(&format!("WebP encode failed: {}", e)));
JsValue::from_str("Image encoding failed")
ToolkitError::WebpEncodeFailed
})
}

Expand All @@ -172,6 +211,24 @@ fn scaled_width_for_height(height: u32, orig_w: u32, orig_h: u32) -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
use std::io::Cursor;

fn make_test_png(width: u32, height: u32) -> Vec<u8> {
let rgba = RgbaImage::from_pixel(width, height, image::Rgba([120, 80, 200, 255]));
let img = DynamicImage::ImageRgba8(rgba);
let mut bytes = Vec::new();
img.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap();
bytes
}

fn decode_image(bytes: &[u8]) -> (ImageFormat, DynamicImage) {
let mut reader = ImageReader::new(Cursor::new(bytes));
reader = reader.with_guessed_format().unwrap();
let format = reader.format().unwrap();
let image = reader.decode().unwrap();
(format, image)
}

#[test]
fn map_brightness_clamps_range() {
Expand Down Expand Up @@ -210,4 +267,58 @@ mod tests {
assert_eq!(scaled_height_for_width(400, 1200, 800), 267);
assert_eq!(scaled_width_for_height(267, 1200, 800), 401);
}

#[test]
fn resize_image_exact_dimensions_encodes_as_jpeg() {
let input = make_test_png(120, 80);
let options = ResizeOptions {
width: Some(64),
height: Some(64),
quality: None,
format: "jpg".to_string(),
brightness: 0.5,
resampling: 4,
};

let output = resize_image_with_options(&input, options).unwrap();
let (format, decoded) = decode_image(&output);

assert_eq!(format, ImageFormat::Jpeg);
assert_eq!(decoded.dimensions(), (64, 64));
}

#[test]
fn resize_image_single_dimension_preserves_aspect_ratio() {
let input = make_test_png(120, 80);
let options = ResizeOptions {
width: Some(60),
height: None,
quality: Some(0.7),
format: "png".to_string(),
brightness: 0.5,
resampling: 4,
};

let output = resize_image_with_options(&input, options).unwrap();
let (format, decoded) = decode_image(&output);

assert_eq!(format, ImageFormat::Png);
assert_eq!(decoded.dimensions(), (60, 40));
}

#[test]
fn resize_image_rejects_unsupported_format() {
let input = make_test_png(32, 32);
let options = ResizeOptions {
width: Some(32),
height: Some(32),
quality: None,
format: "gif".to_string(),
brightness: 0.5,
resampling: 4,
};

let err = resize_image_with_options(&input, options).unwrap_err();
assert!(matches!(err, ToolkitError::UnsupportedFormat));
}
}