Skip to main content

sz_rust_infra_facade/upload/
image.rs

1//! 图像处理模块 — 对齐 PHP `Grafika\Gd\Editor` + `Grafika\Gd\Image` + `Grafika\Color` + `Grafika\Position`
2//!
3//! 本模块实现图像处理功能,对齐 PHP `kosinix/grafika` 库
4//! 的 GD 后端 API(项目中实际使用的图像处理库)。
5//!
6//! ## PHP 对齐说明
7//!
8//! 项目 `composer.json` 未引入 `topthink/image`,实际使用 `kosinix/grafika` 库。
9//! 因此本模块以 Grafika 为对齐基准,而非 think\Image。
10//!
11//! ## PHP 对齐
12//!
13//! ### 核心类映射
14//!
15//! | PHP 类 | Rust 结构 | 说明 |
16//! |---------|-----------|------|
17//! | `Grafika\Gd\Image` | [`Image`] | 图像类(持有状态) |
18//! | `Grafika\Gd\Editor` | [`Editor`] | 编辑器类(所有操作入口) |
19//! | `Grafika\Color` | [`Color`] | 颜色类(CSS hex 解析) |
20//! | `Grafika\Position` | [`Position`] | 9 种位置枚举 |
21//! | `Grafika\ImageType` | [`ImageType`] | 图像类型枚举 |
22//! | `imagettfbbox` | [`measure_text`] | TTF 文本边界测量 |
23//! | `imagettftext` | [`Editor::text`] | TTF 文本绘制 |
24//! | `imagecopyresampled` | `image::imageops::resize` | 缩放重采样 |
25//! | `imagecopy` | `image::imageops::overlay` | 图层合成 |
26//!
27//! ### 核心方法映射
28//!
29//! | PHP 方法 | Rust 方法 | 说明 |
30//! |---------|-----------|------|
31//! | `Image::createFromFile($file)` | [`Image::open`] | 文件 → Image |
32//! | `Image::createBlank($w, $h)` | [`Image::create_blank`] | 空白画布 |
33//! | `Image::getWidth()` | [`Image::width`] | 宽 |
34//! | `Image::getHeight()` | [`Image::height`] | 高 |
35//! | `Image::getType()` | [`Image::image_type`] | 类型 |
36//! | `Editor::open(&$img, $file)` | [`Editor::open`] | 打开文件 |
37//! | `Editor::resizeExact(&$img, $w, $h)` | [`Editor::resize_exact`] | 强制尺寸 |
38//! | `Editor::resizeFit(&$img, $w, $h)` | [`Editor::resize_fit`] | 等比缩放适配 |
39//! | `Editor::resizeFill(&$img, $w, $h)` | [`Editor::resize_fill`] | 填充+裁剪 |
40//! | `Editor::resizeExactWidth(&$img, $w)` | [`Editor::resize_exact_width`] | 等比按宽 |
41//! | `Editor::resizeExactHeight(&$img, $h)` | [`Editor::resize_exact_height`] | 等比按高 |
42//! | `Editor::crop(&$img, $w, $h, $pos, $ox, $oy)` | [`Editor::crop`] | 裁剪 |
43//! | `Editor::blend(&$img1, $img2, $type, $opacity, $pos, $ox, $oy)` | [`Editor::blend`] | 合成 |
44//! | `Editor::text(&$img, $text, $size, $x, $y, $color, $font, $angle)` | [`Editor::text`] | 文本绘制 |
45//! | `Editor::rotate(&$img, $angle, $color)` | [`Editor::rotate`] | 旋转 |
46//! | `Editor::flip(&$img, $mode)` | [`Editor::flip`] | 翻转('h'/'v') |
47//! | `Editor::fill(&$img, $color, $x, $y)` | [`Editor::fill`] | 填充 |
48//! | `Editor::save($img, $file, $type, $quality, $interlace, $perm)` | [`Editor::save`] | 保存 |
49//!
50//! ## PHP 行为对齐(R5 硬约束)
51//!
52//! - **R5-24**:`ImageType` 5 种类型(UNKNOWN/GIF/JPEG/PNG/WBMP)对齐 `Grafika\ImageType`
53//! - **R5-25**:`Color` hex 解析(`#rgb`/`#rrggbb`/`#rgba`/`#rrggbbaa`)对齐 `Grafika\Color`
54//! - **R5-26**:`Position` 9 种位置 + `get_xy` 对齐 `Grafika\Position::getXY`
55//! - **R5-27**:`Image::open` 按 `getimagesize` 探测类型后分派(GIF/JPEG/PNG/WBMP)对齐 `Image::createFromFile`
56//! - **R5-28**:`Editor::resize_exact` 强制目标尺寸(忽略宽高比)对齐 `Editor::resizeExact`
57//! - **R5-29**:`Editor::blend` normal 模式 + opacity + offset 对齐 `Editor::blend`
58//! - **R5-30**:`Editor::text` y 坐标基线偏移(GD `imagettftext` y 是基线,Grafika 内部 `y += size`)
59//!   — Rust 端 `imageproc::drawing::draw_text_mut` y 是顶部,所以 Rust y = PHP y - size
60//! - **R5-31**:`Editor::save` 按扩展名猜类型 + JPEG 默认 quality=75 对齐 `Editor::save`
61//! - **R5-32**:`wrap_text` 对齐业务侧 `wrapText`(`imagettfbbox` 测量 + max_line 截断 + 省略号)
62//!
63//! ## PHP 源码参考
64//!
65//! - `e:\vue\test\鲜视达\server\vendor\kosinix\grafika\src\Grafika\Gd\Image.php`(457 行)
66//! - `e:\vue\test\鲜视达\server\vendor\kosinix\grafika\src\Grafika\Gd\Editor.php`(~830 行)
67//! - `e:\vue\test\鲜视达\server\vendor\kosinix\grafika\src\Grafika\Color.php`
68//! - `e:\vue\test\鲜视达\server\vendor\kosinix\grafika\src\Grafika\Position.php`
69//! - `e:\vue\test\鲜视达\server\vendor\kosinix\grafika\src\Grafika\ImageType.php`
70//! - `e:\vue\test\鲜视达\server\app\common\service\qrcode\ProductService.php`(wrapText 业务侧实现)
71
72use std::path::{Path, PathBuf};
73
74use ab_glyph::{Font, FontVec, Glyph, PxScale, ScaleFont};
75use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage};
76use thiserror::Error;
77
78// ============================================================================
79// 错误类型
80// ============================================================================
81
82/// 图像处理错误 — 对齐 PHP Grafika 异常
83#[derive(Debug, Error)]
84pub enum ImageError {
85    /// 图像打开失败
86    #[error("Failed to open image: {0}")]
87    OpenFailed(String),
88
89    /// 图像保存失败
90    #[error("Failed to save image: {0}")]
91    SaveFailed(String),
92
93    /// 不支持的图像类型
94    #[error("Unsupported image type: {0}")]
95    UnsupportedType(String),
96
97    /// 颜色解析失败
98    #[error("Invalid color: {0}")]
99    InvalidColor(String),
100
101    /// 字体加载失败
102    #[error("Failed to load font: {0}")]
103    FontLoadFailed(String),
104
105    /// IO 错误
106    #[error(transparent)]
107    Io(#[from] std::io::Error),
108
109    /// 图像解码错误
110    #[error(transparent)]
111    Decode(#[from] image::ImageError),
112
113    /// 参数无效
114    #[error("{0}")]
115    InvalidArgument(String),
116}
117
118// ============================================================================
119// ImageType 枚举 — 对齐 Grafika\ImageType
120// ============================================================================
121
122/// 图像类型 — 对齐 PHP `Grafika\ImageType`
123///
124/// PHP 源码(`ImageType.php`):
125/// ```php
126/// class ImageType {
127///     const UNKNOWN = '';
128///     const GIF     = 'GIF';
129///     const JPEG    = 'JPEG';
130///     const PNG     = 'PNG';
131///     const WBMP    = 'WBMP';
132/// }
133/// ```
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum ImageType {
136    /// 未知类型(对齐 `UNKNOWN = ''`)
137    #[default]
138    Unknown,
139    /// GIF(对齐 `GIF = 'GIF'`)
140    Gif,
141    /// JPEG(对齐 `JPEG = 'JPEG'`)
142    Jpeg,
143    /// PNG(对齐 `PNG = 'PNG'`)
144    Png,
145    /// WBMP(对齐 `WBMP = 'WBMP'`)
146    Wbmp,
147}
148
149impl ImageType {
150    /// 对齐 PHP `ImageType` 常量字符串值
151    pub fn as_str(self) -> &'static str {
152        match self {
153            ImageType::Unknown => "",
154            ImageType::Gif => "GIF",
155            ImageType::Jpeg => "JPEG",
156            ImageType::Png => "PNG",
157            ImageType::Wbmp => "WBMP",
158        }
159    }
160
161    /// 从扩展名推断类型(对齐 Grafika `_getImageTypeFromFileName`)
162    pub fn from_extension(ext: &str) -> Self {
163        match ext.to_lowercase().as_str() {
164            "gif" => ImageType::Gif,
165            "jpg" | "jpeg" => ImageType::Jpeg,
166            "png" => ImageType::Png,
167            "wbmp" => ImageType::Wbmp,
168            _ => ImageType::Unknown,
169        }
170    }
171
172    /// 从 image crate 的 ImageFormat 转换(对齐 PHP `getimagesize` 探测结果)
173    pub fn from_image_format(format: image::ImageFormat) -> Self {
174        match format {
175            image::ImageFormat::Gif => ImageType::Gif,
176            image::ImageFormat::Jpeg => ImageType::Jpeg,
177            image::ImageFormat::Png => ImageType::Png,
178            image::ImageFormat::WebP => ImageType::Unknown, // PHP Grafika 不支持 WebP
179            _ => ImageType::Unknown,
180        }
181    }
182
183    /// 转换为 image crate 的 ImageFormat
184    pub fn to_image_format(self) -> Option<image::ImageFormat> {
185        match self {
186            ImageType::Gif => Some(image::ImageFormat::Gif),
187            ImageType::Jpeg => Some(image::ImageFormat::Jpeg),
188            ImageType::Png => Some(image::ImageFormat::Png),
189            ImageType::Wbmp => None, // image crate 不支持 WBMP
190            ImageType::Unknown => None,
191        }
192    }
193}
194
195// ============================================================================
196// Color 结构 — 对齐 Grafika\Color
197// ============================================================================
198
199/// 颜色 — 对齐 PHP `Grafika\Color`
200///
201/// PHP 构造方法支持:
202/// - `new Color('#rgb')`
203/// - `new Color('#rrggbb')`
204/// - `new Color('#rgba')`(含 alpha)
205/// - `new Color('#rrggbbaa')`(含 alpha)
206/// - `new Color([r, g, b])`
207/// - `new Color([r, g, b, a])`(a 是 0-1 浮点)
208/// - `new Color(r, g, b)`(3 个 int 参数)
209/// - `new Color(r, g, b, a)`(4 个参数,a 是 0-1 浮点)
210///
211/// Rust 端简化为 hex 字符串 + RGB 元组两种构造方式。
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct Color {
214    /// 红色(0-255)
215    pub r: u8,
216    /// 绿色(0-255)
217    pub g: u8,
218    /// 蓝色(0-255)
219    pub b: u8,
220    /// Alpha(0-255,0=透明,255=不透明;对齐 Rust `Rgba<u8>` 语义)
221    ///
222    /// 注意:PHP GD alpha 是 0(不透明)~127(透明),Grafika 内部用 `gdAlpha()` 转换。
223    /// Rust 端直接用 0-255(0=透明,255=不透明),与 `Rgba<u8>` 一致。
224    pub a: u8,
225}
226
227impl Color {
228    /// 创建不透明颜色 — 对齐 `new Color(r, g, b)`
229    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
230        Self { r, g, b, a: 255 }
231    }
232
233    /// 创建带 alpha 的颜色 — 对齐 `new Color(r, g, b, a)`
234    ///
235    /// PHP 的 alpha 是 0-1 浮点(0=透明,1=不透明),Rust 端用 0-255。
236    pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
237        Self { r, g, b, a }
238    }
239
240    /// 从 hex 字符串解析 — 对齐 `new Color('#xxxxxx')`
241    ///
242    /// 支持格式:
243    /// - `#rgb` → `#rrggbb`
244    /// - `#rgba` → `#rrggbbaa`
245    /// - `#rrggbb`
246    /// - `#rrggbbaa`
247    /// - 不带 `#` 前缀也支持
248    pub fn from_hex(hex: &str) -> Result<Self, ImageError> {
249        let hex = hex.trim().trim_start_matches('#');
250        let parse = |s: &str| {
251            u8::from_str_radix(s, 16).map_err(|_| ImageError::InvalidColor(hex.to_string()))
252        };
253        let (r, g, b, a) = match hex.len() {
254            3 => {
255                // #rgb → #rrggbb
256                let r = parse(&format!("{}{}", &hex[0..1], &hex[0..1]))?;
257                let g = parse(&format!("{}{}", &hex[1..2], &hex[1..2]))?;
258                let b = parse(&format!("{}{}", &hex[2..3], &hex[2..3]))?;
259                (r, g, b, 255u8)
260            }
261            4 => {
262                // #rgba → #rrggbbaa
263                let r = parse(&format!("{}{}", &hex[0..1], &hex[0..1]))?;
264                let g = parse(&format!("{}{}", &hex[1..2], &hex[1..2]))?;
265                let b = parse(&format!("{}{}", &hex[2..3], &hex[2..3]))?;
266                let a = parse(&format!("{}{}", &hex[3..4], &hex[3..4]))?;
267                (r, g, b, a)
268            }
269            6 => {
270                // #rrggbb
271                let r = parse(&hex[0..2])?;
272                let g = parse(&hex[2..4])?;
273                let b = parse(&hex[4..6])?;
274                (r, g, b, 255u8)
275            }
276            8 => {
277                // #rrggbbaa
278                let r = parse(&hex[0..2])?;
279                let g = parse(&hex[2..4])?;
280                let b = parse(&hex[4..6])?;
281                let a = parse(&hex[6..8])?;
282                (r, g, b, a)
283            }
284            _ => return Err(ImageError::InvalidColor(hex.to_string())),
285        };
286        Ok(Self { r, g, b, a })
287    }
288
289    /// 转换为 `Rgba<u8>` — 用于 image crate
290    pub fn to_rgba(self) -> Rgba<u8> {
291        Rgba([self.r, self.g, self.b, self.a])
292    }
293}
294
295impl Default for Color {
296    fn default() -> Self {
297        Self::rgb(0, 0, 0)
298    }
299}
300
301// ============================================================================
302// Position 枚举 — 对齐 Grafika\Position
303// ============================================================================
304
305/// 位置 — 对齐 PHP `Grafika\Position`
306///
307/// PHP 源码(`Position.php`)9 种位置字符串:
308/// - `top-left` / `top-center` / `top-right`
309/// - `center-left` / `center` / `center-right`
310/// - `bottom-left` / `bottom-center` / `bottom-right`
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub enum Position {
313    /// 左上(对齐 `top-left`)
314    TopLeft,
315    /// 顶部居中(对齐 `top-center`)
316    TopCenter,
317    /// 右上(对齐 `top-right`)
318    TopRight,
319    /// 左侧居中(对齐 `center-left`)
320    CenterLeft,
321    /// 正中(对齐 `center`)
322    Center,
323    /// 右侧居中(对齐 `center-right`)
324    CenterRight,
325    /// 左下(对齐 `bottom-left`)
326    BottomLeft,
327    /// 底部居中(对齐 `bottom-center`)
328    BottomCenter,
329    /// 右下(对齐 `bottom-right`)
330    BottomRight,
331}
332
333impl Position {
334    /// 从字符串解析 — 对齐 Grafika `Position::__construct($position)`
335    pub fn parse(s: &str) -> Result<Self, ImageError> {
336        match s.to_lowercase().as_str() {
337            "top-left" => Ok(Self::TopLeft),
338            "top-center" => Ok(Self::TopCenter),
339            "top-right" => Ok(Self::TopRight),
340            "center-left" => Ok(Self::CenterLeft),
341            "center" => Ok(Self::Center),
342            "center-right" => Ok(Self::CenterRight),
343            "bottom-left" => Ok(Self::BottomLeft),
344            "bottom-center" => Ok(Self::BottomCenter),
345            "bottom-right" => Ok(Self::BottomRight),
346            _ => Err(ImageError::InvalidArgument(format!(
347                "Unknown position: {s}"
348            ))),
349        }
350    }
351
352    /// 转换为字符串(对齐 PHP 字符串值)
353    pub fn as_str(self) -> &'static str {
354        match self {
355            Self::TopLeft => "top-left",
356            Self::TopCenter => "top-center",
357            Self::TopRight => "top-right",
358            Self::CenterLeft => "center-left",
359            Self::Center => "center",
360            Self::CenterRight => "center-right",
361            Self::BottomLeft => "bottom-left",
362            Self::BottomCenter => "bottom-center",
363            Self::BottomRight => "bottom-right",
364        }
365    }
366
367    /// 计算 x/y 偏移 — 对齐 `Grafika\Position::getXY($w1, $h1, $w2, $h2)`
368    ///
369    /// 参数:
370    /// - `w1, h1`:主图宽高
371    /// - `w2, h2`:叠加图宽高
372    ///
373    /// 返回 `(x, y)` 偏移坐标
374    pub fn get_xy(self, w1: u32, h1: u32, w2: u32, h2: u32) -> (i32, i32) {
375        let w1 = w1 as i32;
376        let h1 = h1 as i32;
377        let w2 = w2 as i32;
378        let h2 = h2 as i32;
379        let x = match self {
380            Self::TopLeft | Self::CenterLeft | Self::BottomLeft => 0,
381            Self::TopCenter | Self::Center | Self::BottomCenter => (w1 - w2) / 2,
382            Self::TopRight | Self::CenterRight | Self::BottomRight => w1 - w2,
383        };
384        let y = match self {
385            Self::TopLeft | Self::TopCenter | Self::TopRight => 0,
386            Self::CenterLeft | Self::Center | Self::CenterRight => (h1 - h2) / 2,
387            Self::BottomLeft | Self::BottomCenter | Self::BottomRight => h1 - h2,
388        };
389        (x, y)
390    }
391}
392
393// ============================================================================
394// Image 结构 — 对齐 Grafika\Gd\Image
395// ============================================================================
396
397/// 图像 — 对齐 PHP `Grafika\Gd\Image`
398///
399/// PHP `Image` 类持有 GD 资源 + 元数据(width/height/type/file_path/animated/blocks)。
400/// Rust 端用 `DynamicImage` 替代 GD 资源,其余字段保留。
401#[derive(Debug)]
402pub struct Image {
403    /// 内部图像数据(对齐 PHP `$gd`)
404    dyn_image: DynamicImage,
405    /// 源文件路径(对齐 PHP `$imageFile`)
406    file_path: Option<PathBuf>,
407    /// 图像类型(对齐 PHP `$type`)
408    image_type: ImageType,
409}
410
411impl Image {
412    /// 从文件打开 — 对齐 `Image::createFromFile($imageFile)`
413    ///
414    /// PHP 行为:用 `getimagesize()` 探测类型,分派到 `_createGif/_createJpeg/_createPng/_createWbmp`。
415    /// Rust 端用 `image::open` 统一处理,再用 `guess_type` 推断类型。
416    pub async fn open<P: AsRef<Path>>(path: P) -> Result<Self, ImageError> {
417        let path = path.as_ref();
418        let dyn_image = image::open(path)?;
419        let image_type = guess_image_type(path)?;
420        Ok(Self {
421            dyn_image,
422            file_path: Some(path.to_path_buf()),
423            image_type,
424        })
425    }
426
427    /// 创建空白画布 — 对齐 `Image::createBlank($width, $height)`
428    ///
429    /// PHP 行为:`imagecreatetruecolor($width, $height)`,默认黑色。
430    /// Rust 端用 `RgbaImage::new`,默认透明(与 PHP GD 默认黑色不同,但更符合 Rust 习惯)。
431    pub fn create_blank(width: u32, height: u32) -> Self {
432        let image: RgbaImage = ImageBuffer::new(width, height);
433        Self {
434            dyn_image: DynamicImage::ImageRgba8(image),
435            file_path: None,
436            image_type: ImageType::Unknown,
437        }
438    }
439
440    /// 从 DynamicImage 构造(Rust 扩展,无 PHP 对应)
441    pub fn from_dynamic(dyn_image: DynamicImage, image_type: ImageType) -> Self {
442        Self {
443            dyn_image,
444            file_path: None,
445            image_type,
446        }
447    }
448
449    /// 获取宽度 — 对齐 `Image::getWidth()`
450    pub fn width(&self) -> u32 {
451        self.dyn_image.width()
452    }
453
454    /// 获取高度 — 对齐 `Image::getHeight()`
455    pub fn height(&self) -> u32 {
456        self.dyn_image.height()
457    }
458
459    /// 获取图像类型 — 对齐 `Image::getType()`
460    pub fn image_type(&self) -> ImageType {
461        self.image_type
462    }
463
464    /// 获取源文件路径 — 对齐 `Image::getImageFile()`
465    pub fn file_path(&self) -> Option<&Path> {
466        self.file_path.as_deref()
467    }
468
469    /// 获取内部 DynamicImage 引用(Rust 扩展)
470    pub fn as_dynamic(&self) -> &DynamicImage {
471        &self.dyn_image
472    }
473
474    /// 获取内部 DynamicImage 可变引用(Rust 扩展)
475    pub fn as_dynamic_mut(&mut self) -> &mut DynamicImage {
476        &mut self.dyn_image
477    }
478
479    /// 转换为 RGBA8 — 用于图像操作(Rust 扩展,对齐 GD `imagecreatetruecolor` 返回的资源)
480    pub fn to_rgba8(&self) -> RgbaImage {
481        self.dyn_image.to_rgba8()
482    }
483
484    /// 从 RGBA8 缓冲构造(Rust 扩展)
485    pub fn from_rgba8(image: RgbaImage, image_type: ImageType) -> Self {
486        Self {
487            dyn_image: DynamicImage::ImageRgba8(image),
488            file_path: None,
489            image_type,
490        }
491    }
492}
493
494/// 从文件路径推断图像类型 — 对齐 Grafika `_guessType($imageFile)` 用 `getimagesize`
495fn guess_image_type(path: &Path) -> Result<ImageType, ImageError> {
496    // 优先按扩展名判断(对齐 Grafika `_getImageTypeFromFileName`)
497    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
498    let from_ext = ImageType::from_extension(ext);
499    if from_ext != ImageType::Unknown {
500        return Ok(from_ext);
501    }
502    // 扩展名未知时用 image crate 探测(对齐 PHP `getimagesize`)
503    let format = image::ImageReader::open(path)
504        .map_err(|e| ImageError::OpenFailed(format!("{path:?}: {e}")))?
505        .with_guessed_format()
506        .map_err(|e| ImageError::OpenFailed(format!("{path:?}: {e}")))?
507        .format()
508        .ok_or_else(|| ImageError::UnsupportedType("Unknown image format".to_string()))?;
509    Ok(ImageType::from_image_format(format))
510}
511
512// ============================================================================
513// Editor 结构 — 对齐 Grafika\Gd\Editor
514// ============================================================================
515
516/// 图像编辑器 — 对齐 PHP `Grafika\Gd\Editor`
517///
518/// PHP Grafika 把所有操作方法集中在 `Editor` 类,`Image` 只持有状态。
519/// Rust 端保持同样的架构分离。
520///
521/// 使用方式(对齐 PHP `$editor = Grafika::createEditor(['Gd'])`):
522/// ```ignore
523/// use sz_rust_core::upload::image::{Editor, Image, Color};
524/// let mut editor = Editor::new();
525/// let mut img = Image::open("test.png")?;
526/// editor.resize_exact(&mut img, 100, 100);
527/// editor.save(&img, "out.png", None, None, false, 0o755)?;
528/// ```
529pub struct Editor;
530
531impl Editor {
532    /// 创建编辑器实例 — 对齐 `Grafika::createEditor(['Gd'])`
533    pub fn new() -> Self {
534        Self
535    }
536
537    /// 打开文件 — 对齐 `Editor::open(&$image, $imageFile)`
538    ///
539    /// PHP 语义:`$image` 按引用传递,函数内部赋值为新 Image 对象。
540    /// Rust 语义:返回新 `Image`,调用方自行赋值。
541    pub async fn open<P: AsRef<Path>>(&self, path: P) -> Result<Image, ImageError> {
542        Image::open(path).await
543    }
544
545    /// 强制尺寸缩放 — 对齐 `Editor::resizeExact(&$image, $newWidth, $newHeight)`
546    ///
547    /// PHP 行为:忽略宽高比,强制缩放到目标尺寸(对应 GD `imagecopyresampled`)。
548    /// Rust 端用 `image::imageops::resize` + `FilterType::Lanczos3`(高质量重采样)。
549    pub fn resize_exact(&self, image: &mut Image, new_width: u32, new_height: u32) {
550        let resized = image::imageops::resize(
551            image.as_dynamic(),
552            new_width,
553            new_height,
554            image::imageops::FilterType::Lanczos3,
555        );
556        image.dyn_image = DynamicImage::ImageRgba8(resized);
557    }
558
559    /// 等比缩放适配 — 对齐 `Editor::resizeFit(&$image, $newWidth, $newHeight)`
560    ///
561    /// PHP 行为:等比缩放,使图像完全包含在目标框内(不裁剪)。
562    pub fn resize_fit(&self, image: &mut Image, new_width: u32, new_height: u32) {
563        let (w, h) = (image.width(), image.height());
564        let ratio = (new_width as f64 / w as f64).min(new_height as f64 / h as f64);
565        let target_w = (w as f64 * ratio).round() as u32;
566        let target_h = (h as f64 * ratio).round() as u32;
567        let resized = image::imageops::resize(
568            image.as_dynamic(),
569            target_w,
570            target_h,
571            image::imageops::FilterType::Lanczos3,
572        );
573        image.dyn_image = DynamicImage::ImageRgba8(resized);
574    }
575
576    /// 填充+裁剪 — 对齐 `Editor::resizeFill(&$image, $newWidth, $newHeight)`
577    ///
578    /// PHP 行为:等比缩放使图像完全覆盖目标框,超出部分居中裁剪。
579    pub fn resize_fill(&self, image: &mut Image, new_width: u32, new_height: u32) {
580        let (w, h) = (image.width(), image.height());
581        let ratio = (new_width as f64 / w as f64).max(new_height as f64 / h as f64);
582        let scaled_w = (w as f64 * ratio).round() as u32;
583        let scaled_h = (h as f64 * ratio).round() as u32;
584        // 1. 等比放大
585        let scaled = image::imageops::resize(
586            image.as_dynamic(),
587            scaled_w,
588            scaled_h,
589            image::imageops::FilterType::Lanczos3,
590        );
591        // 2. 居中裁剪
592        let x = (scaled_w - new_width) / 2;
593        let y = (scaled_h - new_height) / 2;
594        let cropped = image::imageops::crop_imm(&scaled, x, y, new_width, new_height).to_image();
595        image.dyn_image = DynamicImage::ImageRgba8(cropped);
596    }
597
598    /// 等比按宽 — 对齐 `Editor::resizeExactWidth(&$image, $newWidth)`
599    pub fn resize_exact_width(&self, image: &mut Image, new_width: u32) {
600        let h = image.height();
601        let new_height = (h as f64 * (new_width as f64 / image.width() as f64)).round() as u32;
602        self.resize_exact(image, new_width, new_height);
603    }
604
605    /// 等比按高 — 对齐 `Editor::resizeExactHeight(&$image, $newHeight)`
606    pub fn resize_exact_height(&self, image: &mut Image, new_height: u32) {
607        let w = image.width();
608        let new_width = (w as f64 * (new_height as f64 / image.height() as f64)).round() as u32;
609        self.resize_exact(image, new_width, new_height);
610    }
611
612    /// 裁剪 — 对齐 `Editor::crop(&$image, $cropWidth, $cropHeight, $position, $offsetX, $offsetY)`
613    ///
614    /// PHP 行为:按 `$position` 计算裁剪起点,加 `$offsetX/$offsetY` 偏移。
615    pub fn crop(
616        &self,
617        image: &mut Image,
618        crop_width: u32,
619        crop_height: u32,
620        position: Position,
621        offset_x: i32,
622        offset_y: i32,
623    ) -> Result<(), ImageError> {
624        let (w, h) = (image.width(), image.height());
625        if crop_width > w || crop_height > h {
626            return Err(ImageError::InvalidArgument(format!(
627                "crop size {crop_width}x{crop_height} larger than image {w}x{h}"
628            )));
629        }
630        let (mut x, mut y) = position.get_xy(w, h, crop_width, crop_height);
631        x += offset_x;
632        y += offset_y;
633        // 边界检查
634        let x = x.max(0) as u32;
635        let y = y.max(0) as u32;
636        let x = x.min(w - crop_width);
637        let y = y.min(h - crop_height);
638        let cropped =
639            image::imageops::crop_imm(image.as_dynamic(), x, y, crop_width, crop_height).to_image();
640        image.dyn_image = DynamicImage::ImageRgba8(cropped);
641        Ok(())
642    }
643
644    /// 图层合成 — 对齐 `Editor::blend(&$image1, $image2, $type, $opacity, $position, $offsetX, $offsetY)`
645    ///
646    /// PHP 行为:
647    /// 1. 按 `$position` + `$offsetX/$offsetY` 计算叠加位置
648    /// 2. 创建新画布(image1 大小)
649    /// 3. 复制 image1 到新画布
650    /// 4. 按 `$type`(normal/multiply/overlay/screen)混合 image2 到新画布
651    /// 5. 销毁原 image1 GD 资源,替换为新画布
652    ///
653    /// Rust 端实现 normal 模式(项目业务只用 normal),其他模式预留接口。
654    #[allow(clippy::too_many_arguments)]
655    pub fn blend(
656        &self,
657        image1: &mut Image,
658        image2: &Image,
659        blend_type: BlendType,
660        opacity: f32,
661        position: Position,
662        offset_x: i32,
663        offset_y: i32,
664    ) -> Result<(), ImageError> {
665        let (w1, h1) = (image1.width(), image1.height());
666        let (w2, h2) = (image2.width(), image2.height());
667        let (base_x, base_y) = position.get_xy(w1, h1, w2, h2);
668        let x = base_x + offset_x;
669        let y = base_y + offset_y;
670
671        // 转换为 RGBA8
672        let mut base = image1.to_rgba8();
673        let overlay = image2.to_rgba8();
674
675        match blend_type {
676            BlendType::Normal => {
677                blend_normal(&mut base, &overlay, x, y, opacity);
678            }
679            BlendType::Multiply => {
680                blend_multiply(&mut base, &overlay, x, y, opacity);
681            }
682            BlendType::Overlay => {
683                blend_overlay(&mut base, &overlay, x, y, opacity);
684            }
685            BlendType::Screen => {
686                blend_screen(&mut base, &overlay, x, y, opacity);
687            }
688        }
689
690        image1.dyn_image = DynamicImage::ImageRgba8(base);
691        Ok(())
692    }
693
694    /// 文本绘制 — 对齐 `Editor::text(&$image, $text, $size, $x, $y, $color, $font, $angle)`
695    ///
696    /// PHP 行为(GD `imagettftext`):
697    /// - `$x, $y` 是文本基线起点
698    /// - Grafika 内部 `$y += $size`(GD 的 y 是基线,绘制时 y 要加 size 才是顶部)
699    /// - `$angle` 是角度(0=水平)
700    /// - `$font` 是 TTF 文件路径
701    ///
702    /// Rust 端使用 `imageproc::drawing::draw_text_mut`:
703    /// - y 是顶部(不是基线)
704    /// - 所以 Rust y = PHP y - size(反向偏移)
705    /// - angle 暂不支持(imageproc 0.25 文本绘制不支持旋转)
706    #[allow(clippy::too_many_arguments)]
707    pub async fn text(
708        &self,
709        image: &mut Image,
710        text: &str,
711        size: u32,
712        x: i32,
713        y: i32,
714        color: Color,
715        font_path: Option<&Path>,
716    ) -> Result<(), ImageError> {
717        let font = load_font(font_path).await?;
718        // R5-30:GD y 是基线,Rust y 是顶部,反向偏移
719        let rust_y = y - size as i32;
720        let mut rgba_image = image.to_rgba8();
721        let scale = PxScale::from(size as f32);
722        imageproc::drawing::draw_text_mut(
723            &mut rgba_image,
724            color.to_rgba(),
725            x,
726            rust_y,
727            scale,
728            &font,
729            text,
730        );
731        image.dyn_image = DynamicImage::ImageRgba8(rgba_image);
732        Ok(())
733    }
734
735    /// 旋转 — 对齐 `Editor::rotate(&$image, $angle, $color)`
736    ///
737    /// PHP 行为:用 `imagerotate` 旋转,`$color` 是旋转后空白区域填充色。
738    /// Rust 端用 `image::imageops::rotate90/180/270` 处理 90/180/270 度,
739    /// 任意角度暂不支持(imageproc 0.25 不直接支持)。
740    pub fn rotate(&self, image: &mut Image, angle: f32) -> Result<(), ImageError> {
741        // 标准化到 [0, 360)
742        let angle = angle.rem_euclid(360.0);
743        let rotated = match angle as i32 {
744            0 => image.dyn_image.clone(),
745            90 | -270 => image.dyn_image.rotate90(),
746            180 | -180 => image.dyn_image.rotate180(),
747            270 | -90 => image.dyn_image.rotate270(),
748            _ => {
749                return Err(ImageError::InvalidArgument(format!(
750                    "rotate only supports 0/90/180/270 degrees, got {angle}"
751                )))
752            }
753        };
754        image.dyn_image = rotated;
755        Ok(())
756    }
757
758    /// 翻转 — 对齐 `Editor::flip(&$image, $mode)`
759    ///
760    /// PHP 行为:`$mode` 是 'h'(水平翻转)或 'v'(垂直翻转)。
761    pub fn flip(&self, image: &mut Image, mode: FlipMode) {
762        match mode {
763            FlipMode::Horizontal => image.dyn_image = image.dyn_image.fliph(),
764            FlipMode::Vertical => image.dyn_image = image.dyn_image.flipv(),
765        }
766    }
767
768    /// 填充 — 对齐 `Editor::fill(&$image, $color, $x, $y)`
769    ///
770    /// PHP 行为:从 `($x, $y)` 开始用 `$color` 填充连通区域(`imagefill`)。
771    /// Rust 端简化为填充整个图像(业务侧不使用此方法)。
772    pub fn fill(&self, image: &mut Image, color: Color) {
773        let (w, h) = (image.width(), image.height());
774        let pixel = color.to_rgba();
775        let mut buf: RgbaImage = ImageBuffer::new(w, h);
776        for y in 0..h {
777            for x in 0..w {
778                buf.put_pixel(x, y, pixel);
779            }
780        }
781        image.dyn_image = DynamicImage::ImageRgba8(buf);
782    }
783
784    /// 保存 — 对齐 `Editor::save($image, $file, $type, $quality, $interlace, $permission)`
785    ///
786    /// PHP 行为:
787    /// - `$type=null` 时按文件扩展名猜,扩展名也未知时用原图类型
788    /// - 自动 `mkdir($targetDir, $permission, true)`
789    /// - GIF:动画 → GifHelper::encode;非动画 → imagegif
790    /// - PNG:imagepng(无 quality)
791    /// - JPEG:quality=null → 75;imageinterlace 控制渐进式;imagejpeg
792    ///
793    /// Rust 端用 `image::save` 简化处理,JPEG quality 通过 `image::codecs::jpeg::JpegEncoder` 设置。
794    pub async fn save(
795        &self,
796        image: &Image,
797        file: &Path,
798        image_type: Option<ImageType>,
799        quality: Option<u8>,
800        _interlace: bool,
801        permission: u32,
802    ) -> Result<(), ImageError> {
803        // 在非 unix 平台 permission 不使用,显式忽略避免警告
804        let _ = &permission;
805        // 1. 确定保存类型
806        let save_type = image_type.unwrap_or_else(|| {
807            // 按扩展名猜(对齐 PHP `$type=null` 行为)
808            let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
809            let t = ImageType::from_extension(ext);
810            if t != ImageType::Unknown {
811                t
812            } else {
813                image.image_type()
814            }
815        });
816
817        // 2. 自动创建父目录(对齐 PHP `mkdir`)
818        if let Some(parent) = file.parent() {
819            if !parent.as_os_str().is_empty() && !parent.exists() {
820                tokio::fs::create_dir_all(parent).await?;
821                #[cfg(unix)]
822                {
823                    use std::os::unix::fs::PermissionsExt;
824                    let _ = tokio::fs::set_permissions(
825                        parent,
826                        std::fs::Permissions::from_mode(permission),
827                    )
828                    .await;
829                }
830            }
831        }
832
833        // 3. 按类型保存
834        match save_type {
835            ImageType::Png => {
836                image.as_dynamic().save(file)?;
837            }
838            ImageType::Jpeg => {
839                // JPEG 默认 quality=75(对齐 PHP)
840                let q = quality.unwrap_or(75);
841                let q = q.clamp(1, 100);
842                let rgba = image.to_rgba8();
843                let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
844                let mut buf = Vec::new();
845                let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, q);
846                encoder.encode_image(&image::DynamicImage::ImageRgb8(rgb))?;
847                tokio::fs::write(file, buf).await?;
848            }
849            ImageType::Gif => {
850                image.as_dynamic().save(file)?;
851            }
852            ImageType::Wbmp => {
853                return Err(ImageError::UnsupportedType(
854                    "WBMP encoding not supported by image crate".to_string(),
855                ));
856            }
857            ImageType::Unknown => {
858                return Err(ImageError::UnsupportedType(format!(
859                    "Cannot determine save type for file: {file:?}"
860                )));
861            }
862        }
863        Ok(())
864    }
865}
866
867impl Default for Editor {
868    fn default() -> Self {
869        Self::new()
870    }
871}
872
873// ============================================================================
874// BlendType 枚举 — 对齐 Grafika blend $type 参数
875// ============================================================================
876
877/// 混合模式 — 对齐 PHP `Editor::blend` 的 `$type` 参数
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879pub enum BlendType {
880    /// 普通模式(对齐 `'normal'`)— 项目业务唯一使用
881    Normal,
882    /// 正片叠底(对齐 `'multiply'`)
883    Multiply,
884    /// 叠加(对齐 `'overlay'`)
885    Overlay,
886    /// 滤色(对齐 `'screen'`)
887    Screen,
888}
889
890impl BlendType {
891    /// 从字符串解析 — 对齐 Grafika `$type` 字符串
892    pub fn parse(s: &str) -> Result<Self, ImageError> {
893        match s.to_lowercase().as_str() {
894            "normal" => Ok(Self::Normal),
895            "multiply" => Ok(Self::Multiply),
896            "overlay" => Ok(Self::Overlay),
897            "screen" => Ok(Self::Screen),
898            _ => Err(ImageError::InvalidArgument(format!(
899                "Unknown blend type: {s}"
900            ))),
901        }
902    }
903}
904
905// ============================================================================
906// FlipMode 枚举 — 对齐 Grafika flip $mode 参数
907// ============================================================================
908
909/// 翻转模式 — 对齐 PHP `Editor::flip` 的 `$mode` 参数
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911pub enum FlipMode {
912    /// 水平翻转(对齐 `'h'`)
913    Horizontal,
914    /// 垂直翻转(对齐 `'v'`)
915    Vertical,
916}
917
918impl FlipMode {
919    /// 从字符串解析 — 对齐 Grafika `$mode` 字符串
920    pub fn parse(s: &str) -> Result<Self, ImageError> {
921        match s.to_lowercase().as_str() {
922            "h" => Ok(Self::Horizontal),
923            "v" => Ok(Self::Vertical),
924            _ => Err(ImageError::InvalidArgument(format!(
925                "Unknown flip mode: {s}"
926            ))),
927        }
928    }
929}
930
931// ============================================================================
932// Normal 混合实现 — 对齐 GD imagecopy + alpha
933// ============================================================================
934
935/// Normal 混合 — 对齐 Grafika `_blendNormal`
936///
937/// 算法:`dest = src * opacity + dest * (1 - opacity)`(按 alpha 通道加权)
938fn blend_normal(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
939    let (w1, h1) = base.dimensions();
940    let (w2, h2) = overlay.dimensions();
941    let opacity = opacity.clamp(0.0, 1.0);
942
943    for oy in 0..h2 {
944        for ox in 0..w2 {
945            let bx = x + ox as i32;
946            let by = y + oy as i32;
947            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
948                continue;
949            }
950            let src = overlay.get_pixel(ox, oy);
951            let dst = base.get_pixel(bx as u32, by as u32);
952            // 源像素有效 alpha + opacity
953            let src_alpha = (src[3] as f32 / 255.0) * opacity;
954            if src_alpha < 1e-6 {
955                continue;
956            }
957            let dst_alpha = dst[3] as f32 / 255.0;
958            // 输出 alpha = src_a + dst_a * (1 - src_a)
959            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
960            if out_alpha < 1e-6 {
961                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
962                continue;
963            }
964            // 输出 RGB = (src_rgb * src_a + dst_rgb * dst_a * (1 - src_a)) / out_a
965            let out_r = ((src[0] as f32 * src_alpha
966                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
967                / out_alpha) as u8;
968            let out_g = ((src[1] as f32 * src_alpha
969                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
970                / out_alpha) as u8;
971            let out_b = ((src[2] as f32 * src_alpha
972                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
973                / out_alpha) as u8;
974            let out_a = (out_alpha * 255.0) as u8;
975            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
976        }
977    }
978}
979
980/// Multiply 混合 — 对齐 Grafika `_blendMultiply`
981fn blend_multiply(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
982    let (w1, h1) = base.dimensions();
983    let (w2, h2) = overlay.dimensions();
984    let opacity = opacity.clamp(0.0, 1.0);
985
986    for oy in 0..h2 {
987        for ox in 0..w2 {
988            let bx = x + ox as i32;
989            let by = y + oy as i32;
990            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
991                continue;
992            }
993            let src = overlay.get_pixel(ox, oy);
994            let dst = base.get_pixel(bx as u32, by as u32);
995            let src_alpha = (src[3] as f32 / 255.0) * opacity;
996            if src_alpha < 1e-6 {
997                continue;
998            }
999            // multiply: out = src * dst / 255
1000            let mult_r = (src[0] as u16 * dst[0] as u16 / 255) as u8;
1001            let mult_g = (src[1] as u16 * dst[1] as u16 / 255) as u8;
1002            let mult_b = (src[2] as u16 * dst[2] as u16 / 255) as u8;
1003            let dst_alpha = dst[3] as f32 / 255.0;
1004            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1005            if out_alpha < 1e-6 {
1006                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1007                continue;
1008            }
1009            let out_r = ((mult_r as f32 * src_alpha
1010                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1011                / out_alpha) as u8;
1012            let out_g = ((mult_g as f32 * src_alpha
1013                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1014                / out_alpha) as u8;
1015            let out_b = ((mult_b as f32 * src_alpha
1016                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1017                / out_alpha) as u8;
1018            let out_a = (out_alpha * 255.0) as u8;
1019            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1020        }
1021    }
1022}
1023
1024/// Overlay 混合 — 对齐 Grafika `_blendOverlay`
1025fn blend_overlay(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
1026    let (w1, h1) = base.dimensions();
1027    let (w2, h2) = overlay.dimensions();
1028    let opacity = opacity.clamp(0.0, 1.0);
1029
1030    for oy in 0..h2 {
1031        for ox in 0..w2 {
1032            let bx = x + ox as i32;
1033            let by = y + oy as i32;
1034            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
1035                continue;
1036            }
1037            let src = overlay.get_pixel(ox, oy);
1038            let dst = base.get_pixel(bx as u32, by as u32);
1039            let src_alpha = (src[3] as f32 / 255.0) * opacity;
1040            if src_alpha < 1e-6 {
1041                continue;
1042            }
1043            // overlay: if dst <= 128: out = 2 * src * dst / 255; else: out = 255 - 2 * (255 - src) * (255 - dst) / 255
1044            let overlay_channel = |s: u8, d: u8| -> u8 {
1045                if d <= 128 {
1046                    (2 * s as u16 * d as u16 / 255) as u8
1047                } else {
1048                    (255 - (2 * (255 - s) as u16 * (255 - d) as u16 / 255)) as u8
1049                }
1050            };
1051            let ov_r = overlay_channel(src[0], dst[0]);
1052            let ov_g = overlay_channel(src[1], dst[1]);
1053            let ov_b = overlay_channel(src[2], dst[2]);
1054            let dst_alpha = dst[3] as f32 / 255.0;
1055            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1056            if out_alpha < 1e-6 {
1057                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1058                continue;
1059            }
1060            let out_r = ((ov_r as f32 * src_alpha + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1061                / out_alpha) as u8;
1062            let out_g = ((ov_g as f32 * src_alpha + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1063                / out_alpha) as u8;
1064            let out_b = ((ov_b as f32 * src_alpha + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1065                / out_alpha) as u8;
1066            let out_a = (out_alpha * 255.0) as u8;
1067            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1068        }
1069    }
1070}
1071
1072/// Screen 混合 — 对齐 Grafika `_blendScreen`
1073fn blend_screen(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
1074    let (w1, h1) = base.dimensions();
1075    let (w2, h2) = overlay.dimensions();
1076    let opacity = opacity.clamp(0.0, 1.0);
1077
1078    for oy in 0..h2 {
1079        for ox in 0..w2 {
1080            let bx = x + ox as i32;
1081            let by = y + oy as i32;
1082            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
1083                continue;
1084            }
1085            let src = overlay.get_pixel(ox, oy);
1086            let dst = base.get_pixel(bx as u32, by as u32);
1087            let src_alpha = (src[3] as f32 / 255.0) * opacity;
1088            if src_alpha < 1e-6 {
1089                continue;
1090            }
1091            // screen: out = 255 - (255 - src) * (255 - dst) / 255
1092            let screen_r = (255 - (255 - src[0]) as u16 * (255 - dst[0]) as u16 / 255) as u8;
1093            let screen_g = (255 - (255 - src[1]) as u16 * (255 - dst[1]) as u16 / 255) as u8;
1094            let screen_b = (255 - (255 - src[2]) as u16 * (255 - dst[2]) as u16 / 255) as u8;
1095            let dst_alpha = dst[3] as f32 / 255.0;
1096            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1097            if out_alpha < 1e-6 {
1098                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1099                continue;
1100            }
1101            let out_r = ((screen_r as f32 * src_alpha
1102                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1103                / out_alpha) as u8;
1104            let out_g = ((screen_g as f32 * src_alpha
1105                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1106                / out_alpha) as u8;
1107            let out_b = ((screen_b as f32 * src_alpha
1108                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1109                / out_alpha) as u8;
1110            let out_a = (out_alpha * 255.0) as u8;
1111            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1112        }
1113    }
1114}
1115
1116// ============================================================================
1117// 字体加载 + 文本测量 — 对齐 imagettfbbox
1118// ============================================================================
1119
1120/// 加载字体 — 优先用 FontRef(零拷贝),失败时返回内置默认字体
1121///
1122/// 对齐 Grafika `text()` 默认字体 `LiberationSans-Regular.ttf`。
1123async fn load_font(font_path: Option<&Path>) -> Result<FontVec, ImageError> {
1124    match font_path {
1125        Some(path) => {
1126            let data = tokio::fs::read(path)
1127                .await
1128                .map_err(|e| ImageError::FontLoadFailed(format!("{path:?}: {e}")))?;
1129            Ok(FontVec::try_from_vec(data)
1130                .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font {path:?}: {e}")))?)
1131        }
1132        None => {
1133            // 无字体路径时返回错误(PHP Grafika 有默认字体,Rust 端要求显式提供)
1134            Err(ImageError::FontLoadFailed(
1135                "font_path is required (no default font available)".to_string(),
1136            ))
1137        }
1138    }
1139}
1140
1141/// 测量文本边界 — 对齐 PHP `imagettfbbox($size, $angle, $font, $text)`
1142///
1143/// PHP 返回 8 个值(4 个角点):
1144/// - 0: 左下角 x
1145/// - 1: 左下角 y
1146/// - 2: 右下角 x
1147/// - 3: 右下角 y
1148/// - 4: 右上角 x
1149/// - 5: 右上角 y
1150/// - 6: 左上角 x
1151/// - 7: 左上角 y
1152///
1153/// Rust 端简化为返回 `(width, height)` — 大多数场景只需要这两个值。
1154///
1155/// 注意:PHP `imagettfbbox` 的 y 轴向下,但返回值中"上"的 y 是负数。
1156pub async fn measure_text(
1157    font_path: &Path,
1158    size: u32,
1159    text: &str,
1160) -> Result<TextMetrics, ImageError> {
1161    let data = tokio::fs::read(font_path)
1162        .await
1163        .map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
1164    let font = FontVec::try_from_vec(data)
1165        .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
1166    Ok(measure_text_with_font(&font, size, text))
1167}
1168
1169/// 用已加载的字体测量文本
1170fn measure_text_with_font<F: Font>(font: &F, size: u32, text: &str) -> TextMetrics {
1171    let scale = PxScale::from(size as f32);
1172    let scaled = font.as_scaled(scale);
1173    let ascent = scaled.ascent();
1174    let descent = scaled.descent();
1175    let height = (ascent - descent).ceil();
1176
1177    let mut width: f32 = 0.0;
1178    let mut prev_glyph: Option<Glyph> = None;
1179    for ch in text.chars() {
1180        let glyph = scaled.scaled_glyph(ch);
1181        if let Some(prev) = prev_glyph {
1182            width += scaled.kern(prev.id, glyph.id);
1183        }
1184        width += scaled.h_advance(glyph.id);
1185        prev_glyph = Some(glyph);
1186    }
1187
1188    TextMetrics {
1189        width: width.ceil() as i32,
1190        height: height.ceil() as i32,
1191        ascent: ascent.ceil() as i32,
1192        descent: descent.ceil() as i32,
1193    }
1194}
1195
1196/// 文本测量结果
1197#[derive(Debug, Clone, Copy)]
1198pub struct TextMetrics {
1199    /// 文本宽度(像素)
1200    pub width: i32,
1201    /// 文本高度(像素)
1202    pub height: i32,
1203    /// 字体 ascent(基线到顶部)
1204    pub ascent: i32,
1205    /// 字体 descent(基线到底部,通常为负数)
1206    pub descent: i32,
1207}
1208
1209// ============================================================================
1210// wrap_text — 对齐业务侧 ProductService::wrapText
1211// ============================================================================
1212
1213/// 文本自动换行 — 对齐 PHP `ProductService::wrapText($fontsize, $angle, $fontface, $string, $width, $max_line)`
1214///
1215/// PHP 源码(`app/common/service/qrcode/ProductService.php` 第 114-138 行):
1216/// ```php
1217/// private function wrapText($fontsize, $angle, $fontface, $string, $width, $max_line = null) {
1218///     $content = "";
1219///     $letter = [];
1220///     for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
1221///         $letter[] = mb_substr($string, $i, 1, 'UTF-8');
1222///     }
1223///     $line_count = 0;
1224///     foreach ($letter as $l) {
1225///         $testbox = imagettfbbox($fontsize, $angle, $fontface, $content . ' ' . $l);
1226///         if (($testbox[2] > $width) && ($content !== "")) {
1227///             $line_count++;
1228///             if ($max_line && $line_count >= $max_line) {
1229///                 $content = mb_substr($content, 0, -1, 'UTF-8') . "...";
1230///                 break;
1231///             }
1232///             $content .= "\n";
1233///         }
1234///         $content .= $l;
1235///     }
1236///     return $content;
1237/// }
1238/// ```
1239///
1240/// **关键细节**:
1241/// 1. PHP 用 `mb_strlen`/`mb_substr` 按 UTF-8 字符拆分
1242/// 2. `imagettfbbox` 测量 `$content . ' ' . $l`(注意有空格连接符)
1243/// 3. `$testbox[2]` 是右下角 x(即文本宽度)
1244/// 4. 超过 `$width` 时:先 `$line_count++`,再判断是否达到 `$max_line`
1245/// 5. 达到 `$max_line` 时:截掉最后一个字符 + `"..."` + break
1246/// 6. 未达到时:在 `$content` 末尾加 `\n`,然后继续加 `$l`
1247///
1248/// **Rust 实现**:
1249/// - 用 `measure_text_with_font` 替代 `imagettfbbox`
1250/// - 测量 `$content . ' ' . $l` 即 `format!("{content} {l}")`
1251/// - 其余逻辑 1:1 对齐
1252pub async fn wrap_text(
1253    font_path: &Path,
1254    fontsize: u32,
1255    string: &str,
1256    width: i32,
1257    max_line: Option<usize>,
1258) -> Result<String, ImageError> {
1259    let data = tokio::fs::read(font_path)
1260        .await
1261        .map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
1262    let font = FontVec::try_from_vec(data)
1263        .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
1264    Ok(wrap_text_with_font(
1265        &font, fontsize, string, width, max_line,
1266    ))
1267}
1268
1269/// 用已加载字体执行 wrap_text(避免重复读取字体文件)
1270fn wrap_text_with_font<F: Font>(
1271    font: &F,
1272    fontsize: u32,
1273    string: &str,
1274    width: i32,
1275    max_line: Option<usize>,
1276) -> String {
1277    let mut content = String::new();
1278    let mut line_count: usize = 0;
1279    for l in string.chars() {
1280        // 对齐 PHP `$content . ' ' . $l`
1281        let test = format!("{content} {l}");
1282        let metrics = measure_text_with_font(font, fontsize, &test);
1283        // 对齐 PHP `($testbox[2] > $width) && ($content !== "")`
1284        if metrics.width > width && !content.is_empty() {
1285            line_count += 1;
1286            if let Some(ml) = max_line {
1287                if line_count >= ml {
1288                    // 对齐 PHP `mb_substr($content, 0, -1, 'UTF-8') . "..."`
1289                    let trimmed: String =
1290                        content.chars().take(content.chars().count() - 1).collect();
1291                    content = format!("{trimmed}...");
1292                    break;
1293                }
1294            }
1295            content.push('\n');
1296        }
1297        content.push(l);
1298    }
1299    content
1300}
1301
1302// ============================================================================
1303// 测试
1304// ============================================================================
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309
1310    // ---- 组 1:ImageType 基础 ----
1311
1312    #[test]
1313    fn test_image_type_as_str() {
1314        assert_eq!(ImageType::Unknown.as_str(), "");
1315        assert_eq!(ImageType::Gif.as_str(), "GIF");
1316        assert_eq!(ImageType::Jpeg.as_str(), "JPEG");
1317        assert_eq!(ImageType::Png.as_str(), "PNG");
1318        assert_eq!(ImageType::Wbmp.as_str(), "WBMP");
1319    }
1320
1321    #[test]
1322    fn test_image_type_from_extension() {
1323        assert_eq!(ImageType::from_extension("gif"), ImageType::Gif);
1324        assert_eq!(ImageType::from_extension("jpg"), ImageType::Jpeg);
1325        assert_eq!(ImageType::from_extension("jpeg"), ImageType::Jpeg);
1326        assert_eq!(ImageType::from_extension("png"), ImageType::Png);
1327        assert_eq!(ImageType::from_extension("wbmp"), ImageType::Wbmp);
1328        assert_eq!(ImageType::from_extension("unknown"), ImageType::Unknown);
1329    }
1330
1331    #[test]
1332    fn test_image_type_default() {
1333        assert_eq!(ImageType::default(), ImageType::Unknown);
1334    }
1335
1336    #[test]
1337    fn test_image_type_from_image_format() {
1338        use image::ImageFormat;
1339        assert_eq!(
1340            ImageType::from_image_format(ImageFormat::Gif),
1341            ImageType::Gif
1342        );
1343        assert_eq!(
1344            ImageType::from_image_format(ImageFormat::Jpeg),
1345            ImageType::Jpeg
1346        );
1347        assert_eq!(
1348            ImageType::from_image_format(ImageFormat::Png),
1349            ImageType::Png
1350        );
1351        assert_eq!(
1352            ImageType::from_image_format(ImageFormat::WebP),
1353            ImageType::Unknown
1354        );
1355    }
1356
1357    #[test]
1358    fn test_image_type_to_image_format() {
1359        assert_eq!(
1360            ImageType::Gif.to_image_format(),
1361            Some(image::ImageFormat::Gif)
1362        );
1363        assert_eq!(
1364            ImageType::Jpeg.to_image_format(),
1365            Some(image::ImageFormat::Jpeg)
1366        );
1367        assert_eq!(
1368            ImageType::Png.to_image_format(),
1369            Some(image::ImageFormat::Png)
1370        );
1371        assert_eq!(ImageType::Wbmp.to_image_format(), None);
1372        assert_eq!(ImageType::Unknown.to_image_format(), None);
1373    }
1374
1375    #[test]
1376    fn test_image_type_from_extension_case_insensitive() {
1377        assert_eq!(ImageType::from_extension("GIF"), ImageType::Gif);
1378        assert_eq!(ImageType::from_extension("PNG"), ImageType::Png);
1379        assert_eq!(ImageType::from_extension("JPG"), ImageType::Jpeg);
1380    }
1381
1382    // ---- 组 2:Color ----
1383
1384    #[test]
1385    fn test_color_rgb() {
1386        let c = Color::rgb(255, 128, 0);
1387        assert_eq!(c.r, 255);
1388        assert_eq!(c.g, 128);
1389        assert_eq!(c.b, 0);
1390        assert_eq!(c.a, 255); // 不透明
1391    }
1392
1393    #[test]
1394    fn test_color_rgba() {
1395        let c = Color::rgba(255, 128, 0, 128);
1396        assert_eq!(c.r, 255);
1397        assert_eq!(c.g, 128);
1398        assert_eq!(c.b, 0);
1399        assert_eq!(c.a, 128);
1400    }
1401
1402    #[test]
1403    fn test_color_from_hex_rrggbb() {
1404        let c = Color::from_hex("#ff8000").unwrap();
1405        assert_eq!(c.r, 255);
1406        assert_eq!(c.g, 128);
1407        assert_eq!(c.b, 0);
1408        assert_eq!(c.a, 255);
1409    }
1410
1411    #[test]
1412    fn test_color_from_hex_rgb() {
1413        let c = Color::from_hex("#f80").unwrap();
1414        assert_eq!(c.r, 255);
1415        assert_eq!(c.g, 136);
1416        assert_eq!(c.b, 0);
1417        assert_eq!(c.a, 255);
1418    }
1419
1420    #[test]
1421    fn test_color_from_hex_rrggbbaa() {
1422        let c = Color::from_hex("#ff800080").unwrap();
1423        assert_eq!(c.r, 255);
1424        assert_eq!(c.g, 128);
1425        assert_eq!(c.b, 0);
1426        assert_eq!(c.a, 128);
1427    }
1428
1429    #[test]
1430    fn test_color_from_hex_no_hash() {
1431        let c = Color::from_hex("ff8000").unwrap();
1432        assert_eq!(c.r, 255);
1433        assert_eq!(c.g, 128);
1434        assert_eq!(c.b, 0);
1435    }
1436
1437    #[test]
1438    fn test_color_from_hex_invalid() {
1439        assert!(Color::from_hex("#xyz").is_err());
1440        assert!(Color::from_hex("#1").is_err());
1441        assert!(Color::from_hex("12345").is_err());
1442    }
1443
1444    #[test]
1445    fn test_color_to_rgba() {
1446        let c = Color::rgb(1, 2, 3);
1447        assert_eq!(c.to_rgba(), Rgba([1, 2, 3, 255]));
1448    }
1449
1450    #[test]
1451    fn test_color_default() {
1452        let c = Color::default();
1453        assert_eq!(c, Color::rgb(0, 0, 0));
1454    }
1455
1456    // ---- 组 3:Position ----
1457
1458    #[test]
1459    fn test_position_parse() {
1460        assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
1461        assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
1462        assert_eq!(Position::parse("TOP-RIGHT").unwrap(), Position::TopRight);
1463        assert_eq!(Position::parse("center").unwrap(), Position::Center);
1464        assert_eq!(
1465            Position::parse("bottom-right").unwrap(),
1466            Position::BottomRight
1467        );
1468    }
1469
1470    #[test]
1471    fn test_position_parse_invalid() {
1472        assert!(Position::parse("invalid").is_err());
1473        assert!(Position::parse("").is_err());
1474    }
1475
1476    #[test]
1477    fn test_position_as_str() {
1478        assert_eq!(Position::TopLeft.as_str(), "top-left");
1479        assert_eq!(Position::Center.as_str(), "center");
1480        assert_eq!(Position::BottomRight.as_str(), "bottom-right");
1481    }
1482
1483    #[test]
1484    fn test_position_get_xy_top_left() {
1485        // 主图 100x100,叠加图 20x20,左上角偏移 (0, 0)
1486        let (x, y) = Position::TopLeft.get_xy(100, 100, 20, 20);
1487        assert_eq!(x, 0);
1488        assert_eq!(y, 0);
1489    }
1490
1491    #[test]
1492    fn test_position_get_xy_center() {
1493        // 主图 100x100,叠加图 20x20,居中偏移 (40, 40)
1494        let (x, y) = Position::Center.get_xy(100, 100, 20, 20);
1495        assert_eq!(x, 40);
1496        assert_eq!(y, 40);
1497    }
1498
1499    #[test]
1500    fn test_position_get_xy_bottom_right() {
1501        // 主图 100x100,叠加图 20x20,右下偏移 (80, 80)
1502        let (x, y) = Position::BottomRight.get_xy(100, 100, 20, 20);
1503        assert_eq!(x, 80);
1504        assert_eq!(y, 80);
1505    }
1506
1507    #[test]
1508    fn test_position_get_xy_top_center() {
1509        let (x, y) = Position::TopCenter.get_xy(100, 100, 20, 20);
1510        assert_eq!(x, 40); // (100-20)/2
1511        assert_eq!(y, 0);
1512    }
1513
1514    // ---- 组 4:Image 基础 ----
1515
1516    fn create_test_png(path: &Path, w: u32, h: u32, color: Rgba<u8>) {
1517        let img: RgbaImage = ImageBuffer::from_pixel(w, h, color);
1518        img.save(path).unwrap();
1519    }
1520
1521    #[test]
1522    fn test_image_create_blank() {
1523        let img = Image::create_blank(100, 50);
1524        assert_eq!(img.width(), 100);
1525        assert_eq!(img.height(), 50);
1526        assert_eq!(img.image_type(), ImageType::Unknown);
1527        assert!(img.file_path().is_none());
1528    }
1529
1530    #[tokio::test]
1531    async fn test_image_open_png() {
1532        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1533        let path = tmp.path();
1534        create_test_png(path, 80, 60, Rgba([255, 0, 0, 255]));
1535        let img = Image::open(path).await.unwrap();
1536        assert_eq!(img.width(), 80);
1537        assert_eq!(img.height(), 60);
1538        assert_eq!(img.image_type(), ImageType::Png);
1539        assert!(img.file_path().is_some());
1540    }
1541
1542    #[test]
1543    fn test_image_from_dynamic() {
1544        let buf: RgbaImage = ImageBuffer::from_pixel(50, 50, Rgba([0, 255, 0, 255]));
1545        let dyn_img = DynamicImage::ImageRgba8(buf);
1546        let img = Image::from_dynamic(dyn_img, ImageType::Png);
1547        assert_eq!(img.width(), 50);
1548        assert_eq!(img.height(), 50);
1549        assert_eq!(img.image_type(), ImageType::Png);
1550    }
1551
1552    #[test]
1553    fn test_image_to_rgba8() {
1554        let img = Image::create_blank(30, 30);
1555        let rgba = img.to_rgba8();
1556        assert_eq!(rgba.dimensions(), (30, 30));
1557    }
1558
1559    // ---- 组 5:Editor open/save ----
1560
1561    #[test]
1562    fn test_editor_new() {
1563        let _editor = Editor::new();
1564        let _editor2 = Editor;
1565    }
1566
1567    #[tokio::test]
1568    async fn test_editor_open() {
1569        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1570        create_test_png(tmp.path(), 100, 100, Rgba([0, 0, 255, 255]));
1571        let editor = Editor::new();
1572        let img = editor.open(tmp.path()).await.unwrap();
1573        assert_eq!(img.width(), 100);
1574        assert_eq!(img.height(), 100);
1575    }
1576
1577    #[tokio::test]
1578    async fn test_editor_save_png() {
1579        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1580        create_test_png(tmp_in.path(), 50, 50, Rgba([0, 255, 0, 255]));
1581        let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1582        let editor = Editor::new();
1583        let img = editor.open(tmp_in.path()).await.unwrap();
1584        editor
1585            .save(&img, tmp_out.path(), None, None, false, 0o755)
1586            .await
1587            .unwrap();
1588        // 验证保存的文件可读
1589        let reopened = image::open(tmp_out.path()).unwrap();
1590        assert_eq!(reopened.width(), 50);
1591        assert_eq!(reopened.height(), 50);
1592    }
1593
1594    #[tokio::test]
1595    async fn test_editor_save_jpeg_with_quality() {
1596        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1597        create_test_png(tmp_in.path(), 50, 50, Rgba([128, 64, 32, 255]));
1598        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
1599        let editor = Editor::new();
1600        let img = editor.open(tmp_in.path()).await.unwrap();
1601        editor
1602            .save(&img, tmp_out.path(), None, Some(90), false, 0o755)
1603            .await
1604            .unwrap();
1605        let reopened = image::open(tmp_out.path()).unwrap();
1606        assert_eq!(reopened.width(), 50);
1607        assert_eq!(reopened.height(), 50);
1608    }
1609
1610    // ---- 组 6:Editor resize ----
1611
1612    #[tokio::test]
1613    async fn test_editor_resize_exact() {
1614        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1615        create_test_png(tmp.path(), 100, 100, Rgba([255, 0, 0, 255]));
1616        let editor = Editor::new();
1617        let mut img = editor.open(tmp.path()).await.unwrap();
1618        editor.resize_exact(&mut img, 50, 80);
1619        assert_eq!(img.width(), 50);
1620        assert_eq!(img.height(), 80);
1621    }
1622
1623    #[tokio::test]
1624    async fn test_editor_resize_fit() {
1625        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1626        create_test_png(tmp.path(), 200, 100, Rgba([0, 255, 0, 255]));
1627        let editor = Editor::new();
1628        let mut img = editor.open(tmp.path()).await.unwrap();
1629        // 200x100 → fit 100x100 → ratio=0.5 → 100x50
1630        editor.resize_fit(&mut img, 100, 100);
1631        assert_eq!(img.width(), 100);
1632        assert_eq!(img.height(), 50);
1633    }
1634
1635    #[tokio::test]
1636    async fn test_editor_resize_fill() {
1637        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1638        create_test_png(tmp.path(), 200, 100, Rgba([0, 0, 255, 255]));
1639        let editor = Editor::new();
1640        let mut img = editor.open(tmp.path()).await.unwrap();
1641        // 200x100 → fill 100x100 → ratio=1.0(按高)→ 200x100 → crop center 100x100
1642        editor.resize_fill(&mut img, 100, 100);
1643        assert_eq!(img.width(), 100);
1644        assert_eq!(img.height(), 100);
1645    }
1646
1647    #[tokio::test]
1648    async fn test_editor_resize_exact_width() {
1649        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1650        create_test_png(tmp.path(), 200, 100, Rgba([255, 255, 0, 255]));
1651        let editor = Editor::new();
1652        let mut img = editor.open(tmp.path()).await.unwrap();
1653        // 200x100 → width=50 → 50x25
1654        editor.resize_exact_width(&mut img, 50);
1655        assert_eq!(img.width(), 50);
1656        assert_eq!(img.height(), 25);
1657    }
1658
1659    #[tokio::test]
1660    async fn test_editor_resize_exact_height() {
1661        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1662        create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 255, 255]));
1663        let editor = Editor::new();
1664        let mut img = editor.open(tmp.path()).await.unwrap();
1665        // 200x100 → height=50 → 100x50
1666        editor.resize_exact_height(&mut img, 50);
1667        assert_eq!(img.width(), 100);
1668        assert_eq!(img.height(), 50);
1669    }
1670
1671    // ---- 组 7:Editor crop/flip/rotate ----
1672
1673    #[tokio::test]
1674    async fn test_editor_crop_center() {
1675        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1676        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
1677        let editor = Editor::new();
1678        let mut img = editor.open(tmp.path()).await.unwrap();
1679        editor
1680            .crop(&mut img, 50, 50, Position::Center, 0, 0)
1681            .unwrap();
1682        assert_eq!(img.width(), 50);
1683        assert_eq!(img.height(), 50);
1684    }
1685
1686    #[tokio::test]
1687    async fn test_editor_crop_too_large() {
1688        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1689        create_test_png(tmp.path(), 50, 50, Rgba([0, 0, 0, 255]));
1690        let editor = Editor::new();
1691        let mut img = editor.open(tmp.path()).await.unwrap();
1692        assert!(editor
1693            .crop(&mut img, 100, 100, Position::TopLeft, 0, 0)
1694            .is_err());
1695    }
1696
1697    #[tokio::test]
1698    async fn test_editor_flip_horizontal() {
1699        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1700        create_test_png(tmp.path(), 80, 60, Rgba([255, 128, 64, 255]));
1701        let editor = Editor::new();
1702        let mut img = editor.open(tmp.path()).await.unwrap();
1703        editor.flip(&mut img, FlipMode::Horizontal);
1704        assert_eq!(img.width(), 80);
1705        assert_eq!(img.height(), 60);
1706    }
1707
1708    #[tokio::test]
1709    async fn test_editor_flip_vertical() {
1710        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1711        create_test_png(tmp.path(), 80, 60, Rgba([255, 128, 64, 255]));
1712        let editor = Editor::new();
1713        let mut img = editor.open(tmp.path()).await.unwrap();
1714        editor.flip(&mut img, FlipMode::Vertical);
1715        assert_eq!(img.width(), 80);
1716        assert_eq!(img.height(), 60);
1717    }
1718
1719    #[tokio::test]
1720    async fn test_editor_rotate_90() {
1721        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1722        create_test_png(tmp.path(), 80, 60, Rgba([64, 255, 128, 255]));
1723        let editor = Editor::new();
1724        let mut img = editor.open(tmp.path()).await.unwrap();
1725        editor.rotate(&mut img, 90.0).unwrap();
1726        assert_eq!(img.width(), 60); // 旋转 90 度后宽高互换
1727        assert_eq!(img.height(), 80);
1728    }
1729
1730    #[tokio::test]
1731    async fn test_editor_rotate_180() {
1732        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1733        create_test_png(tmp.path(), 80, 60, Rgba([64, 128, 255, 255]));
1734        let editor = Editor::new();
1735        let mut img = editor.open(tmp.path()).await.unwrap();
1736        editor.rotate(&mut img, 180.0).unwrap();
1737        assert_eq!(img.width(), 80);
1738        assert_eq!(img.height(), 60);
1739    }
1740
1741    #[tokio::test]
1742    async fn test_editor_rotate_invalid_angle() {
1743        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1744        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
1745        let editor = Editor::new();
1746        let mut img = editor.open(tmp.path()).await.unwrap();
1747        assert!(editor.rotate(&mut img, 45.0).is_err());
1748    }
1749
1750    // ---- 组 8:Editor blend ----
1751
1752    #[tokio::test]
1753    async fn test_editor_blend_normal() {
1754        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1755        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1756        create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
1757        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1758        let editor = Editor::new();
1759        let mut img1 = editor.open(tmp1.path()).await.unwrap();
1760        let img2 = editor.open(tmp2.path()).await.unwrap();
1761        editor
1762            .blend(
1763                &mut img1,
1764                &img2,
1765                BlendType::Normal,
1766                1.0,
1767                Position::TopLeft,
1768                0,
1769                0,
1770            )
1771            .unwrap();
1772        assert_eq!(img1.width(), 100);
1773        assert_eq!(img1.height(), 100);
1774        // 左上角第一个像素应该是叠加图的颜色(白色,不透明)
1775        let rgba = img1.to_rgba8();
1776        let pixel = rgba.get_pixel(0, 0);
1777        assert_eq!(pixel[0], 255);
1778        assert_eq!(pixel[1], 255);
1779        assert_eq!(pixel[2], 255);
1780    }
1781
1782    #[tokio::test]
1783    async fn test_editor_blend_with_offset() {
1784        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1785        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1786        create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
1787        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1788        let editor = Editor::new();
1789        let mut img1 = editor.open(tmp1.path()).await.unwrap();
1790        let img2 = editor.open(tmp2.path()).await.unwrap();
1791        // 偏移到 (30, 30),叠加图 50x50,应影响 (30,30)-(80,80)
1792        editor
1793            .blend(
1794                &mut img1,
1795                &img2,
1796                BlendType::Normal,
1797                1.0,
1798                Position::TopLeft,
1799                30,
1800                30,
1801            )
1802            .unwrap();
1803        let rgba = img1.to_rgba8();
1804        // (0, 0) 应该是黑色(未被叠加)
1805        let p1 = rgba.get_pixel(0, 0);
1806        assert_eq!(p1[0], 0);
1807        // (50, 50) 应该是白色(在叠加区内)
1808        let p2 = rgba.get_pixel(50, 50);
1809        assert_eq!(p2[0], 255);
1810        // (90, 90) 应该是黑色(在叠加区外)
1811        let p3 = rgba.get_pixel(90, 90);
1812        assert_eq!(p3[0], 0);
1813    }
1814
1815    #[tokio::test]
1816    async fn test_editor_blend_opacity_half() {
1817        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1818        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1819        create_test_png(tmp1.path(), 50, 50, Rgba([0, 0, 0, 255]));
1820        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1821        let editor = Editor::new();
1822        let mut img1 = editor.open(tmp1.path()).await.unwrap();
1823        let img2 = editor.open(tmp2.path()).await.unwrap();
1824        // opacity=0.5:黑色 + 50%白色 ≈ 128
1825        editor
1826            .blend(
1827                &mut img1,
1828                &img2,
1829                BlendType::Normal,
1830                0.5,
1831                Position::TopLeft,
1832                0,
1833                0,
1834            )
1835            .unwrap();
1836        let rgba = img1.to_rgba8();
1837        let pixel = rgba.get_pixel(0, 0);
1838        // 半透明白色叠加到黑色:128 左右
1839        assert!(
1840            (120..=136).contains(&pixel[0]),
1841            "expected ~128, got {}",
1842            pixel[0]
1843        );
1844    }
1845
1846    #[test]
1847    fn test_blend_type_parse() {
1848        assert_eq!(BlendType::parse("normal").unwrap(), BlendType::Normal);
1849        assert_eq!(BlendType::parse("MULTIPLY").unwrap(), BlendType::Multiply);
1850        assert_eq!(BlendType::parse("overlay").unwrap(), BlendType::Overlay);
1851        assert_eq!(BlendType::parse("screen").unwrap(), BlendType::Screen);
1852        assert!(BlendType::parse("invalid").is_err());
1853    }
1854
1855    #[test]
1856    fn test_flip_mode_parse() {
1857        assert_eq!(FlipMode::parse("h").unwrap(), FlipMode::Horizontal);
1858        assert_eq!(FlipMode::parse("V").unwrap(), FlipMode::Vertical);
1859        assert!(FlipMode::parse("x").is_err());
1860    }
1861
1862    // ---- 组 9:Editor fill ----
1863
1864    #[test]
1865    fn test_editor_fill() {
1866        let img = Image::create_blank(50, 50);
1867        let editor = Editor::new();
1868        let mut img = img;
1869        editor.fill(&mut img, Color::rgb(255, 0, 0));
1870        let rgba = img.to_rgba8();
1871        let pixel = rgba.get_pixel(0, 0);
1872        assert_eq!(pixel[0], 255);
1873        assert_eq!(pixel[1], 0);
1874        assert_eq!(pixel[2], 0);
1875    }
1876
1877    // ---- 组 10:PHP 行为对齐 R5 ----
1878
1879    // R5-24:ImageType 5 种类型对齐 Grafika\ImageType
1880    #[test]
1881    fn test_r5_24_image_type_constants() {
1882        // 对齐 PHP ImageType 常量
1883        assert_eq!(ImageType::Unknown.as_str(), ""); // const UNKNOWN = ''
1884        assert_eq!(ImageType::Gif.as_str(), "GIF"); // const GIF = 'GIF'
1885        assert_eq!(ImageType::Jpeg.as_str(), "JPEG"); // const JPEG = 'JPEG'
1886        assert_eq!(ImageType::Png.as_str(), "PNG"); // const PNG = 'PNG'
1887        assert_eq!(ImageType::Wbmp.as_str(), "WBMP"); // const WBMP = 'WBMP'
1888    }
1889
1890    // R5-25:Color hex 解析对齐 Grafika\Color
1891    #[test]
1892    fn test_r5_25_color_hex_parsing() {
1893        // 对齐 PHP new Color('#333333')
1894        let c1 = Color::from_hex("#333333").unwrap();
1895        assert_eq!((c1.r, c1.g, c1.b), (0x33, 0x33, 0x33));
1896        // 对齐 PHP new Color('#ff4444')
1897        let c2 = Color::from_hex("#ff4444").unwrap();
1898        assert_eq!((c2.r, c2.g, c2.b), (0xff, 0x44, 0x44));
1899        // 对齐 PHP new Color('#f00')
1900        let c3 = Color::from_hex("#f00").unwrap();
1901        assert_eq!((c3.r, c3.g, c3.b), (0xff, 0x00, 0x00));
1902    }
1903
1904    // R5-26:Position 9 种位置 + get_xy 对齐 Grafika\Position::getXY
1905    #[test]
1906    fn test_r5_26_position_get_xy_all_nine() {
1907        let w1 = 100u32;
1908        let h1 = 100u32;
1909        let w2 = 20u32;
1910        let h2 = 20u32;
1911        // 验证 9 种位置的 get_xy 计算
1912        let cases = [
1913            (Position::TopLeft, 0, 0),
1914            (Position::TopCenter, 40, 0),
1915            (Position::TopRight, 80, 0),
1916            (Position::CenterLeft, 0, 40),
1917            (Position::Center, 40, 40),
1918            (Position::CenterRight, 80, 40),
1919            (Position::BottomLeft, 0, 80),
1920            (Position::BottomCenter, 40, 80),
1921            (Position::BottomRight, 80, 80),
1922        ];
1923        for (pos, ex, ey) in cases {
1924            let (x, y) = pos.get_xy(w1, h1, w2, h2);
1925            assert_eq!(x, ex, "Position {:?} x mismatch", pos);
1926            assert_eq!(y, ey, "Position {:?} y mismatch", pos);
1927        }
1928    }
1929
1930    // R5-27:Image::open 按 getimagesize 探测类型
1931    #[tokio::test]
1932    async fn test_r5_27_image_open_detects_type() {
1933        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1934        create_test_png(tmp.path(), 80, 60, Rgba([255, 255, 255, 255]));
1935        let img = Image::open(tmp.path()).await.unwrap();
1936        assert_eq!(img.image_type(), ImageType::Png);
1937        assert_eq!(img.width(), 80);
1938        assert_eq!(img.height(), 60);
1939    }
1940
1941    // R5-28:Editor::resize_exact 强制目标尺寸
1942    #[tokio::test]
1943    async fn test_r5_28_resize_exact_forces_dimensions() {
1944        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1945        create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 0, 255]));
1946        let editor = Editor::new();
1947        let mut img = editor.open(tmp.path()).await.unwrap();
1948        // 200x100 → 50x50(强制忽略宽高比)
1949        editor.resize_exact(&mut img, 50, 50);
1950        assert_eq!(img.width(), 50);
1951        assert_eq!(img.height(), 50);
1952    }
1953
1954    // R5-29:Editor::blend normal + opacity + offset
1955    #[tokio::test]
1956    async fn test_r5_29_blend_normal_with_offset_and_opacity() {
1957        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1958        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1959        create_test_png(tmp1.path(), 200, 200, Rgba([0, 0, 0, 255]));
1960        create_test_png(tmp2.path(), 100, 100, Rgba([255, 255, 255, 255]));
1961        let editor = Editor::new();
1962        let mut img1 = editor.open(tmp1.path()).await.unwrap();
1963        let img2 = editor.open(tmp2.path()).await.unwrap();
1964        // 对齐 PHP $editor->blend($bg, $fg, 'normal', 1.0, 'top-left', 30, 30)
1965        editor
1966            .blend(
1967                &mut img1,
1968                &img2,
1969                BlendType::Normal,
1970                1.0,
1971                Position::TopLeft,
1972                30,
1973                30,
1974            )
1975            .unwrap();
1976        // 验证叠加区
1977        let rgba = img1.to_rgba8();
1978        // (0, 0) 未叠加 → 黑
1979        assert_eq!(rgba.get_pixel(0, 0)[0], 0);
1980        // (50, 50) 在叠加区 → 白
1981        assert_eq!(rgba.get_pixel(50, 50)[0], 255);
1982        // (129, 129) 在叠加区边界内 → 白(叠加区为 [30, 130) x [30, 130))
1983        assert_eq!(rgba.get_pixel(129, 129)[0], 255);
1984        // (130, 130) 在叠加区外 → 黑
1985        assert_eq!(rgba.get_pixel(130, 130)[0], 0);
1986        // (150, 150) 在叠加区外 → 黑
1987        assert_eq!(rgba.get_pixel(150, 150)[0], 0);
1988    }
1989
1990    // R5-30:Editor::text y 坐标基线偏移
1991    #[tokio::test]
1992    async fn test_r5_30_text_y_baseline_offset() {
1993        // 无法精确测试像素布局(依赖字体),但验证 y - size 偏移逻辑:
1994        // PHP: imagettftext y 是基线,Grafika: y += size(y 变为顶部)
1995        // Rust: y 是顶部,所以 Rust y = PHP y - size
1996        // 验证:text() 不 panic 即可(字体文件不一定可用,用闭包模拟)
1997        let mut img = Image::create_blank(200, 100);
1998        let editor = Editor::new();
1999        // 无字体路径 → 期望 FontLoadFailed 错误
2000        let result = editor
2001            .text(&mut img, "test", 30, 10, 50, Color::rgb(0, 0, 0), None)
2002            .await;
2003        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2004    }
2005
2006    // R5-31:Editor::save 按扩展名猜类型 + JPEG 默认 quality=75
2007    #[tokio::test]
2008    async fn test_r5_31_save_infers_type_from_extension() {
2009        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2010        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 128, 64, 255]));
2011
2012        // 保存为 .png → 应识别为 PNG
2013        let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2014        let editor = Editor::new();
2015        let img = editor.open(tmp_in.path()).await.unwrap();
2016        editor
2017            .save(&img, tmp_png.path(), None, None, false, 0o755)
2018            .await
2019            .unwrap();
2020        assert!(tmp_png.path().exists());
2021
2022        // 保存为 .jpg → 应识别为 JPEG
2023        let tmp_jpg = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2024        editor
2025            .save(&img, tmp_jpg.path(), None, None, false, 0o755)
2026            .await
2027            .unwrap();
2028        assert!(tmp_jpg.path().exists());
2029    }
2030
2031    // R5-32:wrap_text 对齐 PHP wrapText(无字体文件时返回错误,但函数签名对齐)
2032    #[tokio::test]
2033    async fn test_r5_32_wrap_text_signature_alignment() {
2034        // 对齐 PHP wrapText($fontsize, $angle, $fontface, $string, $width, $max_line)
2035        // Rust: wrap_text(font_path, fontsize, string, width, max_line)
2036        // 验证无字体文件时返回 FontLoadFailed
2037        let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, Some(2)).await;
2038        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2039    }
2040
2041    // R5-32 补充:wrap_text_with_font 逻辑验证
2042    #[test]
2043    fn test_r5_32_wrap_text_with_font_logic() {
2044        // 用内置测量逻辑验证 wrap_text 的换行行为
2045        // 加载一个简单的字体(如果可用);否则跳过
2046        let font_path = Path::new(
2047            "e:/vue/test/鲜视达/server/vendor/kosinix/grafika/src/Grafika/fonts/st-heiti-light.ttc",
2048        );
2049        if !font_path.exists() {
2050            // 字体文件不可用,跳过本测试
2051            eprintln!(
2052                "Skipping test_r5_32_wrap_text_with_font_logic: font not found at {font_path:?}"
2053            );
2054            return;
2055        }
2056        let data = std::fs::read(font_path).unwrap();
2057        let font = FontVec::try_from_vec(data).unwrap();
2058        // 短文本不换行
2059        let result = wrap_text_with_font(&font, 30, "hello", 680, Some(2));
2060        assert_eq!(result, "hello");
2061        // 长文本 + max_line=2 → 应该有省略号
2062        let long_text = "这是一个非常长的商品名称用于测试自动换行功能应该被截断并添加省略号";
2063        let result = wrap_text_with_font(&font, 30, long_text, 100, Some(2));
2064        assert!(
2065            result.ends_with("..."),
2066            "result should end with ..., got: {result}"
2067        );
2068        assert!(
2069            result.contains('\n'),
2070            "result should contain newline, got: {result}"
2071        );
2072    }
2073
2074    // ---- 组 11:measure_text ----
2075
2076    #[tokio::test]
2077    async fn test_measure_text_nonexistent_font() {
2078        let result = measure_text(Path::new("/nonexistent.ttf"), 30, "hello").await;
2079        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2080    }
2081
2082    #[test]
2083    fn test_text_metrics_debug() {
2084        let m = TextMetrics {
2085            width: 100,
2086            height: 30,
2087            ascent: 25,
2088            descent: -5,
2089        };
2090        assert_eq!(m.width, 100);
2091        assert_eq!(m.height, 30);
2092    }
2093
2094    // ---- 组 12:Color 补充 ----
2095
2096    #[test]
2097    fn test_color_from_hex_rgba_short() {
2098        let c = Color::from_hex("#f80f").unwrap();
2099        assert_eq!(c.r, 255);
2100        assert_eq!(c.g, 136);
2101        assert_eq!(c.b, 0);
2102        assert_eq!(c.a, 255);
2103    }
2104
2105    #[test]
2106    fn test_color_from_hex_with_whitespace() {
2107        let c = Color::from_hex("  #ff8000  ").unwrap();
2108        assert_eq!(c.r, 255);
2109        assert_eq!(c.g, 128);
2110        assert_eq!(c.b, 0);
2111    }
2112
2113    #[test]
2114    fn test_color_from_hex_empty() {
2115        assert!(Color::from_hex("#").is_err());
2116        assert!(Color::from_hex("").is_err());
2117    }
2118
2119    #[test]
2120    fn test_color_from_hex_invalid_chars() {
2121        assert!(Color::from_hex("#gggggg").is_err());
2122        assert!(Color::from_hex("#zz").is_err());
2123    }
2124
2125    #[test]
2126    fn test_color_copy_and_eq() {
2127        let c1 = Color::rgb(1, 2, 3);
2128        let c2 = c1;
2129        assert_eq!(c1, c2);
2130    }
2131
2132    // ---- 组 13:ImageType 补充 ----
2133
2134    #[test]
2135    fn test_image_type_from_image_format_other() {
2136        use image::ImageFormat;
2137        assert_eq!(
2138            ImageType::from_image_format(ImageFormat::Bmp),
2139            ImageType::Unknown
2140        );
2141        assert_eq!(
2142            ImageType::from_image_format(ImageFormat::Tiff),
2143            ImageType::Unknown
2144        );
2145    }
2146
2147    #[test]
2148    fn test_image_type_from_extension_empty() {
2149        assert_eq!(ImageType::from_extension(""), ImageType::Unknown);
2150    }
2151
2152    // ---- 组 14:Position 补充 ----
2153
2154    #[test]
2155    fn test_position_parse_all_nine() {
2156        assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
2157        assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
2158        assert_eq!(Position::parse("top-right").unwrap(), Position::TopRight);
2159        assert_eq!(
2160            Position::parse("center-left").unwrap(),
2161            Position::CenterLeft
2162        );
2163        assert_eq!(Position::parse("center").unwrap(), Position::Center);
2164        assert_eq!(
2165            Position::parse("center-right").unwrap(),
2166            Position::CenterRight
2167        );
2168        assert_eq!(
2169            Position::parse("bottom-left").unwrap(),
2170            Position::BottomLeft
2171        );
2172        assert_eq!(
2173            Position::parse("bottom-center").unwrap(),
2174            Position::BottomCenter
2175        );
2176        assert_eq!(
2177            Position::parse("bottom-right").unwrap(),
2178            Position::BottomRight
2179        );
2180    }
2181
2182    #[test]
2183    fn test_position_as_str_all_nine() {
2184        assert_eq!(Position::TopLeft.as_str(), "top-left");
2185        assert_eq!(Position::TopCenter.as_str(), "top-center");
2186        assert_eq!(Position::TopRight.as_str(), "top-right");
2187        assert_eq!(Position::CenterLeft.as_str(), "center-left");
2188        assert_eq!(Position::Center.as_str(), "center");
2189        assert_eq!(Position::CenterRight.as_str(), "center-right");
2190        assert_eq!(Position::BottomLeft.as_str(), "bottom-left");
2191        assert_eq!(Position::BottomCenter.as_str(), "bottom-center");
2192        assert_eq!(Position::BottomRight.as_str(), "bottom-right");
2193    }
2194
2195    #[test]
2196    fn test_position_get_xy_unequal_dimensions() {
2197        let (x, y) = Position::Center.get_xy(200, 100, 40, 30);
2198        assert_eq!(x, 80);
2199        assert_eq!(y, 35);
2200    }
2201
2202    // ---- 组 15:Image 补充 ----
2203
2204    #[test]
2205    fn test_image_from_rgba8() {
2206        let buf: RgbaImage = ImageBuffer::from_pixel(40, 30, Rgba([10, 20, 30, 255]));
2207        let img = Image::from_rgba8(buf, ImageType::Png);
2208        assert_eq!(img.width(), 40);
2209        assert_eq!(img.height(), 30);
2210        assert_eq!(img.image_type(), ImageType::Png);
2211        assert!(img.file_path().is_none());
2212    }
2213
2214    #[test]
2215    fn test_image_as_dynamic() {
2216        let img = Image::create_blank(50, 50);
2217        let dyn_ref = img.as_dynamic();
2218        assert_eq!(dyn_ref.width(), 50);
2219        assert_eq!(dyn_ref.height(), 50);
2220    }
2221
2222    #[test]
2223    fn test_image_as_dynamic_mut() {
2224        let mut img = Image::create_blank(50, 50);
2225        let dyn_mut = img.as_dynamic_mut();
2226        assert_eq!(dyn_mut.width(), 50);
2227        assert_eq!(dyn_mut.height(), 50);
2228    }
2229
2230    #[tokio::test]
2231    async fn test_image_open_nonexistent() {
2232        let result = Image::open(Path::new("/nonexistent/file.png")).await;
2233        assert!(result.is_err());
2234    }
2235
2236    #[tokio::test]
2237    async fn test_image_open_unknown_extension_fails() {
2238        // image crate uses extension for format detection; .bin is not recognized.
2239        // Create PNG with proper extension first, then rename to .bin.
2240        let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2241        create_test_png(tmp_png.path(), 60, 40, Rgba([255, 0, 0, 255]));
2242        let bin_path = tmp_png.path().with_extension("bin");
2243        std::fs::rename(tmp_png.path(), &bin_path).unwrap();
2244        let result = Image::open(&bin_path).await;
2245        assert!(result.is_err());
2246    }
2247
2248    // ---- 组 16:Editor 补充 ----
2249
2250    #[test]
2251    fn test_editor_default() {
2252        let _editor = Editor;
2253    }
2254
2255    #[tokio::test]
2256    async fn test_editor_rotate_0() {
2257        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2258        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2259        let editor = Editor::new();
2260        let mut img = editor.open(tmp.path()).await.unwrap();
2261        editor.rotate(&mut img, 0.0).unwrap();
2262        assert_eq!(img.width(), 80);
2263        assert_eq!(img.height(), 60);
2264    }
2265
2266    #[tokio::test]
2267    async fn test_editor_rotate_270() {
2268        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2269        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2270        let editor = Editor::new();
2271        let mut img = editor.open(tmp.path()).await.unwrap();
2272        editor.rotate(&mut img, 270.0).unwrap();
2273        assert_eq!(img.width(), 60);
2274        assert_eq!(img.height(), 80);
2275    }
2276
2277    #[tokio::test]
2278    async fn test_editor_rotate_negative_90() {
2279        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2280        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2281        let editor = Editor::new();
2282        let mut img = editor.open(tmp.path()).await.unwrap();
2283        editor.rotate(&mut img, -90.0).unwrap();
2284        assert_eq!(img.width(), 60);
2285        assert_eq!(img.height(), 80);
2286    }
2287
2288    #[tokio::test]
2289    async fn test_editor_rotate_negative_180() {
2290        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2291        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2292        let editor = Editor::new();
2293        let mut img = editor.open(tmp.path()).await.unwrap();
2294        editor.rotate(&mut img, -180.0).unwrap();
2295        assert_eq!(img.width(), 80);
2296        assert_eq!(img.height(), 60);
2297    }
2298
2299    #[tokio::test]
2300    async fn test_editor_rotate_360_normalized_to_0() {
2301        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2302        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2303        let editor = Editor::new();
2304        let mut img = editor.open(tmp.path()).await.unwrap();
2305        editor.rotate(&mut img, 360.0).unwrap();
2306        assert_eq!(img.width(), 80);
2307        assert_eq!(img.height(), 60);
2308    }
2309
2310    #[tokio::test]
2311    async fn test_editor_crop_with_positive_offset() {
2312        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2313        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2314        let editor = Editor::new();
2315        let mut img = editor.open(tmp.path()).await.unwrap();
2316        editor
2317            .crop(&mut img, 50, 50, Position::Center, 10, 10)
2318            .unwrap();
2319        assert_eq!(img.width(), 50);
2320        assert_eq!(img.height(), 50);
2321    }
2322
2323    #[tokio::test]
2324    async fn test_editor_crop_with_negative_offset_clamped() {
2325        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2326        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2327        let editor = Editor::new();
2328        let mut img = editor.open(tmp.path()).await.unwrap();
2329        editor
2330            .crop(&mut img, 50, 50, Position::TopLeft, -100, -100)
2331            .unwrap();
2332        assert_eq!(img.width(), 50);
2333        assert_eq!(img.height(), 50);
2334    }
2335
2336    #[tokio::test]
2337    async fn test_editor_crop_top_left() {
2338        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2339        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2340        let editor = Editor::new();
2341        let mut img = editor.open(tmp.path()).await.unwrap();
2342        editor
2343            .crop(&mut img, 30, 30, Position::TopLeft, 0, 0)
2344            .unwrap();
2345        assert_eq!(img.width(), 30);
2346        assert_eq!(img.height(), 30);
2347    }
2348
2349    #[tokio::test]
2350    async fn test_editor_crop_bottom_right() {
2351        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2352        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2353        let editor = Editor::new();
2354        let mut img = editor.open(tmp.path()).await.unwrap();
2355        editor
2356            .crop(&mut img, 30, 30, Position::BottomRight, 0, 0)
2357            .unwrap();
2358        assert_eq!(img.width(), 30);
2359        assert_eq!(img.height(), 30);
2360    }
2361
2362    // ---- 组 17:Editor blend 补充 ----
2363
2364    #[test]
2365    fn test_editor_blend_multiply() {
2366        let base = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2367        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2368        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2369        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2370        let editor = Editor::new();
2371        editor
2372            .blend(
2373                &mut img1,
2374                &img2,
2375                BlendType::Multiply,
2376                1.0,
2377                Position::TopLeft,
2378                0,
2379                0,
2380            )
2381            .unwrap();
2382        let rgba = img1.to_rgba8();
2383        let pixel = rgba.get_pixel(0, 0);
2384        assert!(
2385            (60..=68).contains(&pixel[0]),
2386            "expected ~64, got {}",
2387            pixel[0]
2388        );
2389    }
2390
2391    #[test]
2392    fn test_editor_blend_overlay_dark() {
2393        let base = ImageBuffer::from_pixel(50, 50, Rgba([64, 64, 64, 255]));
2394        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2395        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2396        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2397        let editor = Editor::new();
2398        editor
2399            .blend(
2400                &mut img1,
2401                &img2,
2402                BlendType::Overlay,
2403                1.0,
2404                Position::TopLeft,
2405                0,
2406                0,
2407            )
2408            .unwrap();
2409        let rgba = img1.to_rgba8();
2410        let pixel = rgba.get_pixel(0, 0);
2411        assert!(
2412            (60..=68).contains(&pixel[0]),
2413            "expected ~64, got {}",
2414            pixel[0]
2415        );
2416    }
2417
2418    #[test]
2419    fn test_editor_blend_overlay_light() {
2420        let base = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
2421        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2422        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
2423        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2424        let editor = Editor::new();
2425        editor
2426            .blend(
2427                &mut img1,
2428                &img2,
2429                BlendType::Overlay,
2430                1.0,
2431                Position::TopLeft,
2432                0,
2433                0,
2434            )
2435            .unwrap();
2436        let rgba = img1.to_rgba8();
2437        let pixel = rgba.get_pixel(0, 0);
2438        assert!(
2439            (225..=235).contains(&pixel[0]),
2440            "expected ~231, got {}",
2441            pixel[0]
2442        );
2443    }
2444
2445    #[test]
2446    fn test_editor_blend_screen() {
2447        let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2448        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2449        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2450        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2451        let editor = Editor::new();
2452        editor
2453            .blend(
2454                &mut img1,
2455                &img2,
2456                BlendType::Screen,
2457                1.0,
2458                Position::TopLeft,
2459                0,
2460                0,
2461            )
2462            .unwrap();
2463        let rgba = img1.to_rgba8();
2464        let pixel = rgba.get_pixel(0, 0);
2465        assert_eq!(pixel[0], 0);
2466    }
2467
2468    #[test]
2469    fn test_editor_blend_with_negative_offset() {
2470        let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2471        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2472        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 255, 255, 255]));
2473        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2474        let editor = Editor::new();
2475        editor
2476            .blend(
2477                &mut img1,
2478                &img2,
2479                BlendType::Normal,
2480                1.0,
2481                Position::TopLeft,
2482                -25,
2483                -25,
2484            )
2485            .unwrap();
2486        let rgba = img1.to_rgba8();
2487        assert_eq!(rgba.get_pixel(0, 0)[0], 255);
2488        assert_eq!(rgba.get_pixel(24, 24)[0], 255);
2489        assert_eq!(rgba.get_pixel(25, 25)[0], 0);
2490    }
2491
2492    #[test]
2493    fn test_editor_blend_transparent_overlay() {
2494        let base = ImageBuffer::from_pixel(50, 50, Rgba([100, 100, 100, 255]));
2495        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2496        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 0, 0, 0]));
2497        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2498        let editor = Editor::new();
2499        editor
2500            .blend(
2501                &mut img1,
2502                &img2,
2503                BlendType::Normal,
2504                1.0,
2505                Position::TopLeft,
2506                0,
2507                0,
2508            )
2509            .unwrap();
2510        let rgba = img1.to_rgba8();
2511        let pixel = rgba.get_pixel(0, 0);
2512        assert_eq!(pixel[0], 100);
2513    }
2514
2515    // ---- 组 18:Editor save 补充 ----
2516
2517    #[tokio::test]
2518    async fn test_editor_save_gif() {
2519        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2520        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2521        let tmp_out = tempfile::Builder::new().suffix(".gif").tempfile().unwrap();
2522        let editor = Editor::new();
2523        let img = editor.open(tmp_in.path()).await.unwrap();
2524        editor
2525            .save(&img, tmp_out.path(), None, None, false, 0o755)
2526            .await
2527            .unwrap();
2528        assert!(tmp_out.path().exists());
2529        let reopened = image::open(tmp_out.path()).unwrap();
2530        assert_eq!(reopened.width(), 30);
2531    }
2532
2533    #[tokio::test]
2534    async fn test_editor_save_wbmp_error() {
2535        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2536        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2537        let tmp_out = tempfile::Builder::new().suffix(".wbmp").tempfile().unwrap();
2538        let editor = Editor::new();
2539        let img = editor.open(tmp_in.path()).await.unwrap();
2540        let result = editor
2541            .save(&img, tmp_out.path(), None, None, false, 0o755)
2542            .await;
2543        assert!(result.is_err());
2544    }
2545
2546    #[tokio::test]
2547    async fn test_editor_save_unknown_type_error() {
2548        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2549        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2550        let tmp_out = tempfile::Builder::new().suffix(".bin").tempfile().unwrap();
2551        let editor = Editor::new();
2552        let img = editor.open(tmp_in.path()).await.unwrap();
2553        let result = editor
2554            .save(&img, tmp_out.path(), None, None, false, 0o755)
2555            .await;
2556        assert!(result.is_err());
2557    }
2558
2559    #[tokio::test]
2560    async fn test_editor_save_with_explicit_png_type() {
2561        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2562        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2563        let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2564        let editor = Editor::new();
2565        let img = editor.open(tmp_in.path()).await.unwrap();
2566        editor
2567            .save(
2568                &img,
2569                tmp_out.path(),
2570                Some(ImageType::Png),
2571                None,
2572                false,
2573                0o755,
2574            )
2575            .await
2576            .unwrap();
2577        assert!(tmp_out.path().exists());
2578    }
2579
2580    #[tokio::test]
2581    async fn test_editor_save_jpeg_explicit_type() {
2582        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2583        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
2584        let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2585        let editor = Editor::new();
2586        let img = editor.open(tmp_in.path()).await.unwrap();
2587        editor
2588            .save(
2589                &img,
2590                tmp_out.path(),
2591                Some(ImageType::Jpeg),
2592                Some(80),
2593                false,
2594                0o755,
2595            )
2596            .await
2597            .unwrap();
2598        assert!(tmp_out.path().exists());
2599    }
2600
2601    #[tokio::test]
2602    async fn test_editor_save_quality_clamping_high() {
2603        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2604        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
2605        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2606        let editor = Editor::new();
2607        let img = editor.open(tmp_in.path()).await.unwrap();
2608        editor
2609            .save(&img, tmp_out.path(), None, Some(200), false, 0o755)
2610            .await
2611            .unwrap();
2612        assert!(tmp_out.path().exists());
2613    }
2614
2615    #[tokio::test]
2616    async fn test_editor_save_quality_clamping_zero() {
2617        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2618        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
2619        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2620        let editor = Editor::new();
2621        let img = editor.open(tmp_in.path()).await.unwrap();
2622        editor
2623            .save(&img, tmp_out.path(), None, Some(0), false, 0o755)
2624            .await
2625            .unwrap();
2626        assert!(tmp_out.path().exists());
2627    }
2628
2629    #[tokio::test]
2630    async fn test_editor_save_creates_parent_dir() {
2631        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2632        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2633        let tmp_dir = tempfile::tempdir().unwrap();
2634        let output_path = tmp_dir.path().join("subdir").join("output.png");
2635        assert!(!output_path.parent().unwrap().exists());
2636        let editor = Editor::new();
2637        let img = editor.open(tmp_in.path()).await.unwrap();
2638        editor
2639            .save(&img, &output_path, None, None, false, 0o755)
2640            .await
2641            .unwrap();
2642        assert!(output_path.exists());
2643    }
2644
2645    // ---- 组 19:load_font / measure_text / wrap_text 补充 ----
2646
2647    #[tokio::test]
2648    async fn test_load_font_invalid_data() {
2649        let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
2650        std::fs::write(tmp.path(), b"this is not a font").unwrap();
2651        let mut img = Image::create_blank(100, 50);
2652        let editor = Editor::new();
2653        let result = editor
2654            .text(
2655                &mut img,
2656                "test",
2657                20,
2658                10,
2659                30,
2660                Color::rgb(0, 0, 0),
2661                Some(tmp.path()),
2662            )
2663            .await;
2664        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2665    }
2666
2667    #[tokio::test]
2668    async fn test_measure_text_invalid_font() {
2669        let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
2670        std::fs::write(tmp.path(), b"invalid font data").unwrap();
2671        let result = measure_text(tmp.path(), 30, "hello").await;
2672        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2673    }
2674
2675    #[tokio::test]
2676    async fn test_wrap_text_nonexistent_font() {
2677        let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, None).await;
2678        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2679    }
2680
2681    #[tokio::test]
2682    async fn test_editor_text_with_font() {
2683        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2684        if !font_path.exists() {
2685            eprintln!("Skipping test_editor_text_with_font: font not found");
2686            return;
2687        }
2688        let mut img = Image::create_blank(200, 100);
2689        let editor = Editor::new();
2690        let result = editor
2691            .text(
2692                &mut img,
2693                "hello",
2694                30,
2695                10,
2696                50,
2697                Color::rgb(255, 0, 0),
2698                Some(font_path),
2699            )
2700            .await;
2701        assert!(result.is_ok());
2702        assert_eq!(img.width(), 200);
2703        assert_eq!(img.height(), 100);
2704    }
2705
2706    #[tokio::test]
2707    async fn test_measure_text_with_font() {
2708        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2709        if !font_path.exists() {
2710            eprintln!("Skipping test_measure_text_with_font: font not found");
2711            return;
2712        }
2713        let result = measure_text(font_path, 30, "hello").await.unwrap();
2714        assert!(result.width > 0);
2715        assert!(result.height > 0);
2716    }
2717
2718    #[test]
2719    fn test_wrap_text_with_font_no_max_line() {
2720        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2721        if !font_path.exists() {
2722            eprintln!("Skipping test_wrap_text_with_font_no_max_line: font not found");
2723            return;
2724        }
2725        let data = std::fs::read(font_path).unwrap();
2726        let font = FontVec::try_from_vec(data).unwrap();
2727        let long_text = "this is a very long text that should wrap";
2728        let result = wrap_text_with_font(&font, 30, long_text, 100, None);
2729        assert!(result.contains('\n'), "should contain newline: {result}");
2730        assert!(
2731            !result.ends_with("..."),
2732            "should not end with ... when no max_line"
2733        );
2734    }
2735}