tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! 路径图片的上传前处理(《2.功能概述文档》第 9.2 节):规则与 Web SDK 的 prepareImage(sdk/web/src/modules/files/prepare.ts)相同。
//!
//! - 按 EXIF 方向转正、缩放到最长边 max_side(原图不缩放)、去掉全部元数据后重新编码:有透明通道的 PNG/WebP 输出 PNG,其他 JPEG;
//! - GIF 不重新编码(动图);原图(服务端接受的格式)不重新编码,JPEG、PNG 在字节层面去掉元数据(JPEG 只留方向);
//! - 像素超过 max_pixels、文件或结果超过 max_bytes 的按文件发送(image_too_large),解码不了的按文件发送(unsupported_type)。
//!
//! 处理后的图片写入临时目录(temp: true),进入发送队列时由 persist 移走。

use std::fs::{self, File};
use std::io::{BufReader, Cursor};
use std::path::Path;

use ::image::codecs::jpeg::JpegEncoder;
use ::image::codecs::png::{CompressionType, FilterType as PngFilter, PngEncoder};
use ::image::imageops::FilterType;
use ::image::metadata::Orientation;
use ::image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
use serde::{Deserialize, Serialize};

use super::read::{random_hex, read_head, sniff, HEAD_BYTES};
use crate::error::{Error, Result};

#[derive(Debug, Clone, Deserialize)]
pub struct ImageOpts {
    pub original: bool,
    pub max_side: u32,
    /// JPEG 质量,0~1。
    pub quality: f64,
    pub max_bytes: u64,
    pub max_pixels: u64,
}

/// files_prepare_image 的结果:`{ kind: 'image', ... }` 或 `{ kind: 'file', downgraded }`。
#[derive(Debug, Serialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ImageOutcome {
    Image { path: String, temp: bool, size: u64, width: u32, height: u32, format: String, content_type: String },
    File { downgraded: &'static str },
}

const TOO_LARGE: ImageOutcome = ImageOutcome::File { downgraded: "image_too_large" };
const UNSUPPORTED: ImageOutcome = ImageOutcome::File { downgraded: "unsupported_type" };

fn image_format(format: &str) -> Option<ImageFormat> {
    Some(match format {
        "jpeg" => ImageFormat::Jpeg,
        "png" => ImageFormat::Png,
        "gif" => ImageFormat::Gif,
        "webp" => ImageFormat::WebP,
        "bmp" => ImageFormat::Bmp,
        "tiff" => ImageFormat::Tiff,
        _ => return None,
    })
}

fn open_decoder(real: &Path, format: ImageFormat) -> Option<impl ImageDecoder> {
    let file = File::open(real).ok()?;
    let mut reader = ImageReader::new(BufReader::new(file));
    reader.set_format(format);
    reader.into_decoder().ok()
}

/// 只读文件头:宽高和 EXIF 方向。
fn header(real: &Path, format: ImageFormat) -> Option<(u32, u32, Orientation)> {
    let mut decoder = open_decoder(real, format)?;
    let (w, h) = decoder.dimensions();
    let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
    Some((w, h, orientation))
}

fn swaps_sides(orientation: Orientation) -> bool {
    matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH)
}

