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 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 fn open<P: AsRef<Path>>(&self, path: P) -> Result<Image, ImageError> {
542        Image::open(path)
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 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)?;
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 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                std::fs::create_dir_all(parent)?;
821                #[cfg(unix)]
822                {
823                    use std::os::unix::fs::PermissionsExt;
824                    let _ = std::fs::set_permissions(
825                        parent,
826                        std::fs::Permissions::from_mode(permission),
827                    );
828                }
829            }
830        }
831
832        // 3. 按类型保存
833        match save_type {
834            ImageType::Png => {
835                image.as_dynamic().save(file)?;
836            }
837            ImageType::Jpeg => {
838                // JPEG 默认 quality=75(对齐 PHP)
839                let q = quality.unwrap_or(75);
840                let q = q.clamp(1, 100);
841                let rgba = image.to_rgba8();
842                let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
843                let mut file = std::fs::File::create(file)?;
844                let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, q);
845                encoder.encode_image(&image::DynamicImage::ImageRgb8(rgb))?;
846            }
847            ImageType::Gif => {
848                image.as_dynamic().save(file)?;
849            }
850            ImageType::Wbmp => {
851                return Err(ImageError::UnsupportedType(
852                    "WBMP encoding not supported by image crate".to_string(),
853                ));
854            }
855            ImageType::Unknown => {
856                return Err(ImageError::UnsupportedType(format!(
857                    "Cannot determine save type for file: {file:?}"
858                )));
859            }
860        }
861        Ok(())
862    }
863}
864
865impl Default for Editor {
866    fn default() -> Self {
867        Self::new()
868    }
869}
870
871// ============================================================================
872// BlendType 枚举 — 对齐 Grafika blend $type 参数
873// ============================================================================
874
875/// 混合模式 — 对齐 PHP `Editor::blend` 的 `$type` 参数
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877pub enum BlendType {
878    /// 普通模式(对齐 `'normal'`)— 项目业务唯一使用
879    Normal,
880    /// 正片叠底(对齐 `'multiply'`)
881    Multiply,
882    /// 叠加(对齐 `'overlay'`)
883    Overlay,
884    /// 滤色(对齐 `'screen'`)
885    Screen,
886}
887
888impl BlendType {
889    /// 从字符串解析 — 对齐 Grafika `$type` 字符串
890    pub fn parse(s: &str) -> Result<Self, ImageError> {
891        match s.to_lowercase().as_str() {
892            "normal" => Ok(Self::Normal),
893            "multiply" => Ok(Self::Multiply),
894            "overlay" => Ok(Self::Overlay),
895            "screen" => Ok(Self::Screen),
896            _ => Err(ImageError::InvalidArgument(format!(
897                "Unknown blend type: {s}"
898            ))),
899        }
900    }
901}
902
903// ============================================================================
904// FlipMode 枚举 — 对齐 Grafika flip $mode 参数
905// ============================================================================
906
907/// 翻转模式 — 对齐 PHP `Editor::flip` 的 `$mode` 参数
908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
909pub enum FlipMode {
910    /// 水平翻转(对齐 `'h'`)
911    Horizontal,
912    /// 垂直翻转(对齐 `'v'`)
913    Vertical,
914}
915
916impl FlipMode {
917    /// 从字符串解析 — 对齐 Grafika `$mode` 字符串
918    pub fn parse(s: &str) -> Result<Self, ImageError> {
919        match s.to_lowercase().as_str() {
920            "h" => Ok(Self::Horizontal),
921            "v" => Ok(Self::Vertical),
922            _ => Err(ImageError::InvalidArgument(format!(
923                "Unknown flip mode: {s}"
924            ))),
925        }
926    }
927}
928
929// ============================================================================
930// Normal 混合实现 — 对齐 GD imagecopy + alpha
931// ============================================================================
932
933/// Normal 混合 — 对齐 Grafika `_blendNormal`
934///
935/// 算法:`dest = src * opacity + dest * (1 - opacity)`(按 alpha 通道加权)
936fn blend_normal(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
937    let (w1, h1) = base.dimensions();
938    let (w2, h2) = overlay.dimensions();
939    let opacity = opacity.clamp(0.0, 1.0);
940
941    for oy in 0..h2 {
942        for ox in 0..w2 {
943            let bx = x + ox as i32;
944            let by = y + oy as i32;
945            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
946                continue;
947            }
948            let src = overlay.get_pixel(ox, oy);
949            let dst = base.get_pixel(bx as u32, by as u32);
950            // 源像素有效 alpha + opacity
951            let src_alpha = (src[3] as f32 / 255.0) * opacity;
952            if src_alpha < 1e-6 {
953                continue;
954            }
955            let dst_alpha = dst[3] as f32 / 255.0;
956            // 输出 alpha = src_a + dst_a * (1 - src_a)
957            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
958            if out_alpha < 1e-6 {
959                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
960                continue;
961            }
962            // 输出 RGB = (src_rgb * src_a + dst_rgb * dst_a * (1 - src_a)) / out_a
963            let out_r = ((src[0] as f32 * src_alpha
964                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
965                / out_alpha) as u8;
966            let out_g = ((src[1] as f32 * src_alpha
967                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
968                / out_alpha) as u8;
969            let out_b = ((src[2] as f32 * src_alpha
970                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
971                / out_alpha) as u8;
972            let out_a = (out_alpha * 255.0) as u8;
973            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
974        }
975    }
976}
977
978/// Multiply 混合 — 对齐 Grafika `_blendMultiply`
979fn blend_multiply(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
980    let (w1, h1) = base.dimensions();
981    let (w2, h2) = overlay.dimensions();
982    let opacity = opacity.clamp(0.0, 1.0);
983
984    for oy in 0..h2 {
985        for ox in 0..w2 {
986            let bx = x + ox as i32;
987            let by = y + oy as i32;
988            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
989                continue;
990            }
991            let src = overlay.get_pixel(ox, oy);
992            let dst = base.get_pixel(bx as u32, by as u32);
993            let src_alpha = (src[3] as f32 / 255.0) * opacity;
994            if src_alpha < 1e-6 {
995                continue;
996            }
997            // multiply: out = src * dst / 255
998            let mult_r = (src[0] as u16 * dst[0] as u16 / 255) as u8;
999            let mult_g = (src[1] as u16 * dst[1] as u16 / 255) as u8;
1000            let mult_b = (src[2] as u16 * dst[2] as u16 / 255) as u8;
1001            let dst_alpha = dst[3] as f32 / 255.0;
1002            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1003            if out_alpha < 1e-6 {
1004                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1005                continue;
1006            }
1007            let out_r = ((mult_r as f32 * src_alpha
1008                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1009                / out_alpha) as u8;
1010            let out_g = ((mult_g as f32 * src_alpha
1011                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1012                / out_alpha) as u8;
1013            let out_b = ((mult_b as f32 * src_alpha
1014                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1015                / out_alpha) as u8;
1016            let out_a = (out_alpha * 255.0) as u8;
1017            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1018        }
1019    }
1020}
1021
1022/// Overlay 混合 — 对齐 Grafika `_blendOverlay`
1023fn blend_overlay(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
1024    let (w1, h1) = base.dimensions();
1025    let (w2, h2) = overlay.dimensions();
1026    let opacity = opacity.clamp(0.0, 1.0);
1027
1028    for oy in 0..h2 {
1029        for ox in 0..w2 {
1030            let bx = x + ox as i32;
1031            let by = y + oy as i32;
1032            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
1033                continue;
1034            }
1035            let src = overlay.get_pixel(ox, oy);
1036            let dst = base.get_pixel(bx as u32, by as u32);
1037            let src_alpha = (src[3] as f32 / 255.0) * opacity;
1038            if src_alpha < 1e-6 {
1039                continue;
1040            }
1041            // overlay: if dst <= 128: out = 2 * src * dst / 255; else: out = 255 - 2 * (255 - src) * (255 - dst) / 255
1042            let overlay_channel = |s: u8, d: u8| -> u8 {
1043                if d <= 128 {
1044                    (2 * s as u16 * d as u16 / 255) as u8
1045                } else {
1046                    (255 - (2 * (255 - s) as u16 * (255 - d) as u16 / 255)) as u8
1047                }
1048            };
1049            let ov_r = overlay_channel(src[0], dst[0]);
1050            let ov_g = overlay_channel(src[1], dst[1]);
1051            let ov_b = overlay_channel(src[2], dst[2]);
1052            let dst_alpha = dst[3] as f32 / 255.0;
1053            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1054            if out_alpha < 1e-6 {
1055                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1056                continue;
1057            }
1058            let out_r = ((ov_r as f32 * src_alpha + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1059                / out_alpha) as u8;
1060            let out_g = ((ov_g as f32 * src_alpha + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1061                / out_alpha) as u8;
1062            let out_b = ((ov_b as f32 * src_alpha + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1063                / out_alpha) as u8;
1064            let out_a = (out_alpha * 255.0) as u8;
1065            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1066        }
1067    }
1068}
1069
1070/// Screen 混合 — 对齐 Grafika `_blendScreen`
1071fn blend_screen(base: &mut RgbaImage, overlay: &RgbaImage, x: i32, y: i32, opacity: f32) {
1072    let (w1, h1) = base.dimensions();
1073    let (w2, h2) = overlay.dimensions();
1074    let opacity = opacity.clamp(0.0, 1.0);
1075
1076    for oy in 0..h2 {
1077        for ox in 0..w2 {
1078            let bx = x + ox as i32;
1079            let by = y + oy as i32;
1080            if bx < 0 || by < 0 || bx >= w1 as i32 || by >= h1 as i32 {
1081                continue;
1082            }
1083            let src = overlay.get_pixel(ox, oy);
1084            let dst = base.get_pixel(bx as u32, by as u32);
1085            let src_alpha = (src[3] as f32 / 255.0) * opacity;
1086            if src_alpha < 1e-6 {
1087                continue;
1088            }
1089            // screen: out = 255 - (255 - src) * (255 - dst) / 255
1090            let screen_r = (255 - (255 - src[0]) as u16 * (255 - dst[0]) as u16 / 255) as u8;
1091            let screen_g = (255 - (255 - src[1]) as u16 * (255 - dst[1]) as u16 / 255) as u8;
1092            let screen_b = (255 - (255 - src[2]) as u16 * (255 - dst[2]) as u16 / 255) as u8;
1093            let dst_alpha = dst[3] as f32 / 255.0;
1094            let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
1095            if out_alpha < 1e-6 {
1096                base.put_pixel(bx as u32, by as u32, Rgba([0, 0, 0, 0]));
1097                continue;
1098            }
1099            let out_r = ((screen_r as f32 * src_alpha
1100                + dst[0] as f32 * dst_alpha * (1.0 - src_alpha))
1101                / out_alpha) as u8;
1102            let out_g = ((screen_g as f32 * src_alpha
1103                + dst[1] as f32 * dst_alpha * (1.0 - src_alpha))
1104                / out_alpha) as u8;
1105            let out_b = ((screen_b as f32 * src_alpha
1106                + dst[2] as f32 * dst_alpha * (1.0 - src_alpha))
1107                / out_alpha) as u8;
1108            let out_a = (out_alpha * 255.0) as u8;
1109            base.put_pixel(bx as u32, by as u32, Rgba([out_r, out_g, out_b, out_a]));
1110        }
1111    }
1112}
1113
1114// ============================================================================
1115// 字体加载 + 文本测量 — 对齐 imagettfbbox
1116// ============================================================================
1117
1118/// 加载字体 — 优先用 FontRef(零拷贝),失败时返回内置默认字体
1119///
1120/// 对齐 Grafika `text()` 默认字体 `LiberationSans-Regular.ttf`。
1121fn load_font(font_path: Option<&Path>) -> Result<FontVec, ImageError> {
1122    match font_path {
1123        Some(path) => {
1124            let data = std::fs::read(path)
1125                .map_err(|e| ImageError::FontLoadFailed(format!("{path:?}: {e}")))?;
1126            Ok(FontVec::try_from_vec(data)
1127                .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font {path:?}: {e}")))?)
1128        }
1129        None => {
1130            // 无字体路径时返回错误(PHP Grafika 有默认字体,Rust 端要求显式提供)
1131            Err(ImageError::FontLoadFailed(
1132                "font_path is required (no default font available)".to_string(),
1133            ))
1134        }
1135    }
1136}
1137
1138/// 测量文本边界 — 对齐 PHP `imagettfbbox($size, $angle, $font, $text)`
1139///
1140/// PHP 返回 8 个值(4 个角点):
1141/// - 0: 左下角 x
1142/// - 1: 左下角 y
1143/// - 2: 右下角 x
1144/// - 3: 右下角 y
1145/// - 4: 右上角 x
1146/// - 5: 右上角 y
1147/// - 6: 左上角 x
1148/// - 7: 左上角 y
1149///
1150/// Rust 端简化为返回 `(width, height)` — 大多数场景只需要这两个值。
1151///
1152/// 注意:PHP `imagettfbbox` 的 y 轴向下,但返回值中"上"的 y 是负数。
1153pub fn measure_text(font_path: &Path, size: u32, text: &str) -> Result<TextMetrics, ImageError> {
1154    let data = std::fs::read(font_path)
1155        .map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
1156    let font = FontVec::try_from_vec(data)
1157        .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
1158    Ok(measure_text_with_font(&font, size, text))
1159}
1160
1161/// 用已加载的字体测量文本
1162fn measure_text_with_font<F: Font>(font: &F, size: u32, text: &str) -> TextMetrics {
1163    let scale = PxScale::from(size as f32);
1164    let scaled = font.as_scaled(scale);
1165    let ascent = scaled.ascent();
1166    let descent = scaled.descent();
1167    let height = (ascent - descent).ceil();
1168
1169    let mut width: f32 = 0.0;
1170    let mut prev_glyph: Option<Glyph> = None;
1171    for ch in text.chars() {
1172        let glyph = scaled.scaled_glyph(ch);
1173        if let Some(prev) = prev_glyph {
1174            width += scaled.kern(prev.id, glyph.id);
1175        }
1176        width += scaled.h_advance(glyph.id);
1177        prev_glyph = Some(glyph);
1178    }
1179
1180    TextMetrics {
1181        width: width.ceil() as i32,
1182        height: height.ceil() as i32,
1183        ascent: ascent.ceil() as i32,
1184        descent: descent.ceil() as i32,
1185    }
1186}
1187
1188/// 文本测量结果
1189#[derive(Debug, Clone, Copy)]
1190pub struct TextMetrics {
1191    /// 文本宽度(像素)
1192    pub width: i32,
1193    /// 文本高度(像素)
1194    pub height: i32,
1195    /// 字体 ascent(基线到顶部)
1196    pub ascent: i32,
1197    /// 字体 descent(基线到底部,通常为负数)
1198    pub descent: i32,
1199}
1200
1201// ============================================================================
1202// wrap_text — 对齐业务侧 ProductService::wrapText
1203// ============================================================================
1204
1205/// 文本自动换行 — 对齐 PHP `ProductService::wrapText($fontsize, $angle, $fontface, $string, $width, $max_line)`
1206///
1207/// PHP 源码(`app/common/service/qrcode/ProductService.php` 第 114-138 行):
1208/// ```php
1209/// private function wrapText($fontsize, $angle, $fontface, $string, $width, $max_line = null) {
1210///     $content = "";
1211///     $letter = [];
1212///     for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
1213///         $letter[] = mb_substr($string, $i, 1, 'UTF-8');
1214///     }
1215///     $line_count = 0;
1216///     foreach ($letter as $l) {
1217///         $testbox = imagettfbbox($fontsize, $angle, $fontface, $content . ' ' . $l);
1218///         if (($testbox[2] > $width) && ($content !== "")) {
1219///             $line_count++;
1220///             if ($max_line && $line_count >= $max_line) {
1221///                 $content = mb_substr($content, 0, -1, 'UTF-8') . "...";
1222///                 break;
1223///             }
1224///             $content .= "\n";
1225///         }
1226///         $content .= $l;
1227///     }
1228///     return $content;
1229/// }
1230/// ```
1231///
1232/// **关键细节**:
1233/// 1. PHP 用 `mb_strlen`/`mb_substr` 按 UTF-8 字符拆分
1234/// 2. `imagettfbbox` 测量 `$content . ' ' . $l`(注意有空格连接符)
1235/// 3. `$testbox[2]` 是右下角 x(即文本宽度)
1236/// 4. 超过 `$width` 时:先 `$line_count++`,再判断是否达到 `$max_line`
1237/// 5. 达到 `$max_line` 时:截掉最后一个字符 + `"..."` + break
1238/// 6. 未达到时:在 `$content` 末尾加 `\n`,然后继续加 `$l`
1239///
1240/// **Rust 实现**:
1241/// - 用 `measure_text_with_font` 替代 `imagettfbbox`
1242/// - 测量 `$content . ' ' . $l` 即 `format!("{content} {l}")`
1243/// - 其余逻辑 1:1 对齐
1244pub fn wrap_text(
1245    font_path: &Path,
1246    fontsize: u32,
1247    string: &str,
1248    width: i32,
1249    max_line: Option<usize>,
1250) -> Result<String, ImageError> {
1251    let data = std::fs::read(font_path)
1252        .map_err(|e| ImageError::FontLoadFailed(format!("{font_path:?}: {e}")))?;
1253    let font = FontVec::try_from_vec(data)
1254        .map_err(|e| ImageError::FontLoadFailed(format!("Invalid font: {e}")))?;
1255    Ok(wrap_text_with_font(
1256        &font, fontsize, string, width, max_line,
1257    ))
1258}
1259
1260/// 用已加载字体执行 wrap_text(避免重复读取字体文件)
1261fn wrap_text_with_font<F: Font>(
1262    font: &F,
1263    fontsize: u32,
1264    string: &str,
1265    width: i32,
1266    max_line: Option<usize>,
1267) -> String {
1268    let mut content = String::new();
1269    let mut line_count: usize = 0;
1270    for l in string.chars() {
1271        // 对齐 PHP `$content . ' ' . $l`
1272        let test = format!("{content} {l}");
1273        let metrics = measure_text_with_font(font, fontsize, &test);
1274        // 对齐 PHP `($testbox[2] > $width) && ($content !== "")`
1275        if metrics.width > width && !content.is_empty() {
1276            line_count += 1;
1277            if let Some(ml) = max_line {
1278                if line_count >= ml {
1279                    // 对齐 PHP `mb_substr($content, 0, -1, 'UTF-8') . "..."`
1280                    let trimmed: String =
1281                        content.chars().take(content.chars().count() - 1).collect();
1282                    content = format!("{trimmed}...");
1283                    break;
1284                }
1285            }
1286            content.push('\n');
1287        }
1288        content.push(l);
1289    }
1290    content
1291}
1292
1293// ============================================================================
1294// 测试
1295// ============================================================================
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    // ---- 组 1:ImageType 基础 ----
1302
1303    #[test]
1304    fn test_image_type_as_str() {
1305        assert_eq!(ImageType::Unknown.as_str(), "");
1306        assert_eq!(ImageType::Gif.as_str(), "GIF");
1307        assert_eq!(ImageType::Jpeg.as_str(), "JPEG");
1308        assert_eq!(ImageType::Png.as_str(), "PNG");
1309        assert_eq!(ImageType::Wbmp.as_str(), "WBMP");
1310    }
1311
1312    #[test]
1313    fn test_image_type_from_extension() {
1314        assert_eq!(ImageType::from_extension("gif"), ImageType::Gif);
1315        assert_eq!(ImageType::from_extension("jpg"), ImageType::Jpeg);
1316        assert_eq!(ImageType::from_extension("jpeg"), ImageType::Jpeg);
1317        assert_eq!(ImageType::from_extension("png"), ImageType::Png);
1318        assert_eq!(ImageType::from_extension("wbmp"), ImageType::Wbmp);
1319        assert_eq!(ImageType::from_extension("unknown"), ImageType::Unknown);
1320    }
1321
1322    #[test]
1323    fn test_image_type_default() {
1324        assert_eq!(ImageType::default(), ImageType::Unknown);
1325    }
1326
1327    #[test]
1328    fn test_image_type_from_image_format() {
1329        use image::ImageFormat;
1330        assert_eq!(
1331            ImageType::from_image_format(ImageFormat::Gif),
1332            ImageType::Gif
1333        );
1334        assert_eq!(
1335            ImageType::from_image_format(ImageFormat::Jpeg),
1336            ImageType::Jpeg
1337        );
1338        assert_eq!(
1339            ImageType::from_image_format(ImageFormat::Png),
1340            ImageType::Png
1341        );
1342        assert_eq!(
1343            ImageType::from_image_format(ImageFormat::WebP),
1344            ImageType::Unknown
1345        );
1346    }
1347
1348    #[test]
1349    fn test_image_type_to_image_format() {
1350        assert_eq!(
1351            ImageType::Gif.to_image_format(),
1352            Some(image::ImageFormat::Gif)
1353        );
1354        assert_eq!(
1355            ImageType::Jpeg.to_image_format(),
1356            Some(image::ImageFormat::Jpeg)
1357        );
1358        assert_eq!(
1359            ImageType::Png.to_image_format(),
1360            Some(image::ImageFormat::Png)
1361        );
1362        assert_eq!(ImageType::Wbmp.to_image_format(), None);
1363        assert_eq!(ImageType::Unknown.to_image_format(), None);
1364    }
1365
1366    #[test]
1367    fn test_image_type_from_extension_case_insensitive() {
1368        assert_eq!(ImageType::from_extension("GIF"), ImageType::Gif);
1369        assert_eq!(ImageType::from_extension("PNG"), ImageType::Png);
1370        assert_eq!(ImageType::from_extension("JPG"), ImageType::Jpeg);
1371    }
1372
1373    // ---- 组 2:Color ----
1374
1375    #[test]
1376    fn test_color_rgb() {
1377        let c = Color::rgb(255, 128, 0);
1378        assert_eq!(c.r, 255);
1379        assert_eq!(c.g, 128);
1380        assert_eq!(c.b, 0);
1381        assert_eq!(c.a, 255); // 不透明
1382    }
1383
1384    #[test]
1385    fn test_color_rgba() {
1386        let c = Color::rgba(255, 128, 0, 128);
1387        assert_eq!(c.r, 255);
1388        assert_eq!(c.g, 128);
1389        assert_eq!(c.b, 0);
1390        assert_eq!(c.a, 128);
1391    }
1392
1393    #[test]
1394    fn test_color_from_hex_rrggbb() {
1395        let c = Color::from_hex("#ff8000").unwrap();
1396        assert_eq!(c.r, 255);
1397        assert_eq!(c.g, 128);
1398        assert_eq!(c.b, 0);
1399        assert_eq!(c.a, 255);
1400    }
1401
1402    #[test]
1403    fn test_color_from_hex_rgb() {
1404        let c = Color::from_hex("#f80").unwrap();
1405        assert_eq!(c.r, 255);
1406        assert_eq!(c.g, 136);
1407        assert_eq!(c.b, 0);
1408        assert_eq!(c.a, 255);
1409    }
1410
1411    #[test]
1412    fn test_color_from_hex_rrggbbaa() {
1413        let c = Color::from_hex("#ff800080").unwrap();
1414        assert_eq!(c.r, 255);
1415        assert_eq!(c.g, 128);
1416        assert_eq!(c.b, 0);
1417        assert_eq!(c.a, 128);
1418    }
1419
1420    #[test]
1421    fn test_color_from_hex_no_hash() {
1422        let c = Color::from_hex("ff8000").unwrap();
1423        assert_eq!(c.r, 255);
1424        assert_eq!(c.g, 128);
1425        assert_eq!(c.b, 0);
1426    }
1427
1428    #[test]
1429    fn test_color_from_hex_invalid() {
1430        assert!(Color::from_hex("#xyz").is_err());
1431        assert!(Color::from_hex("#1").is_err());
1432        assert!(Color::from_hex("12345").is_err());
1433    }
1434
1435    #[test]
1436    fn test_color_to_rgba() {
1437        let c = Color::rgb(1, 2, 3);
1438        assert_eq!(c.to_rgba(), Rgba([1, 2, 3, 255]));
1439    }
1440
1441    #[test]
1442    fn test_color_default() {
1443        let c = Color::default();
1444        assert_eq!(c, Color::rgb(0, 0, 0));
1445    }
1446
1447    // ---- 组 3:Position ----
1448
1449    #[test]
1450    fn test_position_parse() {
1451        assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
1452        assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
1453        assert_eq!(Position::parse("TOP-RIGHT").unwrap(), Position::TopRight);
1454        assert_eq!(Position::parse("center").unwrap(), Position::Center);
1455        assert_eq!(
1456            Position::parse("bottom-right").unwrap(),
1457            Position::BottomRight
1458        );
1459    }
1460
1461    #[test]
1462    fn test_position_parse_invalid() {
1463        assert!(Position::parse("invalid").is_err());
1464        assert!(Position::parse("").is_err());
1465    }
1466
1467    #[test]
1468    fn test_position_as_str() {
1469        assert_eq!(Position::TopLeft.as_str(), "top-left");
1470        assert_eq!(Position::Center.as_str(), "center");
1471        assert_eq!(Position::BottomRight.as_str(), "bottom-right");
1472    }
1473
1474    #[test]
1475    fn test_position_get_xy_top_left() {
1476        // 主图 100x100,叠加图 20x20,左上角偏移 (0, 0)
1477        let (x, y) = Position::TopLeft.get_xy(100, 100, 20, 20);
1478        assert_eq!(x, 0);
1479        assert_eq!(y, 0);
1480    }
1481
1482    #[test]
1483    fn test_position_get_xy_center() {
1484        // 主图 100x100,叠加图 20x20,居中偏移 (40, 40)
1485        let (x, y) = Position::Center.get_xy(100, 100, 20, 20);
1486        assert_eq!(x, 40);
1487        assert_eq!(y, 40);
1488    }
1489
1490    #[test]
1491    fn test_position_get_xy_bottom_right() {
1492        // 主图 100x100,叠加图 20x20,右下偏移 (80, 80)
1493        let (x, y) = Position::BottomRight.get_xy(100, 100, 20, 20);
1494        assert_eq!(x, 80);
1495        assert_eq!(y, 80);
1496    }
1497
1498    #[test]
1499    fn test_position_get_xy_top_center() {
1500        let (x, y) = Position::TopCenter.get_xy(100, 100, 20, 20);
1501        assert_eq!(x, 40); // (100-20)/2
1502        assert_eq!(y, 0);
1503    }
1504
1505    // ---- 组 4:Image 基础 ----
1506
1507    fn create_test_png(path: &Path, w: u32, h: u32, color: Rgba<u8>) {
1508        let img: RgbaImage = ImageBuffer::from_pixel(w, h, color);
1509        img.save(path).unwrap();
1510    }
1511
1512    #[test]
1513    fn test_image_create_blank() {
1514        let img = Image::create_blank(100, 50);
1515        assert_eq!(img.width(), 100);
1516        assert_eq!(img.height(), 50);
1517        assert_eq!(img.image_type(), ImageType::Unknown);
1518        assert!(img.file_path().is_none());
1519    }
1520
1521    #[test]
1522    fn test_image_open_png() {
1523        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1524        let path = tmp.path();
1525        create_test_png(path, 80, 60, Rgba([255, 0, 0, 255]));
1526        let img = Image::open(path).unwrap();
1527        assert_eq!(img.width(), 80);
1528        assert_eq!(img.height(), 60);
1529        assert_eq!(img.image_type(), ImageType::Png);
1530        assert!(img.file_path().is_some());
1531    }
1532
1533    #[test]
1534    fn test_image_from_dynamic() {
1535        let buf: RgbaImage = ImageBuffer::from_pixel(50, 50, Rgba([0, 255, 0, 255]));
1536        let dyn_img = DynamicImage::ImageRgba8(buf);
1537        let img = Image::from_dynamic(dyn_img, ImageType::Png);
1538        assert_eq!(img.width(), 50);
1539        assert_eq!(img.height(), 50);
1540        assert_eq!(img.image_type(), ImageType::Png);
1541    }
1542
1543    #[test]
1544    fn test_image_to_rgba8() {
1545        let img = Image::create_blank(30, 30);
1546        let rgba = img.to_rgba8();
1547        assert_eq!(rgba.dimensions(), (30, 30));
1548    }
1549
1550    // ---- 组 5:Editor open/save ----
1551
1552    #[test]
1553    fn test_editor_new() {
1554        let _editor = Editor::new();
1555        let _editor2 = Editor;
1556    }
1557
1558    #[test]
1559    fn test_editor_open() {
1560        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1561        create_test_png(tmp.path(), 100, 100, Rgba([0, 0, 255, 255]));
1562        let editor = Editor::new();
1563        let img = editor.open(tmp.path()).unwrap();
1564        assert_eq!(img.width(), 100);
1565        assert_eq!(img.height(), 100);
1566    }
1567
1568    #[test]
1569    fn test_editor_save_png() {
1570        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1571        create_test_png(tmp_in.path(), 50, 50, Rgba([0, 255, 0, 255]));
1572        let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1573        let editor = Editor::new();
1574        let img = editor.open(tmp_in.path()).unwrap();
1575        editor
1576            .save(&img, tmp_out.path(), None, None, false, 0o755)
1577            .unwrap();
1578        // 验证保存的文件可读
1579        let reopened = image::open(tmp_out.path()).unwrap();
1580        assert_eq!(reopened.width(), 50);
1581        assert_eq!(reopened.height(), 50);
1582    }
1583
1584    #[test]
1585    fn test_editor_save_jpeg_with_quality() {
1586        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1587        create_test_png(tmp_in.path(), 50, 50, Rgba([128, 64, 32, 255]));
1588        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
1589        let editor = Editor::new();
1590        let img = editor.open(tmp_in.path()).unwrap();
1591        editor
1592            .save(&img, tmp_out.path(), None, Some(90), false, 0o755)
1593            .unwrap();
1594        let reopened = image::open(tmp_out.path()).unwrap();
1595        assert_eq!(reopened.width(), 50);
1596        assert_eq!(reopened.height(), 50);
1597    }
1598
1599    // ---- 组 6:Editor resize ----
1600
1601    #[test]
1602    fn test_editor_resize_exact() {
1603        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1604        create_test_png(tmp.path(), 100, 100, Rgba([255, 0, 0, 255]));
1605        let editor = Editor::new();
1606        let mut img = editor.open(tmp.path()).unwrap();
1607        editor.resize_exact(&mut img, 50, 80);
1608        assert_eq!(img.width(), 50);
1609        assert_eq!(img.height(), 80);
1610    }
1611
1612    #[test]
1613    fn test_editor_resize_fit() {
1614        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1615        create_test_png(tmp.path(), 200, 100, Rgba([0, 255, 0, 255]));
1616        let editor = Editor::new();
1617        let mut img = editor.open(tmp.path()).unwrap();
1618        // 200x100 → fit 100x100 → ratio=0.5 → 100x50
1619        editor.resize_fit(&mut img, 100, 100);
1620        assert_eq!(img.width(), 100);
1621        assert_eq!(img.height(), 50);
1622    }
1623
1624    #[test]
1625    fn test_editor_resize_fill() {
1626        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1627        create_test_png(tmp.path(), 200, 100, Rgba([0, 0, 255, 255]));
1628        let editor = Editor::new();
1629        let mut img = editor.open(tmp.path()).unwrap();
1630        // 200x100 → fill 100x100 → ratio=1.0(按高)→ 200x100 → crop center 100x100
1631        editor.resize_fill(&mut img, 100, 100);
1632        assert_eq!(img.width(), 100);
1633        assert_eq!(img.height(), 100);
1634    }
1635
1636    #[test]
1637    fn test_editor_resize_exact_width() {
1638        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1639        create_test_png(tmp.path(), 200, 100, Rgba([255, 255, 0, 255]));
1640        let editor = Editor::new();
1641        let mut img = editor.open(tmp.path()).unwrap();
1642        // 200x100 → width=50 → 50x25
1643        editor.resize_exact_width(&mut img, 50);
1644        assert_eq!(img.width(), 50);
1645        assert_eq!(img.height(), 25);
1646    }
1647
1648    #[test]
1649    fn test_editor_resize_exact_height() {
1650        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1651        create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 255, 255]));
1652        let editor = Editor::new();
1653        let mut img = editor.open(tmp.path()).unwrap();
1654        // 200x100 → height=50 → 100x50
1655        editor.resize_exact_height(&mut img, 50);
1656        assert_eq!(img.width(), 100);
1657        assert_eq!(img.height(), 50);
1658    }
1659
1660    // ---- 组 7:Editor crop/flip/rotate ----
1661
1662    #[test]
1663    fn test_editor_crop_center() {
1664        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1665        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
1666        let editor = Editor::new();
1667        let mut img = editor.open(tmp.path()).unwrap();
1668        editor
1669            .crop(&mut img, 50, 50, Position::Center, 0, 0)
1670            .unwrap();
1671        assert_eq!(img.width(), 50);
1672        assert_eq!(img.height(), 50);
1673    }
1674
1675    #[test]
1676    fn test_editor_crop_too_large() {
1677        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1678        create_test_png(tmp.path(), 50, 50, Rgba([0, 0, 0, 255]));
1679        let editor = Editor::new();
1680        let mut img = editor.open(tmp.path()).unwrap();
1681        assert!(editor
1682            .crop(&mut img, 100, 100, Position::TopLeft, 0, 0)
1683            .is_err());
1684    }
1685
1686    #[test]
1687    fn test_editor_flip_horizontal() {
1688        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1689        create_test_png(tmp.path(), 80, 60, Rgba([255, 128, 64, 255]));
1690        let editor = Editor::new();
1691        let mut img = editor.open(tmp.path()).unwrap();
1692        editor.flip(&mut img, FlipMode::Horizontal);
1693        assert_eq!(img.width(), 80);
1694        assert_eq!(img.height(), 60);
1695    }
1696
1697    #[test]
1698    fn test_editor_flip_vertical() {
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()).unwrap();
1703        editor.flip(&mut img, FlipMode::Vertical);
1704        assert_eq!(img.width(), 80);
1705        assert_eq!(img.height(), 60);
1706    }
1707
1708    #[test]
1709    fn test_editor_rotate_90() {
1710        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1711        create_test_png(tmp.path(), 80, 60, Rgba([64, 255, 128, 255]));
1712        let editor = Editor::new();
1713        let mut img = editor.open(tmp.path()).unwrap();
1714        editor.rotate(&mut img, 90.0).unwrap();
1715        assert_eq!(img.width(), 60); // 旋转 90 度后宽高互换
1716        assert_eq!(img.height(), 80);
1717    }
1718
1719    #[test]
1720    fn test_editor_rotate_180() {
1721        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1722        create_test_png(tmp.path(), 80, 60, Rgba([64, 128, 255, 255]));
1723        let editor = Editor::new();
1724        let mut img = editor.open(tmp.path()).unwrap();
1725        editor.rotate(&mut img, 180.0).unwrap();
1726        assert_eq!(img.width(), 80);
1727        assert_eq!(img.height(), 60);
1728    }
1729
1730    #[test]
1731    fn test_editor_rotate_invalid_angle() {
1732        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1733        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
1734        let editor = Editor::new();
1735        let mut img = editor.open(tmp.path()).unwrap();
1736        assert!(editor.rotate(&mut img, 45.0).is_err());
1737    }
1738
1739    // ---- 组 8:Editor blend ----
1740
1741    #[test]
1742    fn test_editor_blend_normal() {
1743        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1744        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1745        create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
1746        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1747        let editor = Editor::new();
1748        let mut img1 = editor.open(tmp1.path()).unwrap();
1749        let img2 = editor.open(tmp2.path()).unwrap();
1750        editor
1751            .blend(
1752                &mut img1,
1753                &img2,
1754                BlendType::Normal,
1755                1.0,
1756                Position::TopLeft,
1757                0,
1758                0,
1759            )
1760            .unwrap();
1761        assert_eq!(img1.width(), 100);
1762        assert_eq!(img1.height(), 100);
1763        // 左上角第一个像素应该是叠加图的颜色(白色,不透明)
1764        let rgba = img1.to_rgba8();
1765        let pixel = rgba.get_pixel(0, 0);
1766        assert_eq!(pixel[0], 255);
1767        assert_eq!(pixel[1], 255);
1768        assert_eq!(pixel[2], 255);
1769    }
1770
1771    #[test]
1772    fn test_editor_blend_with_offset() {
1773        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1774        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1775        create_test_png(tmp1.path(), 100, 100, Rgba([0, 0, 0, 255]));
1776        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1777        let editor = Editor::new();
1778        let mut img1 = editor.open(tmp1.path()).unwrap();
1779        let img2 = editor.open(tmp2.path()).unwrap();
1780        // 偏移到 (30, 30),叠加图 50x50,应影响 (30,30)-(80,80)
1781        editor
1782            .blend(
1783                &mut img1,
1784                &img2,
1785                BlendType::Normal,
1786                1.0,
1787                Position::TopLeft,
1788                30,
1789                30,
1790            )
1791            .unwrap();
1792        let rgba = img1.to_rgba8();
1793        // (0, 0) 应该是黑色(未被叠加)
1794        let p1 = rgba.get_pixel(0, 0);
1795        assert_eq!(p1[0], 0);
1796        // (50, 50) 应该是白色(在叠加区内)
1797        let p2 = rgba.get_pixel(50, 50);
1798        assert_eq!(p2[0], 255);
1799        // (90, 90) 应该是黑色(在叠加区外)
1800        let p3 = rgba.get_pixel(90, 90);
1801        assert_eq!(p3[0], 0);
1802    }
1803
1804    #[test]
1805    fn test_editor_blend_opacity_half() {
1806        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1807        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1808        create_test_png(tmp1.path(), 50, 50, Rgba([0, 0, 0, 255]));
1809        create_test_png(tmp2.path(), 50, 50, Rgba([255, 255, 255, 255]));
1810        let editor = Editor::new();
1811        let mut img1 = editor.open(tmp1.path()).unwrap();
1812        let img2 = editor.open(tmp2.path()).unwrap();
1813        // opacity=0.5:黑色 + 50%白色 ≈ 128
1814        editor
1815            .blend(
1816                &mut img1,
1817                &img2,
1818                BlendType::Normal,
1819                0.5,
1820                Position::TopLeft,
1821                0,
1822                0,
1823            )
1824            .unwrap();
1825        let rgba = img1.to_rgba8();
1826        let pixel = rgba.get_pixel(0, 0);
1827        // 半透明白色叠加到黑色:128 左右
1828        assert!(
1829            (120..=136).contains(&pixel[0]),
1830            "expected ~128, got {}",
1831            pixel[0]
1832        );
1833    }
1834
1835    #[test]
1836    fn test_blend_type_parse() {
1837        assert_eq!(BlendType::parse("normal").unwrap(), BlendType::Normal);
1838        assert_eq!(BlendType::parse("MULTIPLY").unwrap(), BlendType::Multiply);
1839        assert_eq!(BlendType::parse("overlay").unwrap(), BlendType::Overlay);
1840        assert_eq!(BlendType::parse("screen").unwrap(), BlendType::Screen);
1841        assert!(BlendType::parse("invalid").is_err());
1842    }
1843
1844    #[test]
1845    fn test_flip_mode_parse() {
1846        assert_eq!(FlipMode::parse("h").unwrap(), FlipMode::Horizontal);
1847        assert_eq!(FlipMode::parse("V").unwrap(), FlipMode::Vertical);
1848        assert!(FlipMode::parse("x").is_err());
1849    }
1850
1851    // ---- 组 9:Editor fill ----
1852
1853    #[test]
1854    fn test_editor_fill() {
1855        let img = Image::create_blank(50, 50);
1856        let editor = Editor::new();
1857        let mut img = img;
1858        editor.fill(&mut img, Color::rgb(255, 0, 0));
1859        let rgba = img.to_rgba8();
1860        let pixel = rgba.get_pixel(0, 0);
1861        assert_eq!(pixel[0], 255);
1862        assert_eq!(pixel[1], 0);
1863        assert_eq!(pixel[2], 0);
1864    }
1865
1866    // ---- 组 10:PHP 行为对齐 R5 ----
1867
1868    // R5-24:ImageType 5 种类型对齐 Grafika\ImageType
1869    #[test]
1870    fn test_r5_24_image_type_constants() {
1871        // 对齐 PHP ImageType 常量
1872        assert_eq!(ImageType::Unknown.as_str(), ""); // const UNKNOWN = ''
1873        assert_eq!(ImageType::Gif.as_str(), "GIF"); // const GIF = 'GIF'
1874        assert_eq!(ImageType::Jpeg.as_str(), "JPEG"); // const JPEG = 'JPEG'
1875        assert_eq!(ImageType::Png.as_str(), "PNG"); // const PNG = 'PNG'
1876        assert_eq!(ImageType::Wbmp.as_str(), "WBMP"); // const WBMP = 'WBMP'
1877    }
1878
1879    // R5-25:Color hex 解析对齐 Grafika\Color
1880    #[test]
1881    fn test_r5_25_color_hex_parsing() {
1882        // 对齐 PHP new Color('#333333')
1883        let c1 = Color::from_hex("#333333").unwrap();
1884        assert_eq!((c1.r, c1.g, c1.b), (0x33, 0x33, 0x33));
1885        // 对齐 PHP new Color('#ff4444')
1886        let c2 = Color::from_hex("#ff4444").unwrap();
1887        assert_eq!((c2.r, c2.g, c2.b), (0xff, 0x44, 0x44));
1888        // 对齐 PHP new Color('#f00')
1889        let c3 = Color::from_hex("#f00").unwrap();
1890        assert_eq!((c3.r, c3.g, c3.b), (0xff, 0x00, 0x00));
1891    }
1892
1893    // R5-26:Position 9 种位置 + get_xy 对齐 Grafika\Position::getXY
1894    #[test]
1895    fn test_r5_26_position_get_xy_all_nine() {
1896        let w1 = 100u32;
1897        let h1 = 100u32;
1898        let w2 = 20u32;
1899        let h2 = 20u32;
1900        // 验证 9 种位置的 get_xy 计算
1901        let cases = [
1902            (Position::TopLeft, 0, 0),
1903            (Position::TopCenter, 40, 0),
1904            (Position::TopRight, 80, 0),
1905            (Position::CenterLeft, 0, 40),
1906            (Position::Center, 40, 40),
1907            (Position::CenterRight, 80, 40),
1908            (Position::BottomLeft, 0, 80),
1909            (Position::BottomCenter, 40, 80),
1910            (Position::BottomRight, 80, 80),
1911        ];
1912        for (pos, ex, ey) in cases {
1913            let (x, y) = pos.get_xy(w1, h1, w2, h2);
1914            assert_eq!(x, ex, "Position {:?} x mismatch", pos);
1915            assert_eq!(y, ey, "Position {:?} y mismatch", pos);
1916        }
1917    }
1918
1919    // R5-27:Image::open 按 getimagesize 探测类型
1920    #[test]
1921    fn test_r5_27_image_open_detects_type() {
1922        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1923        create_test_png(tmp.path(), 80, 60, Rgba([255, 255, 255, 255]));
1924        let img = Image::open(tmp.path()).unwrap();
1925        assert_eq!(img.image_type(), ImageType::Png);
1926        assert_eq!(img.width(), 80);
1927        assert_eq!(img.height(), 60);
1928    }
1929
1930    // R5-28:Editor::resize_exact 强制目标尺寸
1931    #[test]
1932    fn test_r5_28_resize_exact_forces_dimensions() {
1933        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1934        create_test_png(tmp.path(), 200, 100, Rgba([255, 0, 0, 255]));
1935        let editor = Editor::new();
1936        let mut img = editor.open(tmp.path()).unwrap();
1937        // 200x100 → 50x50(强制忽略宽高比)
1938        editor.resize_exact(&mut img, 50, 50);
1939        assert_eq!(img.width(), 50);
1940        assert_eq!(img.height(), 50);
1941    }
1942
1943    // R5-29:Editor::blend normal + opacity + offset
1944    #[test]
1945    fn test_r5_29_blend_normal_with_offset_and_opacity() {
1946        let tmp1 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1947        let tmp2 = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1948        create_test_png(tmp1.path(), 200, 200, Rgba([0, 0, 0, 255]));
1949        create_test_png(tmp2.path(), 100, 100, Rgba([255, 255, 255, 255]));
1950        let editor = Editor::new();
1951        let mut img1 = editor.open(tmp1.path()).unwrap();
1952        let img2 = editor.open(tmp2.path()).unwrap();
1953        // 对齐 PHP $editor->blend($bg, $fg, 'normal', 1.0, 'top-left', 30, 30)
1954        editor
1955            .blend(
1956                &mut img1,
1957                &img2,
1958                BlendType::Normal,
1959                1.0,
1960                Position::TopLeft,
1961                30,
1962                30,
1963            )
1964            .unwrap();
1965        // 验证叠加区
1966        let rgba = img1.to_rgba8();
1967        // (0, 0) 未叠加 → 黑
1968        assert_eq!(rgba.get_pixel(0, 0)[0], 0);
1969        // (50, 50) 在叠加区 → 白
1970        assert_eq!(rgba.get_pixel(50, 50)[0], 255);
1971        // (129, 129) 在叠加区边界内 → 白(叠加区为 [30, 130) x [30, 130))
1972        assert_eq!(rgba.get_pixel(129, 129)[0], 255);
1973        // (130, 130) 在叠加区外 → 黑
1974        assert_eq!(rgba.get_pixel(130, 130)[0], 0);
1975        // (150, 150) 在叠加区外 → 黑
1976        assert_eq!(rgba.get_pixel(150, 150)[0], 0);
1977    }
1978
1979    // R5-30:Editor::text y 坐标基线偏移
1980    #[test]
1981    fn test_r5_30_text_y_baseline_offset() {
1982        // 无法精确测试像素布局(依赖字体),但验证 y - size 偏移逻辑:
1983        // PHP: imagettftext y 是基线,Grafika: y += size(y 变为顶部)
1984        // Rust: y 是顶部,所以 Rust y = PHP y - size
1985        // 验证:text() 不 panic 即可(字体文件不一定可用,用闭包模拟)
1986        let mut img = Image::create_blank(200, 100);
1987        let editor = Editor::new();
1988        // 无字体路径 → 期望 FontLoadFailed 错误
1989        let result = editor.text(&mut img, "test", 30, 10, 50, Color::rgb(0, 0, 0), None);
1990        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
1991    }
1992
1993    // R5-31:Editor::save 按扩展名猜类型 + JPEG 默认 quality=75
1994    #[test]
1995    fn test_r5_31_save_infers_type_from_extension() {
1996        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
1997        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 128, 64, 255]));
1998
1999        // 保存为 .png → 应识别为 PNG
2000        let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2001        let editor = Editor::new();
2002        let img = editor.open(tmp_in.path()).unwrap();
2003        editor
2004            .save(&img, tmp_png.path(), None, None, false, 0o755)
2005            .unwrap();
2006        assert!(tmp_png.path().exists());
2007
2008        // 保存为 .jpg → 应识别为 JPEG
2009        let tmp_jpg = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2010        editor
2011            .save(&img, tmp_jpg.path(), None, None, false, 0o755)
2012            .unwrap();
2013        assert!(tmp_jpg.path().exists());
2014    }
2015
2016    // R5-32:wrap_text 对齐 PHP wrapText(无字体文件时返回错误,但函数签名对齐)
2017    #[test]
2018    fn test_r5_32_wrap_text_signature_alignment() {
2019        // 对齐 PHP wrapText($fontsize, $angle, $fontface, $string, $width, $max_line)
2020        // Rust: wrap_text(font_path, fontsize, string, width, max_line)
2021        // 验证无字体文件时返回 FontLoadFailed
2022        let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, Some(2));
2023        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2024    }
2025
2026    // R5-32 补充:wrap_text_with_font 逻辑验证
2027    #[test]
2028    fn test_r5_32_wrap_text_with_font_logic() {
2029        // 用内置测量逻辑验证 wrap_text 的换行行为
2030        // 加载一个简单的字体(如果可用);否则跳过
2031        let font_path = Path::new(
2032            "e:/vue/test/鲜视达/server/vendor/kosinix/grafika/src/Grafika/fonts/st-heiti-light.ttc",
2033        );
2034        if !font_path.exists() {
2035            // 字体文件不可用,跳过本测试
2036            eprintln!(
2037                "Skipping test_r5_32_wrap_text_with_font_logic: font not found at {font_path:?}"
2038            );
2039            return;
2040        }
2041        let data = std::fs::read(font_path).unwrap();
2042        let font = FontVec::try_from_vec(data).unwrap();
2043        // 短文本不换行
2044        let result = wrap_text_with_font(&font, 30, "hello", 680, Some(2));
2045        assert_eq!(result, "hello");
2046        // 长文本 + max_line=2 → 应该有省略号
2047        let long_text = "这是一个非常长的商品名称用于测试自动换行功能应该被截断并添加省略号";
2048        let result = wrap_text_with_font(&font, 30, long_text, 100, Some(2));
2049        assert!(
2050            result.ends_with("..."),
2051            "result should end with ..., got: {result}"
2052        );
2053        assert!(
2054            result.contains('\n'),
2055            "result should contain newline, got: {result}"
2056        );
2057    }
2058
2059    // ---- 组 11:measure_text ----
2060
2061    #[test]
2062    fn test_measure_text_nonexistent_font() {
2063        let result = measure_text(Path::new("/nonexistent.ttf"), 30, "hello");
2064        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2065    }
2066
2067    #[test]
2068    fn test_text_metrics_debug() {
2069        let m = TextMetrics {
2070            width: 100,
2071            height: 30,
2072            ascent: 25,
2073            descent: -5,
2074        };
2075        assert_eq!(m.width, 100);
2076        assert_eq!(m.height, 30);
2077    }
2078
2079    // ---- 组 12:Color 补充 ----
2080
2081    #[test]
2082    fn test_color_from_hex_rgba_short() {
2083        let c = Color::from_hex("#f80f").unwrap();
2084        assert_eq!(c.r, 255);
2085        assert_eq!(c.g, 136);
2086        assert_eq!(c.b, 0);
2087        assert_eq!(c.a, 255);
2088    }
2089
2090    #[test]
2091    fn test_color_from_hex_with_whitespace() {
2092        let c = Color::from_hex("  #ff8000  ").unwrap();
2093        assert_eq!(c.r, 255);
2094        assert_eq!(c.g, 128);
2095        assert_eq!(c.b, 0);
2096    }
2097
2098    #[test]
2099    fn test_color_from_hex_empty() {
2100        assert!(Color::from_hex("#").is_err());
2101        assert!(Color::from_hex("").is_err());
2102    }
2103
2104    #[test]
2105    fn test_color_from_hex_invalid_chars() {
2106        assert!(Color::from_hex("#gggggg").is_err());
2107        assert!(Color::from_hex("#zz").is_err());
2108    }
2109
2110    #[test]
2111    fn test_color_copy_and_eq() {
2112        let c1 = Color::rgb(1, 2, 3);
2113        let c2 = c1;
2114        assert_eq!(c1, c2);
2115    }
2116
2117    // ---- 组 13:ImageType 补充 ----
2118
2119    #[test]
2120    fn test_image_type_from_image_format_other() {
2121        use image::ImageFormat;
2122        assert_eq!(
2123            ImageType::from_image_format(ImageFormat::Bmp),
2124            ImageType::Unknown
2125        );
2126        assert_eq!(
2127            ImageType::from_image_format(ImageFormat::Tiff),
2128            ImageType::Unknown
2129        );
2130    }
2131
2132    #[test]
2133    fn test_image_type_from_extension_empty() {
2134        assert_eq!(ImageType::from_extension(""), ImageType::Unknown);
2135    }
2136
2137    // ---- 组 14:Position 补充 ----
2138
2139    #[test]
2140    fn test_position_parse_all_nine() {
2141        assert_eq!(Position::parse("top-left").unwrap(), Position::TopLeft);
2142        assert_eq!(Position::parse("top-center").unwrap(), Position::TopCenter);
2143        assert_eq!(Position::parse("top-right").unwrap(), Position::TopRight);
2144        assert_eq!(
2145            Position::parse("center-left").unwrap(),
2146            Position::CenterLeft
2147        );
2148        assert_eq!(Position::parse("center").unwrap(), Position::Center);
2149        assert_eq!(
2150            Position::parse("center-right").unwrap(),
2151            Position::CenterRight
2152        );
2153        assert_eq!(
2154            Position::parse("bottom-left").unwrap(),
2155            Position::BottomLeft
2156        );
2157        assert_eq!(
2158            Position::parse("bottom-center").unwrap(),
2159            Position::BottomCenter
2160        );
2161        assert_eq!(
2162            Position::parse("bottom-right").unwrap(),
2163            Position::BottomRight
2164        );
2165    }
2166
2167    #[test]
2168    fn test_position_as_str_all_nine() {
2169        assert_eq!(Position::TopLeft.as_str(), "top-left");
2170        assert_eq!(Position::TopCenter.as_str(), "top-center");
2171        assert_eq!(Position::TopRight.as_str(), "top-right");
2172        assert_eq!(Position::CenterLeft.as_str(), "center-left");
2173        assert_eq!(Position::Center.as_str(), "center");
2174        assert_eq!(Position::CenterRight.as_str(), "center-right");
2175        assert_eq!(Position::BottomLeft.as_str(), "bottom-left");
2176        assert_eq!(Position::BottomCenter.as_str(), "bottom-center");
2177        assert_eq!(Position::BottomRight.as_str(), "bottom-right");
2178    }
2179
2180    #[test]
2181    fn test_position_get_xy_unequal_dimensions() {
2182        let (x, y) = Position::Center.get_xy(200, 100, 40, 30);
2183        assert_eq!(x, 80);
2184        assert_eq!(y, 35);
2185    }
2186
2187    // ---- 组 15:Image 补充 ----
2188
2189    #[test]
2190    fn test_image_from_rgba8() {
2191        let buf: RgbaImage = ImageBuffer::from_pixel(40, 30, Rgba([10, 20, 30, 255]));
2192        let img = Image::from_rgba8(buf, ImageType::Png);
2193        assert_eq!(img.width(), 40);
2194        assert_eq!(img.height(), 30);
2195        assert_eq!(img.image_type(), ImageType::Png);
2196        assert!(img.file_path().is_none());
2197    }
2198
2199    #[test]
2200    fn test_image_as_dynamic() {
2201        let img = Image::create_blank(50, 50);
2202        let dyn_ref = img.as_dynamic();
2203        assert_eq!(dyn_ref.width(), 50);
2204        assert_eq!(dyn_ref.height(), 50);
2205    }
2206
2207    #[test]
2208    fn test_image_as_dynamic_mut() {
2209        let mut img = Image::create_blank(50, 50);
2210        let dyn_mut = img.as_dynamic_mut();
2211        assert_eq!(dyn_mut.width(), 50);
2212        assert_eq!(dyn_mut.height(), 50);
2213    }
2214
2215    #[test]
2216    fn test_image_open_nonexistent() {
2217        let result = Image::open(Path::new("/nonexistent/file.png"));
2218        assert!(result.is_err());
2219    }
2220
2221    #[test]
2222    fn test_image_open_unknown_extension_fails() {
2223        // image crate uses extension for format detection; .bin is not recognized.
2224        // Create PNG with proper extension first, then rename to .bin.
2225        let tmp_png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2226        create_test_png(tmp_png.path(), 60, 40, Rgba([255, 0, 0, 255]));
2227        let bin_path = tmp_png.path().with_extension("bin");
2228        std::fs::rename(tmp_png.path(), &bin_path).unwrap();
2229        let result = Image::open(&bin_path);
2230        assert!(result.is_err());
2231    }
2232
2233    // ---- 组 16:Editor 补充 ----
2234
2235    #[test]
2236    fn test_editor_default() {
2237        let _editor = Editor;
2238    }
2239
2240    #[test]
2241    fn test_editor_rotate_0() {
2242        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2243        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2244        let editor = Editor::new();
2245        let mut img = editor.open(tmp.path()).unwrap();
2246        editor.rotate(&mut img, 0.0).unwrap();
2247        assert_eq!(img.width(), 80);
2248        assert_eq!(img.height(), 60);
2249    }
2250
2251    #[test]
2252    fn test_editor_rotate_270() {
2253        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2254        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2255        let editor = Editor::new();
2256        let mut img = editor.open(tmp.path()).unwrap();
2257        editor.rotate(&mut img, 270.0).unwrap();
2258        assert_eq!(img.width(), 60);
2259        assert_eq!(img.height(), 80);
2260    }
2261
2262    #[test]
2263    fn test_editor_rotate_negative_90() {
2264        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2265        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2266        let editor = Editor::new();
2267        let mut img = editor.open(tmp.path()).unwrap();
2268        editor.rotate(&mut img, -90.0).unwrap();
2269        assert_eq!(img.width(), 60);
2270        assert_eq!(img.height(), 80);
2271    }
2272
2273    #[test]
2274    fn test_editor_rotate_negative_180() {
2275        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2276        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2277        let editor = Editor::new();
2278        let mut img = editor.open(tmp.path()).unwrap();
2279        editor.rotate(&mut img, -180.0).unwrap();
2280        assert_eq!(img.width(), 80);
2281        assert_eq!(img.height(), 60);
2282    }
2283
2284    #[test]
2285    fn test_editor_rotate_360_normalized_to_0() {
2286        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2287        create_test_png(tmp.path(), 80, 60, Rgba([0, 0, 0, 255]));
2288        let editor = Editor::new();
2289        let mut img = editor.open(tmp.path()).unwrap();
2290        editor.rotate(&mut img, 360.0).unwrap();
2291        assert_eq!(img.width(), 80);
2292        assert_eq!(img.height(), 60);
2293    }
2294
2295    #[test]
2296    fn test_editor_crop_with_positive_offset() {
2297        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2298        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2299        let editor = Editor::new();
2300        let mut img = editor.open(tmp.path()).unwrap();
2301        editor
2302            .crop(&mut img, 50, 50, Position::Center, 10, 10)
2303            .unwrap();
2304        assert_eq!(img.width(), 50);
2305        assert_eq!(img.height(), 50);
2306    }
2307
2308    #[test]
2309    fn test_editor_crop_with_negative_offset_clamped() {
2310        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2311        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2312        let editor = Editor::new();
2313        let mut img = editor.open(tmp.path()).unwrap();
2314        editor
2315            .crop(&mut img, 50, 50, Position::TopLeft, -100, -100)
2316            .unwrap();
2317        assert_eq!(img.width(), 50);
2318        assert_eq!(img.height(), 50);
2319    }
2320
2321    #[test]
2322    fn test_editor_crop_top_left() {
2323        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2324        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2325        let editor = Editor::new();
2326        let mut img = editor.open(tmp.path()).unwrap();
2327        editor
2328            .crop(&mut img, 30, 30, Position::TopLeft, 0, 0)
2329            .unwrap();
2330        assert_eq!(img.width(), 30);
2331        assert_eq!(img.height(), 30);
2332    }
2333
2334    #[test]
2335    fn test_editor_crop_bottom_right() {
2336        let tmp = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2337        create_test_png(tmp.path(), 100, 100, Rgba([0, 128, 255, 255]));
2338        let editor = Editor::new();
2339        let mut img = editor.open(tmp.path()).unwrap();
2340        editor
2341            .crop(&mut img, 30, 30, Position::BottomRight, 0, 0)
2342            .unwrap();
2343        assert_eq!(img.width(), 30);
2344        assert_eq!(img.height(), 30);
2345    }
2346
2347    // ---- 组 17:Editor blend 补充 ----
2348
2349    #[test]
2350    fn test_editor_blend_multiply() {
2351        let base = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2352        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2353        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2354        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2355        let editor = Editor::new();
2356        editor
2357            .blend(
2358                &mut img1,
2359                &img2,
2360                BlendType::Multiply,
2361                1.0,
2362                Position::TopLeft,
2363                0,
2364                0,
2365            )
2366            .unwrap();
2367        let rgba = img1.to_rgba8();
2368        let pixel = rgba.get_pixel(0, 0);
2369        assert!(
2370            (60..=68).contains(&pixel[0]),
2371            "expected ~64, got {}",
2372            pixel[0]
2373        );
2374    }
2375
2376    #[test]
2377    fn test_editor_blend_overlay_dark() {
2378        let base = ImageBuffer::from_pixel(50, 50, Rgba([64, 64, 64, 255]));
2379        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2380        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([128, 128, 128, 255]));
2381        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2382        let editor = Editor::new();
2383        editor
2384            .blend(
2385                &mut img1,
2386                &img2,
2387                BlendType::Overlay,
2388                1.0,
2389                Position::TopLeft,
2390                0,
2391                0,
2392            )
2393            .unwrap();
2394        let rgba = img1.to_rgba8();
2395        let pixel = rgba.get_pixel(0, 0);
2396        assert!(
2397            (60..=68).contains(&pixel[0]),
2398            "expected ~64, got {}",
2399            pixel[0]
2400        );
2401    }
2402
2403    #[test]
2404    fn test_editor_blend_overlay_light() {
2405        let base = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
2406        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2407        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([200, 200, 200, 255]));
2408        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2409        let editor = Editor::new();
2410        editor
2411            .blend(
2412                &mut img1,
2413                &img2,
2414                BlendType::Overlay,
2415                1.0,
2416                Position::TopLeft,
2417                0,
2418                0,
2419            )
2420            .unwrap();
2421        let rgba = img1.to_rgba8();
2422        let pixel = rgba.get_pixel(0, 0);
2423        assert!(
2424            (225..=235).contains(&pixel[0]),
2425            "expected ~231, got {}",
2426            pixel[0]
2427        );
2428    }
2429
2430    #[test]
2431    fn test_editor_blend_screen() {
2432        let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2433        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2434        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2435        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2436        let editor = Editor::new();
2437        editor
2438            .blend(
2439                &mut img1,
2440                &img2,
2441                BlendType::Screen,
2442                1.0,
2443                Position::TopLeft,
2444                0,
2445                0,
2446            )
2447            .unwrap();
2448        let rgba = img1.to_rgba8();
2449        let pixel = rgba.get_pixel(0, 0);
2450        assert_eq!(pixel[0], 0);
2451    }
2452
2453    #[test]
2454    fn test_editor_blend_with_negative_offset() {
2455        let base = ImageBuffer::from_pixel(50, 50, Rgba([0, 0, 0, 255]));
2456        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2457        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 255, 255, 255]));
2458        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2459        let editor = Editor::new();
2460        editor
2461            .blend(
2462                &mut img1,
2463                &img2,
2464                BlendType::Normal,
2465                1.0,
2466                Position::TopLeft,
2467                -25,
2468                -25,
2469            )
2470            .unwrap();
2471        let rgba = img1.to_rgba8();
2472        assert_eq!(rgba.get_pixel(0, 0)[0], 255);
2473        assert_eq!(rgba.get_pixel(24, 24)[0], 255);
2474        assert_eq!(rgba.get_pixel(25, 25)[0], 0);
2475    }
2476
2477    #[test]
2478    fn test_editor_blend_transparent_overlay() {
2479        let base = ImageBuffer::from_pixel(50, 50, Rgba([100, 100, 100, 255]));
2480        let mut img1 = Image::from_rgba8(base, ImageType::Png);
2481        let overlay = ImageBuffer::from_pixel(50, 50, Rgba([255, 0, 0, 0]));
2482        let img2 = Image::from_rgba8(overlay, ImageType::Png);
2483        let editor = Editor::new();
2484        editor
2485            .blend(
2486                &mut img1,
2487                &img2,
2488                BlendType::Normal,
2489                1.0,
2490                Position::TopLeft,
2491                0,
2492                0,
2493            )
2494            .unwrap();
2495        let rgba = img1.to_rgba8();
2496        let pixel = rgba.get_pixel(0, 0);
2497        assert_eq!(pixel[0], 100);
2498    }
2499
2500    // ---- 组 18:Editor save 补充 ----
2501
2502    #[test]
2503    fn test_editor_save_gif() {
2504        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2505        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2506        let tmp_out = tempfile::Builder::new().suffix(".gif").tempfile().unwrap();
2507        let editor = Editor::new();
2508        let img = editor.open(tmp_in.path()).unwrap();
2509        editor
2510            .save(&img, tmp_out.path(), None, None, false, 0o755)
2511            .unwrap();
2512        assert!(tmp_out.path().exists());
2513        let reopened = image::open(tmp_out.path()).unwrap();
2514        assert_eq!(reopened.width(), 30);
2515    }
2516
2517    #[test]
2518    fn test_editor_save_wbmp_error() {
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(".wbmp").tempfile().unwrap();
2522        let editor = Editor::new();
2523        let img = editor.open(tmp_in.path()).unwrap();
2524        let result = editor.save(&img, tmp_out.path(), None, None, false, 0o755);
2525        assert!(result.is_err());
2526    }
2527
2528    #[test]
2529    fn test_editor_save_unknown_type_error() {
2530        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2531        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2532        let tmp_out = tempfile::Builder::new().suffix(".bin").tempfile().unwrap();
2533        let editor = Editor::new();
2534        let img = editor.open(tmp_in.path()).unwrap();
2535        let result = editor.save(&img, tmp_out.path(), None, None, false, 0o755);
2536        assert!(result.is_err());
2537    }
2538
2539    #[test]
2540    fn test_editor_save_with_explicit_png_type() {
2541        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2542        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2543        let tmp_out = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2544        let editor = Editor::new();
2545        let img = editor.open(tmp_in.path()).unwrap();
2546        editor
2547            .save(
2548                &img,
2549                tmp_out.path(),
2550                Some(ImageType::Png),
2551                None,
2552                false,
2553                0o755,
2554            )
2555            .unwrap();
2556        assert!(tmp_out.path().exists());
2557    }
2558
2559    #[test]
2560    fn test_editor_save_jpeg_explicit_type() {
2561        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2562        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 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()).unwrap();
2566        editor
2567            .save(
2568                &img,
2569                tmp_out.path(),
2570                Some(ImageType::Jpeg),
2571                Some(80),
2572                false,
2573                0o755,
2574            )
2575            .unwrap();
2576        assert!(tmp_out.path().exists());
2577    }
2578
2579    #[test]
2580    fn test_editor_save_quality_clamping_high() {
2581        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2582        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
2583        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2584        let editor = Editor::new();
2585        let img = editor.open(tmp_in.path()).unwrap();
2586        editor
2587            .save(&img, tmp_out.path(), None, Some(200), false, 0o755)
2588            .unwrap();
2589        assert!(tmp_out.path().exists());
2590    }
2591
2592    #[test]
2593    fn test_editor_save_quality_clamping_zero() {
2594        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2595        create_test_png(tmp_in.path(), 30, 30, Rgba([128, 64, 32, 255]));
2596        let tmp_out = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
2597        let editor = Editor::new();
2598        let img = editor.open(tmp_in.path()).unwrap();
2599        editor
2600            .save(&img, tmp_out.path(), None, Some(0), false, 0o755)
2601            .unwrap();
2602        assert!(tmp_out.path().exists());
2603    }
2604
2605    #[test]
2606    fn test_editor_save_creates_parent_dir() {
2607        let tmp_in = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
2608        create_test_png(tmp_in.path(), 30, 30, Rgba([255, 0, 0, 255]));
2609        let tmp_dir = tempfile::tempdir().unwrap();
2610        let output_path = tmp_dir.path().join("subdir").join("output.png");
2611        assert!(!output_path.parent().unwrap().exists());
2612        let editor = Editor::new();
2613        let img = editor.open(tmp_in.path()).unwrap();
2614        editor
2615            .save(&img, &output_path, None, None, false, 0o755)
2616            .unwrap();
2617        assert!(output_path.exists());
2618    }
2619
2620    // ---- 组 19:load_font / measure_text / wrap_text 补充 ----
2621
2622    #[test]
2623    fn test_load_font_invalid_data() {
2624        let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
2625        std::fs::write(tmp.path(), b"this is not a font").unwrap();
2626        let mut img = Image::create_blank(100, 50);
2627        let editor = Editor::new();
2628        let result = editor.text(
2629            &mut img,
2630            "test",
2631            20,
2632            10,
2633            30,
2634            Color::rgb(0, 0, 0),
2635            Some(tmp.path()),
2636        );
2637        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2638    }
2639
2640    #[test]
2641    fn test_measure_text_invalid_font() {
2642        let tmp = tempfile::Builder::new().suffix(".ttf").tempfile().unwrap();
2643        std::fs::write(tmp.path(), b"invalid font data").unwrap();
2644        let result = measure_text(tmp.path(), 30, "hello");
2645        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2646    }
2647
2648    #[test]
2649    fn test_wrap_text_nonexistent_font() {
2650        let result = wrap_text(Path::new("/nonexistent.ttf"), 30, "hello", 680, None);
2651        assert!(matches!(result, Err(ImageError::FontLoadFailed(_))));
2652    }
2653
2654    #[test]
2655    fn test_editor_text_with_font() {
2656        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2657        if !font_path.exists() {
2658            eprintln!("Skipping test_editor_text_with_font: font not found");
2659            return;
2660        }
2661        let mut img = Image::create_blank(200, 100);
2662        let editor = Editor::new();
2663        let result = editor.text(
2664            &mut img,
2665            "hello",
2666            30,
2667            10,
2668            50,
2669            Color::rgb(255, 0, 0),
2670            Some(font_path),
2671        );
2672        assert!(result.is_ok());
2673        assert_eq!(img.width(), 200);
2674        assert_eq!(img.height(), 100);
2675    }
2676
2677    #[test]
2678    fn test_measure_text_with_font() {
2679        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2680        if !font_path.exists() {
2681            eprintln!("Skipping test_measure_text_with_font: font not found");
2682            return;
2683        }
2684        let result = measure_text(font_path, 30, "hello").unwrap();
2685        assert!(result.width > 0);
2686        assert!(result.height > 0);
2687    }
2688
2689    #[test]
2690    fn test_wrap_text_with_font_no_max_line() {
2691        let font_path = Path::new("C:/Windows/Fonts/arial.ttf");
2692        if !font_path.exists() {
2693            eprintln!("Skipping test_wrap_text_with_font_no_max_line: font not found");
2694            return;
2695        }
2696        let data = std::fs::read(font_path).unwrap();
2697        let font = FontVec::try_from_vec(data).unwrap();
2698        let long_text = "this is a very long text that should wrap";
2699        let result = wrap_text_with_font(&font, 30, long_text, 100, None);
2700        assert!(result.contains('\n'), "should contain newline: {result}");
2701        assert!(
2702            !result.ends_with("..."),
2703            "should not end with ... when no max_line"
2704        );
2705    }
2706}