use std::path::{Path, PathBuf};
use ab_glyph::{Font, FontVec, Glyph, PxScale, ScaleFont};
use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ImageError {
#[error("Failed to open image: {0}")]
OpenFailed(String),
#[error("Failed to save image: {0}")]
SaveFailed(String),
#[error("Unsupported image type: {0}")]
UnsupportedType(String),
#[error("Invalid color: {0}")]
InvalidColor(String),
#[error("Failed to load font: {0}")]
FontLoadFailed(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Decode(#[from] image::ImageError),
#[error("{0}")]
InvalidArgument(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImageType {
#[default]
Unknown,
Gif,
Jpeg,
Png,
Wbmp,
}
impl ImageType {
pub fn as_str(self) -> &'static str {
match self {
ImageType::Unknown => "",
ImageType::Gif => "GIF",
ImageType::Jpeg => "JPEG",
ImageType::Png => "PNG",
ImageType::Wbmp => "WBMP",
}
}
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"gif" => ImageType::Gif,
"jpg" | "jpeg" => ImageType::Jpeg,
"png" => ImageType::Png,
"wbmp" => ImageType::Wbmp,
_ => ImageType::Unknown,
}
}
pub fn from_image_format(format: image::ImageFormat) -> Self {
match format {
image::ImageFormat::Gif => ImageType::Gif,
image::ImageFormat::Jpeg => ImageType::Jpeg,
image::ImageFormat::Png => ImageType::Png,
image::ImageFormat::WebP => ImageType::Unknown, _ => ImageType::Unknown,
}
}
pub fn to_image_format(self) -> Option<image::ImageFormat> {
match self {
ImageType::Gif => Some(image::ImageFormat::Gif),
ImageType::Jpeg => Some(image::ImageFormat::Jpeg),
ImageType::Png => Some(image::ImageFormat::Png),
ImageType::Wbmp => None, ImageType::Unknown => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Color {
pub fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub fn from_hex(hex: &str) -> Result<Self, ImageError> {
let hex = hex.trim().trim_start_matches('#');
let parse = |s: &str| {
u8::from_str_radix(s, 16).map_err(|_| ImageError::InvalidColor(hex.to_string()))
};
let (r, g, b, a) = match hex.len() {
3 => {
let r = parse(&format!("{}{}", &hex[0..1], &hex[0..1]))?;
let g = parse(&format!("{}{}", &hex[1..2], &hex[1..2]))?;
let b = parse(&format!("{}{}", &hex[2..3], &hex[2..3]))?;
(r, g, b, 255u8)
}
4 => {
let r = parse(&format!("{}{}", &hex[0..1], &hex[0..1]))?;
let g = parse(&format!("{}{}", &hex[1..2], &hex[1..2]))?;
let b = parse(&format!("{}{}", &hex[2..3], &hex[2..3]))?;
let a = parse(&format!("{}{}", &hex[3..4], &hex[3..4]))?;
(r, g, b, a)
}
6 => {
let r = parse(&hex[0..2])?;
let g = parse(&hex[2..4])?;
let b = parse(&hex[4..6])?;
(r, g, b, 255u8)
}
8 => {
let r = parse(&hex[0..2])?;
let g = parse(&hex[2..4])?;
let b = parse(&hex[4..6])?;
let a = parse(&hex[6..8])?;
(r, g, b, a)
}
_ => return Err(ImageError::InvalidColor(hex.to_string())),
};
Ok(Self { r, g, b, a })
}
pub fn to_rgba(self) -> Rgba<u8> {
Rgba([self.r, self.g, self.b, self.a])
}
}
impl Default for Color {
fn default() -> Self {
Self::rgb(0, 0, 0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
TopLeft,
TopCenter,
TopRight,
CenterLeft,
Center,
CenterRight,
BottomLeft,
BottomCenter,
BottomRight,
}
impl Position {
pub fn parse(s: &str) -> Result<Self, ImageError> {
match s.to_lowercase().as_str() {
"top-left" => Ok(Self::TopLeft),
"top-center" => Ok(Self::TopCenter),
"top-right" => Ok(Self::TopRight),
"center-left" => Ok(Self::CenterLeft),
"center" => Ok(Self::Center),
"center-right" => Ok(Self::CenterRight),
"bottom-left" => Ok(Self::BottomLeft),
"bottom-center" => Ok(Self::BottomCenter),
"bottom-right" => Ok(Self::BottomRight),
_ => Err(ImageError::InvalidArgument(format!(
"Unknown position: {s}"
))),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::TopLeft => "top-left",
Self::TopCenter => "top-center",
Self::TopRight => "top-right",
Self::CenterLeft => "center-left",
Self::Center => "center",
Self::CenterRight => "center-right",
Self::BottomLeft => "bottom-left",
Self::BottomCenter => "bottom-center",
Self::BottomRight => "bottom-right",
}
}
pub fn get_xy(self, w1: u32, h1: u32, w2: u32, h2: u32) -> (i32, i32) {
let w1 = w1 as i32;
let h1 = h1 as i32;
let w2 = w2 as i32;
let h2 = h2 as i32;
let x = match self {
Self::TopLeft | Self::CenterLeft | Self::BottomLeft => 0,
Self::TopCenter | Self::Center | Self::BottomCenter => (w1 - w2) / 2,
Self::TopRight | Self::CenterRight | Self::BottomRight => w1 - w2,
};
let y = match self {
Self::TopLeft | Self::TopCenter | Self::TopRight => 0,
Self::CenterLeft | Self::Center | Self::CenterRight => (h1 - h2) / 2,
Self::BottomLeft | Self::BottomCenter | Self::BottomRight => h1 - h2,
};
(x, y)
}
}
#[derive(Debug)]
pub struct Image {
dyn_image: DynamicImage,
file_path: Option<PathBuf>,
image_type: ImageType,
}
impl Image {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, ImageError> {
let path = path.as_ref();
let dyn_image = image::open(path)?;
let image_type = guess_image_type(path)?;
Ok(Self {
dyn_image,
file_path: Some(path.to_path_buf()),
image_type,
})
}
pub fn create_blank(width: u32, height: u32) -> Self {
let image: RgbaImage = ImageBuffer::new(width, height);
Self {
dyn_image: DynamicImage::ImageRgba8(image),
file_path: None,
image_type: ImageType::Unknown,
}
}
pub fn from_dynamic(dyn_image: DynamicImage, image_type: ImageType) -> Self {
Self {
dyn_image,
file_path: None,
image_type,
}
}
pub fn width(&self) -> u32 {
self.dyn_image.width()
}
pub fn height(&self) -> u32 {
self.dyn_image.height()
}
pub fn image_type(&self) -> ImageType {
self.image_type
}
pub fn file_path(&self) -> Option<&Path> {
self.file_path.as_deref()
}
pub fn as_dynamic(&self) -> &DynamicImage {
&self.dyn_image
}
pub fn as_dynamic_mut(&mut self) -> &mut DynamicImage {
&mut self.dyn_image
}
pub fn to_rgba8(&self) -> RgbaImage {
self.dyn_image.to_rgba8()
}
pub fn from_rgba8(image: RgbaImage, image_type: ImageType) -> Self {
Self {
dyn_image: DynamicImage::ImageRgba8(image),
file_path: None,
image_type,
}
}
}
fn guess_image_type(path: &Path) -> Result<ImageType, ImageError> {
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let from_ext = ImageType::from_extension(ext);
if from_ext != ImageType::Unknown {
return Ok(from_ext);
}
let format = image::ImageReader::open(path)
.map_err(|e| ImageError::OpenFailed(format!("{path:?}: {e}")))?
.with_guessed_format()
.map_err(|e| ImageError::OpenFailed(format!("{path:?}: {e}")))?
.format()
.ok_or_else(|| ImageError::UnsupportedType("Unknown image format".to_string()))?;
Ok(ImageType::from_image_format(format))
}
pub struct Editor;
impl Editor {
pub fn new() -> Self {
Self
}
pub fn open<P: AsRef<Path>>(&self, path: P) -> Result<Image, ImageError> {
Image::open(path)
}
pub fn resize_exact(&self, image: &mut Image, new_width: u32, new_height: u32) {
let resized = image::imageops::resize(
image.as_dynamic(),
new_width,
new_height,
image::imageops::FilterType::Lanczos3,
);
image.dyn_image = DynamicImage::ImageRgba8(resized);
}
pub fn resize_fit(&self, image: &mut Image, new_width: u32, new_height: u32) {
let (w, h) = (image.width(), image.height());
let ratio = (new_width as f64 / w as f64).min(new_height as f64 / h as f64);
let target_w = (w as f64 * ratio).round() as u32;
let target_h = (h as f64 * ratio).round() as u32;
let resized = image::imageops::resize(
image.as_dynamic(),
target_w,
target_h,
image::imageops::FilterType::Lanczos3,
);
image.dyn_image = DynamicImage::ImageRgba8(resized);
}
pub fn resize_fill(&self, image: &mut Image, new_width: u32, new_height: u32) {
let (w, h) = (image.width(), image.height());
let ratio = (new_width as f64 / w as f64).max(new_height as f64 / h as f64);
let scaled_w = (w as f64 * ratio).round() as u32;
let scaled_h = (h as f64 * ratio).round() as u32;
let scaled = image::imageops::resize(
image.as_dynamic(),
scaled_w,
scaled_h,
image::imageops::FilterType::Lanczos3,
);
let x = (scaled_w - new_width) / 2;
let y = (scaled_h - new_height) / 2;
let cropped = image::imageops::crop_imm(&scaled, x, y, new_width, new_height).to_image();
image.dyn_image = DynamicImage::ImageRgba8(cropped);
}
pub fn resize_exact_width(&self, image: &mut Image, new_width: u32) {
let h = image.height();
let new_height = (h as f64 * (new_width as f64 / image.width() as f64)).round() as u32;
self.resize_exact(image, new_width, new_height);
}
pub fn resize_exact_height(&self, image: &mut Image, new_height: u32) {
let w = image.width();
let new_width = (w as f64 * (new_height as f64 / image.height() as f64)).round() as u32;
self.resize_exact(image, new_width, new_height);
}
pub fn crop(
&self,
image: &mut Image,
crop_width: u32,
crop_height: u32,
position: Position,
offset_x: i32,
offset_y: i32,
) -> Result<(), ImageError> {
let (w, h) = (image.width(), image.height());
if crop_width > w || crop_height > h {
return Err(ImageError::InvalidArgument(format!(
"crop size {crop_width}x{crop_height} larger than image {w}x{h}"
)));
}
let (mut x, mut y) = position.get_xy(w, h, crop_width, crop_height);
x += offset_x;
y += offset_y;
let x = x.max(0) as u32;
let y = y.max(0) as u32;
let x = x.min(w - crop_width);
let y = y.min(h - crop_height);
let cropped =
image::imageops::crop_imm(image.as_dynamic(), x, y, crop_width, crop_height).to_image();
image.dyn_image = DynamicImage::ImageRgba8(cropped);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn blend(
&self,
image1: &mut Image,
image2: &Image,
blend_type: BlendType,
opacity: f32,
position: Position,
offset_x: i32,
offset_y: i32,
) -> Result<(), ImageError> {
let (w1, h1) = (image1.width(), image1.height());
let (w2, h2) = (image2.width(), image2.height());
let (base_x, base_y) = position.get_xy(w1, h1, w2, h2);
let x = base_x + offset_x;
let y = base_y + offset_y;
let mut base = image1.to_rgba8();
let overlay = image2.to_rgba8();
match blend_type {
BlendType::Normal => {
blend_normal(&mut base, &overlay, x, y, opacity);
}
BlendType::Multiply => {
blend_multiply(&mut base, &overlay, x, y, opacity);
}
BlendType::Overlay => {
blend_overlay(&mut base, &overlay, x, y, opacity);
}
BlendType::Screen => {
blend_screen(&mut base, &overlay, x, y, opacity);
}
}
image1.dyn_image = DynamicImage::ImageRgba8(base);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn text(
&self,
image: &mut Image,
text: &str,
size: u32,
x: i32,
y: i32,
color: Color,
font_path: Option<&Path>,
) -> Result<(), ImageError> {
let font = load_font(font_path)?;
let rust_y = y - size as i32;
let mut rgba_image = image.to_rgba8();
let scale = PxScale::from(size as f32);
imageproc::drawing::draw_text_mut(
&mut rgba_image,
color.to_rgba(),
x,
rust_y,
scale,
&font,
text,
);
image.dyn_image = DynamicImage::ImageRgba8(rgba_image);
Ok(())
}
pub fn rotate(&self, image: &mut Image, angle: f32) -> Result<(), ImageError> {
let angle = angle.rem_euclid(360.0);
let rotated = match angle as i32 {
0 => image.dyn_image.clone(),
90 | -270 => image.dyn_image.rotate90(),
180 | -180 => image.dyn_image.rotate180(),
270 | -90 => image.dyn_image.rotate270(),
_ => {
return Err(ImageError::InvalidArgument(format!(
"rotate only supports 0/90/180/270 degrees, got {angle}"
)))
}
};
image.dyn_image = rotated;
Ok(())
}
pub fn flip(&self, image: &mut Image, mode: FlipMode) {
match mode {
FlipMode::Horizontal => image.dyn_image = image.dyn_image.fliph(),
FlipMode::Vertical => image.dyn_image = image.dyn_image.flipv(),
}
}
pub fn fill(&self, image: &mut Image, color: Color) {
let (w, h) = (image.width(), image.height());
let pixel = color.to_rgba();
let mut buf: RgbaImage = ImageBuffer::new(w, h);
for y in 0..h {
for x in 0..w {
buf.put_pixel(x, y, pixel);
}
}
image.dyn_image = DynamicImage::ImageRgba8(buf);
}
pub fn save(
&self,
image: &Image,
file: &Path,
image_type: Option<ImageType>,
quality: Option<u8>,
_interlace: bool,
permission: u32,
) -> Result<(), ImageError> {
let _ = &permission;
let save_type = image_type.unwrap_or_else(|| {
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
let t = ImageType::from_extension(ext);
if t != ImageType::Unknown {
t
} else {
image.image_type()
}
});
if let Some(parent) = file.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
parent,
std::fs::Permissions::from_mode(permission),
);
}
}
}
match save_type {
ImageType::Png => {
image.as_dynamic().save(file)?;
}
ImageType::Jpeg => {
let q = quality.unwrap_or(75);
let q = q.clamp(1, 100);
let rgba = image.to_rgba8();
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
let mut file = std::fs::File::create(file)?;
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, q);
encoder.encode_image(&image::DynamicImage::ImageRgb8(rgb))?;
}
ImageType::Gif => {
image.as_dynamic().save(file)?;
}
ImageType::Wbmp => {
return Err(ImageError::UnsupportedType(
"WBMP encoding not supported by image crate".to_string(),
));
}
ImageType::Unknown => {
return Err(ImageError::UnsupportedType(format!(
"Cannot determine save type for file: {file:?}"
)));
}
}
Ok(())
}
}
impl Default for Editor {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlendType {
Normal,
Multiply,
Overlay,
Screen,
}
impl BlendType {
pub fn parse(s: &str) -> Result<Self, ImageError> {
match s.to_lowercase().as_str() {
"normal" => Ok(Self::Normal),
"multiply" => Ok(Self::Multiply),
"overlay" => Ok(Self::Overlay),
"screen" => Ok(Self::Screen),
_ => Err(ImageError::InvalidArgument(format!(
"Unknown blend type: {s}"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlipMode {
Horizontal,
Vertical,
}
impl FlipMode {
pub fn parse(s: &str) -> Result<Self, ImageError> {
match s.to_lowercase().as_str() {
"h" => Ok(Self::Horizontal),
"v" => Ok(Self::Vertical),
_ => Err(ImageError::InvalidArgument(format!(
"Unknown flip mode: {s}"
))),
}
}
}
fn blend_normal(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
let (w1, h1) = base.dimensions();
let (w2, h2) = overlay.dimensions();
let opacity = opacity.clamp(0.0, 1.0);
for oy in 0..h2 {
for ox in 0..w2 {
let bx = x + ox as i32;
let by = y + oy as i32;
if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
continue;
}
let src = overlay.get_pixel(ox, oy);
let dst = base.get_pixel(bx as u32, by as u32);
let src_alpha = (src[3] as f32 / 255.0) * opacity;
if src_alpha < 1e-6 {
continue;
}
let dst_alpha = dst[3] as f32 / 255.0;
let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
if out_alpha < 1e-6 {
base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
continue;
}
let out_r = ((src[0] as f32 * src_alpha
+ dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_g = ((src[1] as f32 * src_alpha
+ dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_b = ((src[2] as f32 * src_alpha
+ dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_a = (out_alpha * 255.0) as u8;
base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
}
}
}
fn blend_multiply(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
let (w1, h1) = base.dimensions();
let (w2, h2) = overlay.dimensions();
let opacity = opacity.clamp(0.0, 1.0);
for oy in 0..h2 {
for ox in 0..w2 {
let bx = x + ox as i32;
let by = y + oy as i32;
if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
continue;
}
let src = overlay.get_pixel(ox, oy);
let dst = base.get_pixel(bx as u32, by as u32);
let src_alpha = (src[3] as f32 / 255.0) * opacity;
if src_alpha < 1e-6 {
continue;
}
let mult_r = (src[0] as u16 * dst[0] as u16 / 255) as u8;
let mult_g = (src[1] as u16 * dst[1] as u16 / 255) as u8;
let mult_b = (src[2] as u16 * dst[2] as u16 / 255) as u8;
let dst_alpha = dst[3] as f32 / 255.0;
let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
if out_alpha < 1e-6 {
base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
continue;
}
let out_r = ((mult_r as f32 * src_alpha
+ dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_g = ((mult_g as f32 * src_alpha
+ dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_b = ((mult_b as f32 * src_alpha
+ dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_a = (out_alpha * 255.0) as u8;
base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
}
}
}
fn blend_overlay(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
let (w1, h1) = base.dimensions();
let (w2, h2) = overlay.dimensions();
let opacity = opacity.clamp(0.0, 1.0);
for oy in 0..h2 {
for ox in 0..w2 {
let bx = x + ox as i32;
let by = y + oy as i32;
if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
continue;
}
let src = overlay.get_pixel(ox, oy);
let dst = base.get_pixel(bx as u32, by as u32);
let src_alpha = (src[3] as f32 / 255.0) * opacity;
if src_alpha < 1e-6 {
continue;
}
let overlay_channel = |s: u8, d: u8| -> u8 {
if d <= 128 {
(2 * s as u16 * d as u16 / 255) as u8
} else {
(255 - (2 * (255 - s) as u16 * (255 - d) as u16 / 255)) as u8
}
};
let ov_r = overlay_channel(src[0], dst[0]);
let ov_g = overlay_channel(src[1], dst[1]);
let ov_b = overlay_channel(src[2], dst[2]);
let dst_alpha = dst[3] as f32 / 255.0;
let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
if out_alpha < 1e-6 {
base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
continue;
}
let out_r = ((ov_r as f32 * src_alpha + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_g = ((ov_g as f32 * src_alpha + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_b = ((ov_b as f32 * src_alpha + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_a = (out_alpha * 255.0) as u8;
base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
}
}
}
fn blend_screen(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
let (w1, h1) = base.dimensions();
let (w2, h2) = overlay.dimensions();
let opacity = opacity.clamp(0.0, 1.0);
for oy in 0..h2 {
for ox in 0..w2 {
let bx = x + ox as i32;
let by = y + oy as i32;
if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
continue;
}
let src = overlay.get_pixel(ox, oy);
let dst = base.get_pixel(bx as u32, by as u32);
let src_alpha = (src[3] as f32 / 255.0) * opacity;
if src_alpha < 1e-6 {
continue;
}
let screen_r = (255 - (255 - src[0]) as u16 * (255 - dst[0]) as u16 / 255) as u8;
let screen_g = (255 - (255 - src[1]) as u16 * (255 - dst[1]) as u16 / 255) as u8;
let screen_b = (255 - (255 - src[2]) as u16 * (255 - dst[2]) as u16 / 255) as u8;
let dst_alpha = dst[3] as f32 / 255.0;
let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
if out_alpha < 1e-6 {
base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
continue;
}
let out_r = ((screen_r as f32 * src_alpha
+ dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_g = ((screen_g as f32 * src_alpha
+ dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_b = ((screen_b as f32 * src_alpha
+ dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
/ out_alpha) as u8;
let out_a = (out_alpha * 255.0) as u8;
base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
}
}
}
fn load_font(font_path: Option<&Path>) -> Result<FontVec, ImageError> {
match font_path {
Some(path) => {
let data = std::fs::read(path)
.map_err(|e| ImageError::FontLoadFailed(format!("{path:?}: {e}")))?;
Ok(FontVec::try_from_vec(data)
.map_err(|e| ImageError::FontLoadFailed(format!("Invalid font {path:?}: {e}")))?)
}
None => {
Err(ImageError::FontLoadFailed(
"font_path is required (no default font available)".to_string(),
))
}
}
}
pub fn measure_text(font_path: &Path, size: u32, text: &str) -> Result<TextMetrics, ImageError> {
let data = std::fs::read(font_path)
.map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
let font = FontVec::try_from_vec(data)
.map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
Ok(measure_text_with_font(&font, size, text))
}
fn measure_text_with_font<F: Font>(font: &F, size: u32, text: &str) -> TextMetrics {
let scale = PxScale::from(size as f32);
let scaled = font.as_scaled(scale);
let ascent = scaled.ascent();
let descent = scaled.descent();
let height = (ascent - descent).ceil();
let mut width: f32 = 0.0;
let mut prev_glyph: Option<Glyph> = None;
for ch in text.chars() {
let glyph = scaled.scaled_glyph(ch);
if let Some(prev) = prev_glyph {
width += scaled.kern(prev.id, glyph.id);
}
width += scaled.h_advance(glyph.id);
prev_glyph = Some(glyph);
}
TextMetrics {
width: width.ceil() as i32,
height: height.ceil() as i32,
ascent: ascent.ceil() as i32,
descent: descent.ceil() as i32,
}
}
#[derive(Debug, Clone, Copy)]
pub struct TextMetrics {
pub width: i32,
pub height: i32,
pub ascent: i32,
pub descent: i32,
}
pub fn wrap_text(
font_path: &Path,
fontsize: u32,
string: &str,
width: i32,
max_line: Option<usize>,
) -> Result<String, ImageError> {
let data = std::fs::read(font_path)
.map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
let font = FontVec::try_from_vec(data)
.map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
Ok(wrap_text_with_font(
&font, fontsize, string, width, max_line,
))
}
fn wrap_text_with_font<F: Font>(
font: &F,
fontsize: u32,
string: &str,
width: i32,
max_line: Option<usize>,
) -> String {
let mut content = String::new();
let mut line_count: usize = 0;
for l in string.chars() {
let test = format!("{content} {l}");
let metrics = measure_text_with_font(font, fontsize, &test);
if metrics.width > width && !content.is_empty() {
line_count += 1;
if let Some(ml) = max_line {
if line_count >= ml {
let trimmed: String =
content.chars().take(content.chars().count() - 1).collect();
content = format!("{trimmed}...");
break;
}
}
content.push('\n');
}
content.push(l);
}
content
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_type_as_str() {
assert_eq!(ImageType::Unknown.as_str(), "");
assert_eq!(ImageType::Gif.as_str(), "GIF");
assert_eq!(ImageType::Jpeg.as_str(), "JPEG");
assert_eq!(ImageType::Png.as_str(), "PNG");
assert_eq!(ImageType::Wbmp.as_str(), "WBMP");
}
#[test]
fn test_image_type_from_extension() {
assert_eq!(ImageType::from_extension("gif"), ImageType::Gif);
assert_eq!(ImageType::from_extension("jpg"), ImageType::Jpeg);
assert_eq!(ImageType::from_extension("jpeg"), ImageType::Jpeg);
assert_eq!(ImageType::from_extension("png"), ImageType::Png);
assert_eq!(ImageType::from_extension("wbmp"), ImageType::Wbmp);
assert_eq!(ImageType::from_extension("unknown"), ImageType::Unknown);
}
#[test]
fn test_image_type_default() {
assert_eq!(ImageType::default(), ImageType::Unknown);
}
#[test]
fn test_image_type_from_image_format() {
use image::ImageFormat;
assert_eq!(
ImageType::from_image_format(ImageFormat::Gif),
ImageType::Gif
);
assert_eq!(
ImageType::from_image_format(ImageFormat::Jpeg),
ImageType::Jpeg
);
assert_eq!(
ImageType::from_image_format(ImageFormat::Png),
ImageType::Png
);
assert_eq!(
ImageType::from_image_format(ImageFormat::WebP),
ImageType::Unknown
);
}
#[test]
fn test_image_type_to_image_format() {
assert_eq!(
ImageType::Gif.to_image_format(),
Some(image::ImageFormat::Gif)
);
assert_eq!(
ImageType::Jpeg.to_image_format(),
Some(image::ImageFormat::Jpeg)
);
assert_eq!(
ImageType::Png.to_image_format(),
Some(image::ImageFormat::Png)
);
assert_eq!(ImageType::Wbmp.to_image_format(), None);
assert_eq!(ImageType::Unknown.to_image_format(), None);
}
#[test]
fn test_image_type_from_extension_case_insensitive() {
assert_eq!(ImageType::from_extension("GIF"), ImageType::Gif);
assert_eq!(ImageType::from_extension("PNG"), ImageType::Png);
assert_eq!(ImageType::from_extension("JPG"), ImageType::Jpeg);
}
#[test]
fn test_color_rgb() {
let c = Color::rgb(255, 128, 0);
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
assert_eq!(c.a, 255); }
#[test]
fn test_color_rgba() {
let c = Color::rgba(255, 128, 0, 128);
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
assert_eq!(c.a, 128);
}
#[test]
fn test_color_from_hex_rrggbb() {
let c = Color::from_hex("#ff8000").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
assert_eq!(c.a, 255);
}
#[test]
fn test_color_from_hex_rgb() {
let c = Color::from_hex("#f80").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 136);
assert_eq!(c.b, 0);
assert_eq!(c.a, 255);
}
#[test]
fn test_color_from_hex_rrggbbaa() {
let c = Color::from_hex("#ff800080").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
assert_eq!(c.a, 128);
}
#[test]
fn test_color_from_hex_no_hash() {
let c = Color::from_hex("ff8000").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
}
#[test]
fn test_color_from_hex_invalid() {
assert!(Color::from_hex("#xyz").is_err());
assert!(Color::from_hex("#1").is_err());
assert!(Color::from_hex("12345").is_err());
}
#[test]
fn test_color_to_rgba() {
let c = Color::rgb(1, 2, 3);
assert_eq!(c.to_rgba(), Rgba([1, 2, 3, 255]));
}
#[test]
fn test_color_default() {
let c = Color::default();
assert_eq!(c, Color::rgb(0, 0, 0));
}
#[test]
fn test_position_parse() {
assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
assert_eq!(Position::parse("TOP-RIGHT").unwrap(), Position::TopRight);
assert_eq!(Position::parse("center").unwrap(), Position::Center);
assert_eq!(
Position::parse("bottom-right").unwrap(),
Position::BottomRight
);
}
#[test]
fn test_position_parse_invalid() {
assert!(Position::parse("invalid").is_err());
assert!(Position::parse("").is_err());
}
#[test]
fn test_position_as_str() {
assert_eq!(Position::TopLeft.as_str(), "top-left");
assert_eq!(Position::Center.as_str(), "center");
assert_eq!(Position::BottomRight.as_str(), "bottom-right");
}
#[test]
fn test_position_get_xy_top_left() {
let (x, y) = Position::TopLeft.get_xy(100, 100, 20, 20);
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn test_position_get_xy_center() {
let (x, y) = Position::Center.get_xy(100, 100, 20, 20);
assert_eq!(x, 40);
assert_eq!(y, 40);
}
#[test]
fn test_position_get_xy_bottom_right() {
let (x, y) = Position::BottomRight.get_xy(100, 100, 20, 20);
assert_eq!(x, 80);
assert_eq!(y, 80);
}
#[test]
fn test_position_get_xy_top_center() {
let (x, y) = Position::TopCenter.get_xy(100, 100, 20, 20);
assert_eq!(x, 40); assert_eq!(y, 0);
}
fn create_test_png(path: &Path, w: u32, h: u32, color: Rgba<u8>) {
let img: RgbaImage = ImageBuffer::from_pixel(w, h, color);
img.save(path).unwrap();
}
#[test]
fn test_image_create_blank() {
let img = Image::create_blank(100, 50);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 50);
assert_eq!(img.image_type(), ImageType::Unknown);
assert!(img.file_path().is_none());
}
#[test]
fn test_image_open_png() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let path = tmp.path();
create_test_png(path, 80, 60, Rgba([255, 0, 0, 255]));
let img = Image::open(path).unwrap();
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
assert_eq!(img.image_type(), ImageType::Png);
assert!(img.file_path().is_some());
}
#[test]
fn test_image_from_dynamic() {
let buf: RgbaImage = ImageBuffer::from_pixel(50, 50, Rgba([0, 255, 0, 255]));
let dyn_img = DynamicImage::ImageRgba8(buf);
let img = Image::from_dynamic(dyn_img, ImageType::Png);
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
assert_eq!(img.image_type(), ImageType::Png);
}
#[test]
fn test_image_to_rgba8() {
let img = Image::create_blank(30, 30);
let rgba = img.to_rgba8();
assert_eq!(rgba.dimensions(), (30, 30));
}
#[test]
fn test_editor_new() {
let _editor = Editor::new();
let _editor2 = Editor;
}
#[test]
fn test_editor_open() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 0, 255, 255]));
let editor = Editor::new();
let img = editor.open(tmp.path()).unwrap();
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 100);
}
#[test]
fn test_editor_save_png() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 50, 50, Rgba([0, 255, 0, 255]));
let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_out.path(), None, None, false, 0o755)
.unwrap();
let reopened = image::open(tmp_out.path()).unwrap();
assert_eq!(reopened.width(), 50);
assert_eq!(reopened.height(), 50);
}
#[test]
fn test_editor_save_jpeg_with_quality() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 50, 50, Rgba([128, 64, 32, 255]));
let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_out.path(), None, Some(90), false, 0o755)
.unwrap();
let reopened = image::open(tmp_out.path()).unwrap();
assert_eq!(reopened.width(), 50);
assert_eq!(reopened.height(), 50);
}
#[test]
fn test_editor_resize_exact() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([255, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_exact(&mut img, 50, 80);
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 80);
}
#[test]
fn test_editor_resize_fit() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 200, 100, Rgba([0, 255, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_fit(&mut img, 100, 100);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 50);
}
#[test]
fn test_editor_resize_fill() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 200, 100, Rgba([0, 0, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_fill(&mut img, 100, 100);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 100);
}
#[test]
fn test_editor_resize_exact_width() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 200, 100, Rgba([255, 255, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_exact_width(&mut img, 50);
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 25);
}
#[test]
fn test_editor_resize_exact_height() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_exact_height(&mut img, 50);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 50);
}
#[test]
fn test_editor_crop_center() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor
.crop(&mut img, 50, 50, Position::Center, 0, 0)
.unwrap();
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_editor_crop_too_large() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 50, 50, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
assert!(editor
.crop(&mut img, 100, 100, Position::TopLeft, 0, 0)
.is_err());
}
#[test]
fn test_editor_flip_horizontal() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([255, 128, 64, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.flip(&mut img, FlipMode::Horizontal);
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_flip_vertical() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([255, 128, 64, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.flip(&mut img, FlipMode::Vertical);
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_rotate_90() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([64, 255, 128, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, 90.0).unwrap();
assert_eq!(img.width(), 60); assert_eq!(img.height(), 80);
}
#[test]
fn test_editor_rotate_180() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([64, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, 180.0).unwrap();
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_rotate_invalid_angle() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
assert!(editor.rotate(&mut img, 45.0).is_err());
}
#[test]
fn test_editor_blend_normal() {
let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
let editor = Editor::new();
let mut img1 = editor.open(tmp1.path()).unwrap();
let img2 = editor.open(tmp2.path()).unwrap();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
assert_eq!(img1.width(), 100);
assert_eq!(img1.height(), 100);
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert_eq!(pixel[0], 255);
assert_eq!(pixel[1], 255);
assert_eq!(pixel[2], 255);
}
#[test]
fn test_editor_blend_with_offset() {
let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
let editor = Editor::new();
let mut img1 = editor.open(tmp1.path()).unwrap();
let img2 = editor.open(tmp2.path()).unwrap();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
1.0,
Position::TopLeft,
30,
30,
)
.unwrap();
let rgba = img1.to_rgba8();
let p1 = rgba.get_pixel(0, 0);
assert_eq!(p1[0], 0);
let p2 = rgba.get_pixel(50, 50);
assert_eq!(p2[0], 255);
let p3 = rgba.get_pixel(90, 90);
assert_eq!(p3[0], 0);
}
#[test]
fn test_editor_blend_opacity_half() {
let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp1.path(), 50, 50, Rgba([0, 0, 0, 255]));
create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
let editor = Editor::new();
let mut img1 = editor.open(tmp1.path()).unwrap();
let img2 = editor.open(tmp2.path()).unwrap();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
0.5,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert!(
(120..=136).contains(&pixel[0]),
"expected ~128, got {}",
pixel[0]
);
}
#[test]
fn test_blend_type_parse() {
assert_eq!(BlendType::parse("normal").unwrap(), BlendType::Normal);
assert_eq!(BlendType::parse("MULTIPLY").unwrap(), BlendType::Multiply);
assert_eq!(BlendType::parse("overlay").unwrap(), BlendType::Overlay);
assert_eq!(BlendType::parse("screen").unwrap(), BlendType::Screen);
assert!(BlendType::parse("invalid").is_err());
}
#[test]
fn test_flip_mode_parse() {
assert_eq!(FlipMode::parse("h").unwrap(), FlipMode::Horizontal);
assert_eq!(FlipMode::parse("V").unwrap(), FlipMode::Vertical);
assert!(FlipMode::parse("x").is_err());
}
#[test]
fn test_editor_fill() {
let img = Image::create_blank(50, 50);
let editor = Editor::new();
let mut img = img;
editor.fill(&mut img, Color::rgb(255, 0, 0));
let rgba = img.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert_eq!(pixel[0], 255);
assert_eq!(pixel[1], 0);
assert_eq!(pixel[2], 0);
}
#[test]
fn test_r5_24_image_type_constants() {
assert_eq!(ImageType::Unknown.as_str(), ""); assert_eq!(ImageType::Gif.as_str(), "GIF"); assert_eq!(ImageType::Jpeg.as_str(), "JPEG"); assert_eq!(ImageType::Png.as_str(), "PNG"); assert_eq!(ImageType::Wbmp.as_str(), "WBMP"); }
#[test]
fn test_r5_25_color_hex_parsing() {
let c1 = Color::from_hex("#333333").unwrap();
assert_eq!((c1.r, c1.g, c1.b), (0x33, 0x33, 0x33));
let c2 = Color::from_hex("#ff4444").unwrap();
assert_eq!((c2.r, c2.g, c2.b), (0xff, 0x44, 0x44));
let c3 = Color::from_hex("#f00").unwrap();
assert_eq!((c3.r, c3.g, c3.b), (0xff, 0x00, 0x00));
}
#[test]
fn test_r5_26_position_get_xy_all_nine() {
let w1 = 100u32;
let h1 = 100u32;
let w2 = 20u32;
let h2 = 20u32;
let cases = [
(Position::TopLeft, 0, 0),
(Position::TopCenter, 40, 0),
(Position::TopRight, 80, 0),
(Position::CenterLeft, 0, 40),
(Position::Center, 40, 40),
(Position::CenterRight, 80, 40),
(Position::BottomLeft, 0, 80),
(Position::BottomCenter, 40, 80),
(Position::BottomRight, 80, 80),
];
for (pos, ex, ey) in cases {
let (x, y) = pos.get_xy(w1, h1, w2, h2);
assert_eq!(x, ex, "Position {:?} x mismatch", pos);
assert_eq!(y, ey, "Position {:?} y mismatch", pos);
}
}
#[test]
fn test_r5_27_image_open_detects_type() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([255, 255, 255, 255]));
let img = Image::open(tmp.path()).unwrap();
assert_eq!(img.image_type(), ImageType::Png);
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_r5_28_resize_exact_forces_dimensions() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.resize_exact(&mut img, 50, 50);
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_r5_29_blend_normal_with_offset_and_opacity() {
let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp1.path(), 200, 200, Rgba([0, 0, 0, 255]));
create_test_png(tmp2.path(), 100, 100, Rgba([255, 255, 255, 255]));
let editor = Editor::new();
let mut img1 = editor.open(tmp1.path()).unwrap();
let img2 = editor.open(tmp2.path()).unwrap();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
1.0,
Position::TopLeft,
30,
30,
)
.unwrap();
let rgba = img1.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0)[0], 0);
assert_eq!(rgba.get_pixel(50, 50)[0], 255);
assert_eq!(rgba.get_pixel(129, 129)[0], 255);
assert_eq!(rgba.get_pixel(130, 130)[0], 0);
assert_eq!(rgba.get_pixel(150, 150)[0], 0);
}
#[test]
fn test_r5_30_text_y_baseline_offset() {
let mut img = Image::create_blank(200, 100);
let editor = Editor::new();
let result = editor.text(&mut img, "test", 30, 10, 50, Color::rgb(0, 0, 0), None);
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_r5_31_save_infers_type_from_extension() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 128, 64, 255]));
let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_png.path(), None, None, false, 0o755)
.unwrap();
assert!(tmp_png.path().exists());
let tmp_jpg = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
editor
.save(&img, tmp_jpg.path(), None, None, false, 0o755)
.unwrap();
assert!(tmp_jpg.path().exists());
}
#[test]
fn test_r5_32_wrap_text_signature_alignment() {
let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, Some(2));
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_r5_32_wrap_text_with_font_logic() {
let font_path = Path::new(
"e:/vue/test/鲜视达/server/vendor/kosinix/grafika/src/Grafika/fonts/st-heiti-light.ttc",
);
if !font_path.exists() {
eprintln!(
"Skipping test_r5_32_wrap_text_with_font_logic: font not found at {font_path:?}"
);
return;
}
let data = std::fs::read(font_path).unwrap();
let font = FontVec::try_from_vec(data).unwrap();
let result = wrap_text_with_font(&font, 30, "hello", 680, Some(2));
assert_eq!(result, "hello");
let long_text = "这是一个非常长的商品名称用于测试自动换行功能应该被截断并添加省略号";
let result = wrap_text_with_font(&font, 30, long_text, 100, Some(2));
assert!(
result.ends_with("..."),
"result should end with ..., got: {result}"
);
assert!(
result.contains('\n'),
"result should contain newline, got: {result}"
);
}
#[test]
fn test_measure_text_nonexistent_font() {
let result = measure_text(Path::new("/nonexistent.ttf"), 30, "hello");
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_text_metrics_debug() {
let m = TextMetrics {
width: 100,
height: 30,
ascent: 25,
descent: -5,
};
assert_eq!(m.width, 100);
assert_eq!(m.height, 30);
}
#[test]
fn test_color_from_hex_rgba_short() {
let c = Color::from_hex("#f80f").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 136);
assert_eq!(c.b, 0);
assert_eq!(c.a, 255);
}
#[test]
fn test_color_from_hex_with_whitespace() {
let c = Color::from_hex(" #ff8000 ").unwrap();
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
}
#[test]
fn test_color_from_hex_empty() {
assert!(Color::from_hex("#").is_err());
assert!(Color::from_hex("").is_err());
}
#[test]
fn test_color_from_hex_invalid_chars() {
assert!(Color::from_hex("#gggggg").is_err());
assert!(Color::from_hex("#zz").is_err());
}
#[test]
fn test_color_copy_and_eq() {
let c1 = Color::rgb(1, 2, 3);
let c2 = c1;
assert_eq!(c1, c2);
}
#[test]
fn test_image_type_from_image_format_other() {
use image::ImageFormat;
assert_eq!(
ImageType::from_image_format(ImageFormat::Bmp),
ImageType::Unknown
);
assert_eq!(
ImageType::from_image_format(ImageFormat::Tiff),
ImageType::Unknown
);
}
#[test]
fn test_image_type_from_extension_empty() {
assert_eq!(ImageType::from_extension(""), ImageType::Unknown);
}
#[test]
fn test_position_parse_all_nine() {
assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
assert_eq!(Position::parse("top-right").unwrap(), Position::TopRight);
assert_eq!(
Position::parse("center-left").unwrap(),
Position::CenterLeft
);
assert_eq!(Position::parse("center").unwrap(), Position::Center);
assert_eq!(
Position::parse("center-right").unwrap(),
Position::CenterRight
);
assert_eq!(
Position::parse("bottom-left").unwrap(),
Position::BottomLeft
);
assert_eq!(
Position::parse("bottom-center").unwrap(),
Position::BottomCenter
);
assert_eq!(
Position::parse("bottom-right").unwrap(),
Position::BottomRight
);
}
#[test]
fn test_position_as_str_all_nine() {
assert_eq!(Position::TopLeft.as_str(), "top-left");
assert_eq!(Position::TopCenter.as_str(), "top-center");
assert_eq!(Position::TopRight.as_str(), "top-right");
assert_eq!(Position::CenterLeft.as_str(), "center-left");
assert_eq!(Position::Center.as_str(), "center");
assert_eq!(Position::CenterRight.as_str(), "center-right");
assert_eq!(Position::BottomLeft.as_str(), "bottom-left");
assert_eq!(Position::BottomCenter.as_str(), "bottom-center");
assert_eq!(Position::BottomRight.as_str(), "bottom-right");
}
#[test]
fn test_position_get_xy_unequal_dimensions() {
let (x, y) = Position::Center.get_xy(200, 100, 40, 30);
assert_eq!(x, 80);
assert_eq!(y, 35);
}
#[test]
fn test_image_from_rgba8() {
let buf: RgbaImage = ImageBuffer::from_pixel(40, 30, Rgba([10, 20, 30, 255]));
let img = Image::from_rgba8(buf, ImageType::Png);
assert_eq!(img.width(), 40);
assert_eq!(img.height(), 30);
assert_eq!(img.image_type(), ImageType::Png);
assert!(img.file_path().is_none());
}
#[test]
fn test_image_as_dynamic() {
let img = Image::create_blank(50, 50);
let dyn_ref = img.as_dynamic();
assert_eq!(dyn_ref.width(), 50);
assert_eq!(dyn_ref.height(), 50);
}
#[test]
fn test_image_as_dynamic_mut() {
let mut img = Image::create_blank(50, 50);
let dyn_mut = img.as_dynamic_mut();
assert_eq!(dyn_mut.width(), 50);
assert_eq!(dyn_mut.height(), 50);
}
#[test]
fn test_image_open_nonexistent() {
let result = Image::open(Path::new("/nonexistent/file.png"));
assert!(result.is_err());
}
#[test]
fn test_image_open_unknown_extension_fails() {
let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_png.path(), 60, 40, Rgba([255, 0, 0, 255]));
let bin_path = tmp_png.path().with_extension("bin");
std::fs::rename(tmp_png.path(), &bin_path).unwrap();
let result = Image::open(&bin_path);
assert!(result.is_err());
}
#[test]
fn test_editor_default() {
let _editor = Editor;
}
#[test]
fn test_editor_rotate_0() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, 0.0).unwrap();
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_rotate_270() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, 270.0).unwrap();
assert_eq!(img.width(), 60);
assert_eq!(img.height(), 80);
}
#[test]
fn test_editor_rotate_negative_90() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, -90.0).unwrap();
assert_eq!(img.width(), 60);
assert_eq!(img.height(), 80);
}
#[test]
fn test_editor_rotate_negative_180() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, -180.0).unwrap();
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_rotate_360_normalized_to_0() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor.rotate(&mut img, 360.0).unwrap();
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 60);
}
#[test]
fn test_editor_crop_with_positive_offset() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor
.crop(&mut img, 50, 50, Position::Center, 10, 10)
.unwrap();
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_editor_crop_with_negative_offset_clamped() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor
.crop(&mut img, 50, 50, Position::TopLeft, -100, -100)
.unwrap();
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_editor_crop_top_left() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor
.crop(&mut img, 30, 30, Position::TopLeft, 0, 0)
.unwrap();
assert_eq!(img.width(), 30);
assert_eq!(img.height(), 30);
}
#[test]
fn test_editor_crop_bottom_right() {
let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
let editor = Editor::new();
let mut img = editor.open(tmp.path()).unwrap();
editor
.crop(&mut img, 30, 30, Position::BottomRight, 0, 0)
.unwrap();
assert_eq!(img.width(), 30);
assert_eq!(img.height(), 30);
}
#[test]
fn test_editor_blend_multiply() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Multiply,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert!(
(60..=68).contains(&pixel[0]),
"expected ~64, got {}",
pixel[0]
);
}
#[test]
fn test_editor_blend_overlay_dark() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([64, 64, 64, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Overlay,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert!(
(60..=68).contains(&pixel[0]),
"expected ~64, got {}",
pixel[0]
);
}
#[test]
fn test_editor_blend_overlay_light() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Overlay,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert!(
(225..=235).contains(&pixel[0]),
"expected ~231, got {}",
pixel[0]
);
}
#[test]
fn test_editor_blend_screen() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Screen,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert_eq!(pixel[0], 0);
}
#[test]
fn test_editor_blend_with_negative_offset() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 255, 255, 255]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
1.0,
Position::TopLeft,
-25,
-25,
)
.unwrap();
let rgba = img1.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0)[0], 255);
assert_eq!(rgba.get_pixel(24, 24)[0], 255);
assert_eq!(rgba.get_pixel(25, 25)[0], 0);
}
#[test]
fn test_editor_blend_transparent_overlay() {
let base = ImageBuffer::from_pixel(50, 50, Rgba([100, 100, 100, 255]));
let mut img1 = Image::from_rgba8(base, ImageType::Png);
let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 0, 0, 0]));
let img2 = Image::from_rgba8(overlay, ImageType::Png);
let editor = Editor::new();
editor
.blend(
&mut img1,
&img2,
BlendType::Normal,
1.0,
Position::TopLeft,
0,
0,
)
.unwrap();
let rgba = img1.to_rgba8();
let pixel = rgba.get_pixel(0, 0);
assert_eq!(pixel[0], 100);
}
#[test]
fn test_editor_save_gif() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
let tmp_out = tempfile::Builder::new().suffix(".gif").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_out.path(), None, None, false, 0o755)
.unwrap();
assert!(tmp_out.path().exists());
let reopened = image::open(tmp_out.path()).unwrap();
assert_eq!(reopened.width(), 30);
}
#[test]
fn test_editor_save_wbmp_error() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
let tmp_out = tempfile::Builder::new().suffix(".wbmp").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
let result = editor.save(&img, tmp_out.path(), None, None, false, 0o755);
assert!(result.is_err());
}
#[test]
fn test_editor_save_unknown_type_error() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
let tmp_out = tempfile::Builder::new().suffix(".bin").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
let result = editor.save(&img, tmp_out.path(), None, None, false, 0o755);
assert!(result.is_err());
}
#[test]
fn test_editor_save_with_explicit_png_type() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(
&img,
tmp_out.path(),
Some(ImageType::Png),
None,
false,
0o755,
)
.unwrap();
assert!(tmp_out.path().exists());
}
#[test]
fn test_editor_save_jpeg_explicit_type() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(
&img,
tmp_out.path(),
Some(ImageType::Jpeg),
Some(80),
false,
0o755,
)
.unwrap();
assert!(tmp_out.path().exists());
}
#[test]
fn test_editor_save_quality_clamping_high() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_out.path(), None, Some(200), false, 0o755)
.unwrap();
assert!(tmp_out.path().exists());
}
#[test]
fn test_editor_save_quality_clamping_zero() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, tmp_out.path(), None, Some(0), false, 0o755)
.unwrap();
assert!(tmp_out.path().exists());
}
#[test]
fn test_editor_save_creates_parent_dir() {
let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
let tmp_dir = tempfile::tempdir().unwrap();
let output_path = tmp_dir.path().join("subdir").join("output.png");
assert!(!output_path.parent().unwrap().exists());
let editor = Editor::new();
let img = editor.open(tmp_in.path()).unwrap();
editor
.save(&img, &output_path, None, None, false, 0o755)
.unwrap();
assert!(output_path.exists());
}
#[test]
fn test_load_font_invalid_data() {
let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
std::fs::write(tmp.path(), b"this is not a font").unwrap();
let mut img = Image::create_blank(100, 50);
let editor = Editor::new();
let result = editor.text(
&mut img,
"test",
20,
10,
30,
Color::rgb(0, 0, 0),
Some(tmp.path()),
);
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_measure_text_invalid_font() {
let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
std::fs::write(tmp.path(), b"invalid font data").unwrap();
let result = measure_text(tmp.path(), 30, "hello");
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_wrap_text_nonexistent_font() {
let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, None);
assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
}
#[test]
fn test_editor_text_with_font() {
let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
if !font_path.exists() {
eprintln!("Skipping test_editor_text_with_font: font not found");
return;
}
let mut img = Image::create_blank(200, 100);
let editor = Editor::new();
let result = editor.text(
&mut img,
"hello",
30,
10,
50,
Color::rgb(255, 0, 0),
Some(font_path),
);
assert!(result.is_ok());
assert_eq!(img.width(), 200);
assert_eq!(img.height(), 100);
}
#[test]
fn test_measure_text_with_font() {
let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
if !font_path.exists() {
eprintln!("Skipping test_measure_text_with_font: font not found");
return;
}
let result = measure_text(font_path, 30, "hello").unwrap();
assert!(result.width > 0);
assert!(result.height > 0);
}
#[test]
fn test_wrap_text_with_font_no_max_line() {
let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
if !font_path.exists() {
eprintln!("Skipping test_wrap_text_with_font_no_max_line: font not found");
return;
}
let data = std::fs::read(font_path).unwrap();
let font = FontVec::try_from_vec(data).unwrap();
let long_text = "this is a very long text that should wrap";
let result = wrap_text_with_font(&font, 30, long_text, 100, None);
assert!(result.contains('\n'), "should contain newline: {result}");
assert!(
!result.ends_with("..."),
"should not end with ... when no max_line"
);
}
}