/// 图片的上传前处理。path 为调用方给的路径(不处理时原样交回),real 为检查后的真实路径。
pub fn prepare_image(path: &str, real: &Path, temp: &Path, opts: &ImageOpts) -> Result<ImageOutcome> {
    let meta = fs::metadata(real).map_err(|e| Error::from_io_read(&e))?;
    let mut file = File::open(real).map_err(|e| Error::from_io_read(&e))?;
    let head = read_head(&mut file, HEAD_BYTES).map_err(|e| Error::from_io_read(&e))?;
    drop(file);
    let format = sniff(&head);
    let accepted = matches!(format, "jpeg" | "png" | "gif" | "webp");
    let head_info = image_format(format).and_then(|f| header(real, f));
    if let Some((w, h, _)) = head_info {
        if w as u64 * h as u64 > opts.max_pixels {
            return Ok(TOO_LARGE);
        }
    }
    // GIF 不重新编码;原图只去掉元数据
    if format == "gif" || (opts.original && accepted) {
        if meta.len() > opts.max_bytes {
            return Ok(TOO_LARGE);
        }
        let Some((w, h, orientation)) = head_info else { return Ok(UNSUPPORTED) };
        // 与 Web SDK 的 imageSize 相同:JPEG 的方向为 5~8 时宽高互换(原图保留方向)
        let (width, height) = if format == "jpeg" && swaps_sides(orientation) { (h, w) } else { (w, h) };
        let content_type = super::read::content_type(format).unwrap_or("application/octet-stream").to_string();
        if format == "jpeg" || format == "png" {
            let bytes = fs::read(real).map_err(|e| Error::from_io_read(&e))?;
            let stripped = strip_metadata(&bytes, format);
            let out = write_temp(temp, if format == "jpeg" { "jpg" } else { "png" }, &stripped)?;
            return Ok(ImageOutcome::Image { path: out, temp: true, size: stripped.len() as u64, width, height, format: format.into(), content_type });
        }
        return Ok(ImageOutcome::Image { path: path.to_string(), temp: false, size: meta.len(), width, height, format: format.into(), content_type });
    }
    let image = match decode(real, format, opts.max_pixels) {
        Ok(image) => image,
        Err(outcome) => return Ok(outcome),
    };
    let (w, h) = (image.width(), image.height());
    if w as u64 * h as u64 > opts.max_pixels {
        return Ok(TOO_LARGE);
    }
    let scale = if opts.original { 1.0 } else { (opts.max_side as f64 / w.max(h) as f64).min(1.0) };
    let width = ((w as f64 * scale).round() as u32).max(1);
    let height = ((h as f64 * scale).round() as u32).max(1);
    let image = if (width, height) == (w, h) { image } else { image.resize_exact(width, height, FilterType::Lanczos3) };
    // 有透明通道的 PNG、WebP 保持 PNG,其他的 JPEG
    let keep_png = (format == "png" || format == "webp") && image.color().has_alpha();
    let bytes = encode(&image, keep_png, opts.quality)?;
    if bytes.len() as u64 > opts.max_bytes {
        return Ok(TOO_LARGE);
    }
    let (out_format, ext, content_type) = if keep_png { ("png", "png", "image/png") } else { ("jpeg", "jpg", "image/jpeg") };
    let out = write_temp(temp, ext, &bytes)?;
    Ok(ImageOutcome::Image { path: out, temp: true, size: bytes.len() as u64, width, height, format: out_format.into(), content_type: content_type.into() })
}

/// 解码并按方向转正;失败时交回应当降为文件的原因。
fn decode(real: &Path, format: &str, max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
    // HEIC、AVIF 都是 HEIF 容器:有 libheif 时由它解码(AVIF 要求装有 AV1 解码插件)
    if format == "heic" || format == "avif" {
        return heic::decode(real, max_pixels);
    }
    let Some(image_format) = image_format(format) else { return Err(UNSUPPORTED) };
    let mut decoder = open_decoder(real, image_format).ok_or(UNSUPPORTED)?;
    let (w, h) = decoder.dimensions();
    if w as u64 * h as u64 > max_pixels {
        return Err(TOO_LARGE);
    }
    let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
    let mut image = DynamicImage::from_decoder(decoder).map_err(|err| {
        log::debug!("图片解码失败:{err}");
        UNSUPPORTED
    })?;
    image.apply_orientation(orientation);
    Ok(image)
}

/// 重新编码:编码器不写任何元数据(EXIF、XMP、ICC 文本),位置等信息随之去掉。
fn encode(image: &DynamicImage, png: bool, quality: f64) -> Result<Vec<u8>> {
    let mut out = Cursor::new(Vec::new());
    let result = if png {
        let encoder = PngEncoder::new_with_quality(&mut out, CompressionType::Default, PngFilter::Adaptive);
        DynamicImage::ImageRgba8(image.to_rgba8()).write_with_encoder(encoder)
    } else {
        let q = (quality * 100.0).round().clamp(1.0, 100.0) as u8;
        DynamicImage::ImageRgb8(image.to_rgb8()).write_with_encoder(JpegEncoder::new_with_quality(&mut out, q))
    };
    result.map_err(|e| Error::internal(format!("图片编码失败:{e}")))?;
    Ok(out.into_inner())
}

fn write_temp(temp: &Path, ext: &str, bytes: &[u8]) -> Result<String> {
    fs::create_dir_all(temp).map_err(|e| Error::from_io(&e))?;
    let path = temp.join(format!("{}-image.{ext}", random_hex()));
    if let Err(err) = fs::write(&path, bytes) {
        let _ = fs::remove_file(&path);
        return Err(Error::from_io(&err));
    }
    Ok(path.to_string_lossy().into_owned())
}

/// HEIC:打开 heic 特性时用 libheif 解码(libheif 按 irot、imir 转正),否则按文件发送。
/// macOS 的 ImageIO、Windows 的 WIC 还没有接入:这两个系统上的 HEIC 按文件发送。
#[cfg(feature = "heic")]
mod heic {
    use super::*;
    use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};

    pub fn decode(real: &Path, max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
        let failed = |err: libheif_rs::HeifError| {
            log::debug!("HEIC 解码失败:{err}");
            UNSUPPORTED
        };
        let ctx = HeifContext::read_from_file(&real.to_string_lossy()).map_err(failed)?;
        let handle = ctx.primary_image_handle().map_err(failed)?;
        if handle.width() as u64 * handle.height() as u64 > max_pixels {
            return Err(TOO_LARGE);
        }
        let alpha = handle.has_alpha_channel();
        let chroma = if alpha { RgbChroma::Rgba } else { RgbChroma::Rgb };
        let image = LibHeif::new().decode(&handle, ColorSpace::Rgb(chroma), None).map_err(failed)?;
        let planes = image.planes();
        let plane = planes.interleaved.ok_or(UNSUPPORTED)?;
        let channels = if alpha { 4 } else { 3 };
        let row = plane.width as usize * channels;
        let mut pixels = Vec::with_capacity(row * plane.height as usize);
        for y in 0..plane.height as usize {
            pixels.extend_from_slice(&plane.data[y * plane.stride..y * plane.stride + row]);
        }
        let image = if alpha {
            ::image::RgbaImage::from_raw(plane.width, plane.height, pixels).map(DynamicImage::ImageRgba8)
        } else {
            ::image::RgbImage::from_raw(plane.width, plane.height, pixels).map(DynamicImage::ImageRgb8)
        };
        image.ok_or(UNSUPPORTED)
    }
}

#[cfg(not(feature = "heic"))]
mod heic {
    use super::*;

    pub fn decode(_real: &Path, _max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
        Err(UNSUPPORTED)
    }
}

/// 原图去掉元数据(同 sniff.ts 的 stripMetadata):JPEG 把 APP1 中的 EXIF 换成只含方向的最小 EXIF、删除 XMP、保留 ICC(APP2);
/// PNG 删除 eXIf、tEXt、iTXt、zTXt 块。
pub fn strip_metadata(buf: &[u8], format: &str) -> Vec<u8> {
    match format {
        "jpeg" => strip_jpeg(buf),
        "png" => strip_png(buf),
        _ => buf.to_vec(),
    }
}

fn be16(b: &[u8], i: usize) -> Option<usize> {
    Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]) as usize)
}

fn strip_jpeg(buf: &[u8]) -> Vec<u8> {
    let mut out = buf[..buf.len().min(2)].to_vec();
    let mut i = 2;
    let mut orientation: Option<u16> = None;
    let mut inserted = false;
    while i + 4 <= buf.len() && buf[i] == 0xff {
        let marker = buf[i + 1];
        if marker == 0xda {
            break; // 扫描数据开始,后面原样保留
        }
        let Some(len) = be16(buf, i + 2) else { break };
        let segment = &buf[i..(i + 2 + len).min(buf.len())];
        if marker == 0xe1 {
            if buf.get(i + 4..i + 8) == Some(b"Exif") {
                orientation = exif_orientation(buf, i + 10, len.saturating_sub(8));
            }
            // 去掉 EXIF 和 XMP(都在 APP1 中)
        } else {
            if !inserted && marker != 0xe0 {
                if let Some(o) = orientation.filter(|o| *o != 1) {
                    out.extend_from_slice(&minimal_exif(o));
                }
                inserted = true;
            }
            out.extend_from_slice(segment);
        }
        i += 2 + len;
    }
    if !inserted {
        if let Some(o) = orientation.filter(|o| *o != 1) {
            out.extend_from_slice(&minimal_exif(o));
        }
    }
    if i < buf.len() {
        out.extend_from_slice(&buf[i..]);
    }
    out
}

/// TIFF 结构中的 Orientation(0x0112)。
fn exif_orientation(buf: &[u8], start: usize, len: usize) -> Option<u16> {
    let tiff = buf.get(start..(start + len).min(buf.len()))?;
    let little = tiff.get(0..2)? == [0x49, 0x49];
    let u16_at = |i: usize| -> Option<u16> {
        let b = [*tiff.get(i)?, *tiff.get(i + 1)?];
        Some(if little { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
    };
    let u32_at = |i: usize| -> Option<u32> {
        let b = [*tiff.get(i)?, *tiff.get(i + 1)?, *tiff.get(i + 2)?, *tiff.get(i + 3)?];
        Some(if little { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
    };
    let ifd = u32_at(4)? as usize;
    let count = u16_at(ifd)? as usize;
    for k in 0..count {
        let entry = ifd + 2 + k * 12;
        if u16_at(entry)? == 0x0112 {
            return u16_at(entry + 8);
        }
    }
    None
}

/// 只含方向一项的 EXIF(APP1)。
fn minimal_exif(orientation: u16) -> Vec<u8> {
    let tiff = [0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation as u8, 0, 0, 0, 0, 0, 0];
    let mut body = b"Exif\0\0".to_vec();
    body.extend_from_slice(&tiff);
    let len = body.len() + 2;
    let mut out = vec![0xff, 0xe1, (len >> 8) as u8, (len & 0xff) as u8];
    out.extend_from_slice(&body);
    out
}

fn strip_png(buf: &[u8]) -> Vec<u8> {
    let mut out = buf[..buf.len().min(8)].to_vec();
    let mut i = 8;
    while i + 12 <= buf.len() {
        let len = u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]) as usize;
        let kind = &buf[i + 4..i + 8];
        let end = i.saturating_add(12).saturating_add(len);
        if !matches!(kind, b"eXIf" | b"tEXt" | b"iTXt" | b"zTXt") {
            out.extend_from_slice(&buf[i..end.min(buf.len())]);
        }
        i = end;
        if kind == b"IEND" {
            break;
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use ::image::{Rgb, RgbImage, Rgba, RgbaImage};

    /// 左半红、右半蓝的 JPEG,在 SOI 之后插入带方向(和一项 GPS 标记)的 APP1 EXIF。
    fn jpeg_with_orientation(w: u32, h: u32, orientation: u16) -> Vec<u8> {
        let img = RgbImage::from_fn(w, h, |x, _| if x < w / 2 { Rgb([255, 0, 0]) } else { Rgb([0, 0, 255]) });
        let mut plain = Cursor::new(Vec::new());
        DynamicImage::ImageRgb8(img).write_with_encoder(JpegEncoder::new_with_quality(&mut plain, 95)).unwrap();
        let plain = plain.into_inner();
        // TIFF(大端):IFD0 两项:Orientation、GPSInfo 指针
        let mut tiff = vec![0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8, 0, 2];
        tiff.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1]);
        tiff.extend_from_slice(&orientation.to_be_bytes());
        tiff.extend_from_slice(&[0, 0]);
        tiff.extend_from_slice(&[0x88, 0x25, 0, 4, 0, 0, 0, 1, 0, 0, 0, 0]);
        tiff.extend_from_slice(&[0, 0, 0, 0]);
        let mut body = b"Exif\0\0".to_vec();
        body.extend_from_slice(&tiff);
        let len = body.len() + 2;
        let mut out = vec![0xff, 0xd8, 0xff, 0xe1, (len >> 8) as u8, len as u8];
        out.extend_from_slice(&body);
        out.extend_from_slice(&plain[2..]);
        out
    }

    fn opts(original: bool) -> ImageOpts {
        ImageOpts { original, max_side: 2048, quality: 0.85, max_bytes: 20 * 1024 * 1024, max_pixels: 50_000_000 }
    }

    fn has_exif(bytes: &[u8]) -> bool {
        bytes.windows(6).any(|w| w == b"Exif\0\0")
    }

    #[test]
    fn rotates_and_strips_exif() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("photo.jpg");
        fs::write(&src, jpeg_with_orientation(40, 20, 6)).unwrap();
        assert!(has_exif(&fs::read(&src).unwrap()));
        let temp = dir.path().join("temp");
        let out = prepare_image(src.to_str().unwrap(), &src, &temp, &opts(false)).unwrap();
        let ImageOutcome::Image { path, temp: is_temp, width, height, format, content_type, size } = out else { panic!("{out:?}") };
        assert!(is_temp && path.starts_with(temp.to_str().unwrap()));
        assert_eq!((width, height, format.as_str(), content_type.as_str()), (20, 40, "jpeg", "image/jpeg"));
        let bytes = fs::read(&path).unwrap();
        assert_eq!(size, bytes.len() as u64);
        assert!(!has_exif(&bytes), "输出不应带 EXIF");
        // 方向 6 为顺时针转 90 度:原图的左边(红)到了上边
        let decoded = ::image::load_from_memory(&bytes).unwrap().to_rgb8();
        assert_eq!(decoded.dimensions(), (20, 40));
        let top = decoded.get_pixel(10, 5);
        let bottom = decoded.get_pixel(10, 35);
        assert!(top[0] > 200 && top[2] < 60, "上边应为红色:{top:?}");
        assert!(bottom[2] > 200 && bottom[0] < 60, "下边应为蓝色:{bottom:?}");
    }

    #[test]
    fn original_keeps_pixels_and_orientation_only() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("photo.jpg");
        let original = jpeg_with_orientation(40, 20, 6);
        fs::write(&src, &original).unwrap();
        let out = prepare_image(src.to_str().unwrap(), &src, dir.path(), &opts(true)).unwrap();
        let ImageOutcome::Image { path, width, height, .. } = out else { panic!() };
        assert_eq!((width, height), (20, 40));
        let bytes = fs::read(&path).unwrap();
        // 原来的 EXIF(含 GPS 指针)没有了,只剩只含方向的最小 EXIF
        let source_exif = &original[4..original.len().min(4 + 46)];
        assert!(!bytes.windows(source_exif.len()).any(|w| w == source_exif));
        let minimal = minimal_exif(6);
        assert_eq!(bytes.windows(6).filter(|w| *w == b"Exif\0\0").count(), 1);
        let at = bytes.windows(minimal.len()).position(|w| w == minimal.as_slice()).expect("应有最小 EXIF");
        assert_eq!(exif_orientation(&bytes, at + 10, 26), Some(6));
        // 压缩数据原样保留
        assert!(bytes.ends_with(&original[original.len() - 100..]));
    }

    #[test]
    fn scales_and_keeps_png_with_alpha() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("wide.png");
        let img = RgbaImage::from_fn(3000, 100, |x, _| Rgba([0, 0, 0, (x % 256) as u8]));
        img.save(&src).unwrap();
        let out = prepare_image(src.to_str().unwrap(), &src, dir.path(), &opts(false)).unwrap();
        let ImageOutcome::Image { width, height, format, .. } = out else { panic!() };
        assert_eq!((width, height, format.as_str()), (2048, 68, "png"));
        // 没有透明通道的 PNG 转为 JPEG
        let opaque = dir.path().join("opaque.png");
        RgbImage::from_pixel(10, 10, Rgb([1, 2, 3])).save(&opaque).unwrap();
        let out = prepare_image(opaque.to_str().unwrap(), &opaque, dir.path(), &opts(false)).unwrap();
        assert!(matches!(out, ImageOutcome::Image { ref format, .. } if format == "jpeg"));
    }

    #[test]
    fn downgrades() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("photo.jpg");
        fs::write(&src, jpeg_with_orientation(40, 20, 1)).unwrap();
        let mut small = opts(false);
        small.max_pixels = 100;
        assert_eq!(prepare_image("p", &src, dir.path(), &small).unwrap(), TOO_LARGE);
        let mut tiny = opts(false);
        tiny.max_bytes = 10;
        assert_eq!(prepare_image("p", &src, dir.path(), &tiny).unwrap(), TOO_LARGE);
        let junk = dir.path().join("junk.jpg");
        fs::write(&junk, b"not an image at all").unwrap();
        assert_eq!(prepare_image("p", &junk, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
        let broken = dir.path().join("broken.jpg");
        fs::write(&broken, [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0]).unwrap();
        assert_eq!(prepare_image("p", &broken, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
        #[cfg(not(feature = "heic"))]
        {
            let heic = dir.path().join("a.heic");
            fs::write(&heic, b"\0\0\0\x18ftypheic\0\0\0\0mif1heic").unwrap();
            assert_eq!(prepare_image("p", &heic, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
        }
    }

    #[test]
    fn gif_is_not_reencoded() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("a.gif");
        RgbaImage::from_pixel(8, 6, Rgba([1, 2, 3, 255])).save(&src).unwrap();
        let out = prepare_image("/x/a.gif", &src, dir.path(), &opts(false)).unwrap();
        assert_eq!(
            out,
            ImageOutcome::Image { path: "/x/a.gif".into(), temp: false, size: fs::metadata(&src).unwrap().len(), width: 8, height: 6, format: "gif".into(), content_type: "image/gif".into() }
        );
    }

    #[test]
    fn png_text_chunks_removed() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("a.png");
        RgbImage::from_pixel(2, 2, Rgb([9, 9, 9])).save(&src).unwrap();
        let bytes = fs::read(&src).unwrap();
        // 在 IHDR 之后插入一个 tEXt 块
        let mut with_text = bytes[..33].to_vec();
        let text = b"Commentlocation";
        with_text.extend_from_slice(&(text.len() as u32).to_be_bytes());
        with_text.extend_from_slice(b"tEXt");
        with_text.extend_from_slice(text);
        with_text.extend_from_slice(&[0, 0, 0, 0]);
        with_text.extend_from_slice(&bytes[33..]);
        assert_eq!(strip_png(&with_text), bytes);
    }
}