Skip to main content

car/
image_conv.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use crate::car::{CSIItem, CarError, CarResult};
5#[cfg(feature = "image")]
6use crate::decode::{DecodeOptions, DecodeStage, OrientationPolicy, RenditionData};
7#[cfg(feature = "image")]
8use crate::metadata::ExifOrientation;
9use crate::model::Encoding;
10use crate::model::rendition::{CompressionType, Rendition};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RgbaPayload {
14    pub width: u32,
15    pub height: u32,
16    pub rgba: Vec<u8>,
17}
18
19#[cfg(all(test, feature = "image", target_arch = "wasm32"))]
20mod wasm_image_tests {
21    use std::io::Cursor;
22
23    use image::ImageDecoder;
24    use wasm_bindgen_test::wasm_bindgen_test;
25
26    use crate::Car;
27
28    const ASSETS_CAR_BYTES: &[u8] = include_bytes!(concat!(
29        env!("CARGO_MANIFEST_DIR"),
30        "/../car-tests/data/Assets.car"
31    ));
32
33    #[wasm_bindgen_test]
34    fn png_bytes_omit_host_icc_profiles_on_wasm() {
35        let car = Car::from_bytes(ASSETS_CAR_BYTES.to_vec()).expect("load archive from bytes");
36        let item = car
37            .rendtions_with_name("2016_coin1")
38            .and_then(|items| items.first())
39            .expect("missing 2016_coin1 rendition");
40
41        let png = item.png_bytes().expect("encode png bytes");
42        let mut decoder =
43            image::codecs::png::PngDecoder::new(Cursor::new(png)).expect("decode png bytes");
44
45        assert!(
46            decoder
47                .icc_profile()
48                .expect("reading ICC metadata should succeed")
49                .is_none(),
50            "wasm path should not read host ICC profile files"
51        );
52    }
53}
54
55#[cfg(feature = "image")]
56fn resolve_bytes_per_row(item: &CSIItem, bpp: u32) -> CarResult<u32> {
57    let min_row = item.header.width * bpp;
58    if let Some(bytes_per_row) = item.bytes_per_row_tlv() {
59        if bytes_per_row < min_row {
60            return Err(CarError::DecodeFailed(
61                "bytes_per_row smaller than minimum row size".to_string(),
62            ));
63        }
64        return Ok(bytes_per_row);
65    }
66    Ok(min_row)
67}
68
69#[cfg(feature = "image")]
70fn strip_stride(data: Vec<u8>, width: u32, height: u32, bpp: u32, stride: u32) -> Vec<u8> {
71    let row_bytes = (width * bpp) as usize;
72    let stride = stride as usize;
73    if row_bytes == stride {
74        return data;
75    }
76    let mut out = Vec::with_capacity(row_bytes * height as usize);
77    for row in 0..height as usize {
78        let start = row * stride;
79        let end = start + row_bytes;
80        if end <= data.len() {
81            out.extend_from_slice(&data[start..end]);
82        }
83    }
84    out
85}
86
87#[cfg(feature = "image")]
88fn is_deepmap2(item: &CSIItem) -> bool {
89    matches!(
90        &item.header.rendition,
91        Some(Rendition::ThemeCBCK(cbck)) if matches!(cbck.compression_type, CompressionType::Deepmap2)
92    )
93}
94
95pub(crate) fn is_hevc_compressed(item: &CSIItem) -> bool {
96    matches!(
97        &item.header.rendition,
98        Some(Rendition::ThemeCBCK(cbck)) if matches!(cbck.compression_type, CompressionType::HEVC)
99    )
100}
101
102fn hevc_source_bytes(item: &CSIItem) -> CarResult<Option<Cow<'_, [u8]>>> {
103    let Some(Rendition::ThemeCBCK(cbck)) = &item.header.rendition else {
104        return Ok(None);
105    };
106    if !matches!(cbck.compression_type, CompressionType::HEVC) {
107        return Ok(None);
108    }
109    if cbck.chunks.len() != 1 {
110        return Err(CarError::DecodeFailed(format!(
111            "HEVC raw export expects a single chunk, got {}",
112            cbck.chunks.len()
113        )));
114    }
115
116    heic_container_from_hevc_chunk(cbck.chunks[0].raw_data.as_slice())
117        .map(|data| Some(Cow::Borrowed(data)))
118}
119
120fn heic_container_from_hevc_chunk(raw: &[u8]) -> CarResult<&[u8]> {
121    const WRAPPER_LEN: usize = 8;
122    const MIN_FTYP_LEN: usize = 16;
123
124    if raw.len() < WRAPPER_LEN + MIN_FTYP_LEN {
125        return Err(CarError::DecodeFailed(
126            "HEVC chunk too short to contain HEIC payload".to_string(),
127        ));
128    }
129
130    let declared_len = u32::from_le_bytes(raw[4..8].try_into().unwrap()) as usize;
131    let body_len = raw.len() - WRAPPER_LEN;
132    if declared_len != body_len {
133        return Err(CarError::DecodeFailed(format!(
134            "HEVC chunk length mismatch: declared {}, got {}",
135            declared_len, body_len
136        )));
137    }
138
139    let body = &raw[WRAPPER_LEN..];
140    let ftyp_len = u32::from_be_bytes(body[0..4].try_into().unwrap()) as usize;
141    if ftyp_len < MIN_FTYP_LEN || ftyp_len > body.len() {
142        return Err(CarError::DecodeFailed(format!(
143            "HEVC chunk has invalid ftyp box length: {ftyp_len}"
144        )));
145    }
146    if &body[4..8] != b"ftyp" {
147        return Err(CarError::DecodeFailed(
148            "HEVC chunk does not start with HEIC ftyp box".to_string(),
149        ));
150    }
151    if !ftyp_has_heif_brand(&body[..ftyp_len]) {
152        return Err(CarError::DecodeFailed(
153            "HEVC chunk ftyp box does not advertise HEIC/HEIF brand".to_string(),
154        ));
155    }
156
157    Ok(body)
158}
159
160fn ftyp_has_heif_brand(ftyp_box: &[u8]) -> bool {
161    is_heif_brand(&ftyp_box[8..12]) || ftyp_box[16..].chunks_exact(4).any(is_heif_brand)
162}
163
164fn is_heif_brand(brand: &[u8]) -> bool {
165    matches!(
166        brand,
167        b"heic"
168            | b"heix"
169            | b"hevc"
170            | b"hevx"
171            | b"heim"
172            | b"heis"
173            | b"mif1"
174            | b"msf1"
175            | b"MiHE"
176            | b"MiHB"
177    )
178}
179
180#[cfg(feature = "image")]
181fn unpremultiply_rgba8_in_place(pixels: &mut [u8]) {
182    for px in pixels.chunks_exact_mut(4) {
183        let alpha = px[3];
184        if alpha == 0 || alpha == u8::MAX {
185            continue;
186        }
187
188        for channel in &mut px[..3] {
189            let value = (((*channel as u32) * u8::MAX as u32) + (alpha as u32 / 2)) / alpha as u32;
190            *channel = value.min(u8::MAX as u32) as u8;
191        }
192    }
193}
194
195#[cfg(feature = "image")]
196fn unpremultiply_rgba16_in_place(pixels: &mut [u16]) {
197    for px in pixels.chunks_exact_mut(4) {
198        let alpha = px[3];
199        if alpha == 0 || alpha == u16::MAX {
200            continue;
201        }
202
203        for channel in &mut px[..3] {
204            let value = (((*channel as u64) * u16::MAX as u64) + (alpha as u64 / 2)) / alpha as u64;
205            *channel = value.min(u16::MAX as u64) as u16;
206        }
207    }
208}
209
210/// Crop `img` to the sub-region described by `rect`.
211///
212/// `RenditionTypeReference.(x, y, width, height)` stores a CGRect-style frame whose
213/// origin is measured from the source image's bottom-left corner. `image` uses a
214/// top-left origin, so `y` must be flipped before cropping.
215#[cfg(feature = "image")]
216fn apply_reference_frame(
217    img: image::DynamicImage,
218    rect: &super::ReferenceRect,
219) -> CarResult<image::DynamicImage> {
220    let img_width = img.width();
221    let img_height = img.height();
222    let right = rect.x.checked_add(rect.width);
223    let top = rect.y.checked_add(rect.height);
224    match (right, top) {
225        (Some(right), Some(top)) if right <= img_width && top <= img_height => {
226            let crop_y = img_height - top;
227            Ok(img.crop_imm(rect.x, crop_y, rect.width, rect.height))
228        }
229        _ => Err(CarError::DecodeFailed(format!(
230            "reference crop rect (x={}, y={}, w={}, h={}) exceeds source image {}x{}",
231            rect.x, rect.y, rect.width, rect.height, img_width, img_height
232        ))),
233    }
234}
235
236#[cfg(feature = "image")]
237#[cfg(not(target_arch = "wasm32"))]
238fn png_icc_profile(item: &CSIItem) -> Option<Vec<u8>> {
239    use crate::model::{ColorModel, rendition::AttributeType};
240    use std::sync::OnceLock;
241
242    static SRGB_PROFILE: OnceLock<Option<Vec<u8>>> = OnceLock::new();
243    static P3_PROFILE: OnceLock<Option<Vec<u8>>> = OnceLock::new();
244
245    let is_display_p3 = item.header.color_model == ColorModel::RGBP3
246        || item
247            .attrs
248            .iter()
249            .any(|attr| attr.name == AttributeType::DisplayGamut && attr.val == 1);
250
251    if is_display_p3 {
252        P3_PROFILE
253            .get_or_init(|| std::fs::read("/System/Library/ColorSync/Profiles/Display P3.icc").ok())
254            .clone()
255    } else {
256        SRGB_PROFILE
257            .get_or_init(|| {
258                std::fs::read("/System/Library/ColorSync/Profiles/sRGB Profile.icc").ok()
259            })
260            .clone()
261    }
262}
263
264#[cfg(feature = "image")]
265#[cfg(target_arch = "wasm32")]
266fn png_icc_profile(_item: &CSIItem) -> Option<Vec<u8>> {
267    None
268}
269
270#[cfg(feature = "image")]
271fn encode_png_with_icc<W>(item: &CSIItem, img: &image::DynamicImage, writer: W) -> CarResult<()>
272where
273    W: std::io::Write,
274{
275    use image::ImageEncoder;
276    let mut encoder = image::codecs::png::PngEncoder::new(writer);
277    if let Some(profile) = png_icc_profile(item) {
278        let _ = encoder.set_icc_profile(profile);
279    }
280    encoder.write_image(
281        img.as_bytes(),
282        img.width(),
283        img.height(),
284        img.color().into(),
285    )?;
286    Ok(())
287}
288
289#[cfg(feature = "image")]
290fn write_png_with_icc(item: &CSIItem, img: &image::DynamicImage, path: &Path) -> CarResult<()> {
291    let file = std::fs::File::create(path)?;
292    let writer = std::io::BufWriter::new(file);
293    encode_png_with_icc(item, img, writer)
294}
295
296#[cfg(feature = "image")]
297fn render_image_with_crops(
298    item: &CSIItem,
299    crops: &[super::ReferenceRect],
300) -> CarResult<image::DynamicImage> {
301    item.to_image_with_crops_and_options(crops, &DecodeOptions::default())
302}
303
304#[cfg(feature = "image")]
305fn apply_exif_orientation(
306    item: &CSIItem,
307    options: &DecodeOptions,
308    img: image::DynamicImage,
309) -> image::DynamicImage {
310    if !matches!(options.orientation_policy, OrientationPolicy::ApplyExif) {
311        return img;
312    }
313
314    match item.exif_orientation() {
315        Some(ExifOrientation::Mirrored) => img.fliph(),
316        Some(ExifOrientation::Rotated180) => img.rotate180(),
317        Some(ExifOrientation::Rotated180Mirrored) => img.rotate180().fliph(),
318        Some(ExifOrientation::Rotated90) => img.rotate90(),
319        Some(ExifOrientation::Rotated90Mirrored) => img.rotate90().fliph(),
320        Some(ExifOrientation::Rotated270) => img.rotate270(),
321        Some(ExifOrientation::Rotated270Mirrored) => img.rotate270().fliph(),
322        Some(ExifOrientation::None | ExifOrientation::Normal | ExifOrientation::Unknown(_))
323        | None => img,
324    }
325}
326
327#[cfg(feature = "image")]
328fn encode_png_bytes(item: &CSIItem, img: &image::DynamicImage) -> CarResult<Vec<u8>> {
329    let mut buf = Vec::new();
330    encode_png_with_icc(item, img, &mut buf)?;
331    Ok(buf)
332}
333
334#[cfg(feature = "image")]
335fn is_png_path(path: &Path) -> bool {
336    path.extension()
337        .and_then(|ext| ext.to_str())
338        .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
339}
340
341/// 热路径 PNG 直写:对 Deepmap2/ARGB/GA8/ARGB16 跳过 DynamicImage 中间层,
342/// 将解码后的像素缓冲直接送入 PngEncoder,减少中间分配与多遍扫描。
343///
344/// 返回 `Ok(true)` 表示已处理,`Ok(false)` 表示当前编码不在直接路径上,
345/// 调用方应回退到 `to_image()` + `save_image()` 通用路径。
346#[cfg(feature = "image")]
347fn save_image_to_png_direct(item: &CSIItem, path: &Path) -> CarResult<bool> {
348    use image::{ExtendedColorType, ImageEncoder};
349
350    // 提前检查编码是否在直写白名单中,避免对 GRAY/GA16/RGB5/JPEG/WEBP 等编码调用
351    // decode_data(),随后又回退到 to_image() 导致整段数据被二次解码。
352    let is_deepmap2 = is_deepmap2(item);
353    if !is_deepmap2 {
354        match item.header.encoding {
355            Encoding::ARGB | Encoding::GA8 | Encoding::ARGB16 => {}
356            _ => return Ok(false),
357        }
358    }
359
360    let w = item.header.width;
361    let h = item.header.height;
362
363    let data = match item.decode_data()? {
364        Some(RenditionData::Image { data }) => data,
365        _ => return Ok(false),
366    };
367
368    // 每种编码产出 (像素字节, 颜色类型, 输出宽, 输出高)
369    let (pixels, color_type, out_w, out_h): (Vec<u8>, ExtendedColorType, u32, u32) = if is_deepmap2
370    {
371        // Deepmap2 解码输出恒为 RGBA8888;仅在长度与期望不符时才做 stride 剥离
372        let row_bytes = (w as usize).saturating_mul(4);
373        if row_bytes == 0 {
374            return Ok(false);
375        }
376        let mut pixels = if data.len() == row_bytes * h as usize {
377            data
378        } else {
379            let stride = resolve_bytes_per_row(item, 4).unwrap_or(w * 4);
380            strip_stride(data, w, h, 4, stride)
381        };
382        if pixels.len() % row_bytes != 0 {
383            return Err(CarError::DecodeFailed(format!(
384                "RGBA buffer size mismatch: expected a multiple of {row_bytes} bytes, got {}",
385                pixels.len()
386            )));
387        }
388        // CoreUI stores Deepmap2 RGBA payloads with premultiplied alpha on
389        // semi-transparent edge pixels; convert to straight alpha before saving.
390        unpremultiply_rgba8_in_place(&mut pixels);
391        let inferred_h = (pixels.len() / row_bytes) as u32;
392        (pixels, ExtendedColorType::Rgba8, w, inferred_h)
393    } else {
394        match item.header.encoding {
395            Encoding::ARGB => {
396                // 单遍:stride 剥离 + BGRA(预乘 alpha)→RGBA(直 alpha)
397                let stride = resolve_bytes_per_row(item, 4)? as usize;
398                let row_bytes = (w * 4) as usize;
399                let mut pixels = Vec::with_capacity(row_bytes * h as usize);
400                for row in 0..h as usize {
401                    let start = row * stride;
402                    let end = start + row_bytes;
403                    if end > data.len() {
404                        break;
405                    }
406                    for chunk in data[start..end].chunks_exact(4) {
407                        pixels.push(chunk[2]); // R (BGRA→RGBA)
408                        pixels.push(chunk[1]); // G
409                        pixels.push(chunk[0]); // B
410                        pixels.push(chunk[3]); // A
411                    }
412                }
413                let expected = row_bytes * h as usize;
414                if pixels.len() < expected {
415                    return Err(CarError::DecodeFailed(format!(
416                        "ARGB pixel buffer truncated: expected {expected} bytes, got {}",
417                        pixels.len()
418                    )));
419                }
420                unpremultiply_rgba8_in_place(&mut pixels);
421                (pixels, ExtendedColorType::Rgba8, w, h)
422            }
423            Encoding::GA8 => {
424                let stride = resolve_bytes_per_row(item, 2)?;
425                let pixels = strip_stride(data, w, h, 2, stride);
426                let expected = w as usize * h as usize * 2;
427                if pixels.len() < expected {
428                    return Err(CarError::DecodeFailed(format!(
429                        "GA8 pixel buffer truncated: expected {expected} bytes, got {}",
430                        pixels.len()
431                    )));
432                }
433                (pixels, ExtendedColorType::La8, w, h)
434            }
435            Encoding::ARGB16 => {
436                // 单遍:BGRA16LE(预乘 alpha)→RGBA16NE(直 alpha)
437                // PngEncoder 的 Rgba16 颜色类型要求本机字节序 u16,不能直接透传 LE 字节。
438                let stride = resolve_bytes_per_row(item, 8)? as usize;
439                let row_bytes = (w * 8) as usize; // 4 通道 × 2 字节
440                let mut pixels: Vec<u8> = Vec::with_capacity(w as usize * h as usize * 8);
441                for row in 0..h as usize {
442                    let row_start = row * stride;
443                    if row_start + row_bytes > data.len() {
444                        break;
445                    }
446                    for px in data[row_start..row_start + row_bytes].chunks_exact(8) {
447                        // BGRA16LE → RGBA16NE,通道重排与反预乘在同一遍完成
448                        let r = u16::from_le_bytes([px[4], px[5]]);
449                        let g = u16::from_le_bytes([px[2], px[3]]);
450                        let b = u16::from_le_bytes([px[0], px[1]]);
451                        let a = u16::from_le_bytes([px[6], px[7]]);
452                        let (r, g, b) = if a == 0 || a == u16::MAX {
453                            (r, g, b)
454                        } else {
455                            let up = |c: u16| -> u16 {
456                                ((c as u64 * u16::MAX as u64 + a as u64 / 2) / a as u64)
457                                    .min(u16::MAX as u64) as u16
458                            };
459                            (up(r), up(g), up(b))
460                        };
461                        pixels.extend_from_slice(&r.to_ne_bytes());
462                        pixels.extend_from_slice(&g.to_ne_bytes());
463                        pixels.extend_from_slice(&b.to_ne_bytes());
464                        pixels.extend_from_slice(&a.to_ne_bytes());
465                    }
466                }
467                let expected = w as usize * h as usize * 8;
468                if pixels.len() < expected {
469                    return Err(CarError::DecodeFailed(format!(
470                        "ARGB16 pixel buffer truncated: expected {expected} bytes, got {}",
471                        pixels.len()
472                    )));
473                }
474                (pixels, ExtendedColorType::Rgba16, w, h)
475            }
476            _ => return Ok(false),
477        }
478    };
479
480    let file = std::fs::File::create(path)?;
481    let writer = std::io::BufWriter::new(file);
482    let mut encoder = image::codecs::png::PngEncoder::new(writer);
483    if let Some(profile) = png_icc_profile(item) {
484        let _ = encoder.set_icc_profile(profile);
485    }
486    encoder.write_image(&pixels, out_w, out_h, color_type)?;
487    Ok(true)
488}
489
490#[cfg(feature = "image")]
491impl CSIItem {
492    pub fn to_image(&self) -> CarResult<image::DynamicImage> {
493        self.to_image_with_options(&DecodeOptions::default())
494    }
495
496    pub fn to_image_with_options(&self, options: &DecodeOptions) -> CarResult<image::DynamicImage> {
497        self.to_image_with_crops_and_options(&[], options)
498    }
499
500    /// Decode the source, crop in innermost-first order, then apply its EXIF orientation.
501    ///
502    /// Crop coordinates refer to the unoriented source. The image conversion budget
503    /// applies before cropping, so a small crop cannot bypass the source budget.
504    pub fn to_image_with_crops_and_options(
505        &self,
506        crops: &[super::ReferenceRect],
507        options: &DecodeOptions,
508    ) -> CarResult<image::DynamicImage> {
509        let source_options = DecodeOptions {
510            orientation_policy: OrientationPolicy::Ignore,
511            ..options.clone()
512        };
513        let img = self.to_image_unoriented(&source_options)?;
514        options.check_output_bytes(img.as_bytes().len(), DecodeStage::ImageConversion)?;
515        let mut img = img;
516        for rect in crops {
517            img = apply_reference_frame(img, rect)?;
518        }
519        Ok(apply_exif_orientation(self, options, img))
520    }
521
522    fn to_image_unoriented(&self, options: &DecodeOptions) -> CarResult<image::DynamicImage> {
523        use image::{DynamicImage, ImageBuffer};
524
525        let data = match self.decode_data_with_options(options)? {
526            Some(RenditionData::Image { data }) => data,
527            _ => {
528                return Err(CarError::UnsupportedEncoding(self.header.encoding));
529            }
530        };
531
532        let w = self.header.width;
533        let h = self.header.height;
534
535        // Deepmap2 special case: always RGBA8888
536        if is_deepmap2(self) {
537            let row_bytes = (w as usize).saturating_mul(4);
538            let header_len = row_bytes.saturating_mul(h as usize);
539            let mut pixels = if data.len() == header_len {
540                data
541            } else {
542                // Deepmap2 already returns a packed RGBA buffer. Some assets still carry a
543                // BytesPerRow TLV for the original source pixel format, which is not applicable
544                // after deepmap2 expansion. Only use stride stripping as a recovery path when the
545                // decoded byte count does not already match the target RGBA image size.
546                let stride = resolve_bytes_per_row(self, 4).unwrap_or(w * 4);
547                strip_stride(data, w, h, 4, stride)
548            };
549            if row_bytes == 0 || pixels.len() % row_bytes != 0 {
550                return Err(CarError::DecodeFailed(format!(
551                    "RGBA buffer size mismatch: expected a multiple of {row_bytes} bytes, got {}",
552                    pixels.len()
553                )));
554            }
555            unpremultiply_rgba8_in_place(&mut pixels);
556            let inferred_h = (pixels.len() / row_bytes) as u32;
557            let buf = ImageBuffer::from_raw(w, inferred_h, pixels)
558                .ok_or_else(|| CarError::DecodeFailed("RGBA buffer size mismatch".into()))?;
559            return Ok(DynamicImage::ImageRgba8(buf));
560        }
561
562        match self.header.encoding {
563            Encoding::ARGB => {
564                let stride = resolve_bytes_per_row(self, 4)?;
565                let mut pixels = strip_stride(data, w, h, 4, stride);
566                // BGRA(预乘 alpha) → RGBA(直 alpha)
567                for chunk in pixels.chunks_exact_mut(4) {
568                    chunk.swap(0, 2);
569                }
570                unpremultiply_rgba8_in_place(&mut pixels);
571                let buf = ImageBuffer::from_raw(w, h, pixels)
572                    .ok_or_else(|| CarError::DecodeFailed("ARGB buffer size mismatch".into()))?;
573                Ok(DynamicImage::ImageRgba8(buf))
574            }
575            Encoding::GRAY => {
576                let stride = resolve_bytes_per_row(self, 1)?;
577                let pixels = strip_stride(data, w, h, 1, stride);
578                let buf = ImageBuffer::from_raw(w, h, pixels)
579                    .ok_or_else(|| CarError::DecodeFailed("GRAY buffer size mismatch".into()))?;
580                Ok(DynamicImage::ImageLuma8(buf))
581            }
582            Encoding::GA8 => {
583                let stride = resolve_bytes_per_row(self, 2)?;
584                let pixels = strip_stride(data, w, h, 2, stride);
585                let buf = ImageBuffer::from_raw(w, h, pixels)
586                    .ok_or_else(|| CarError::DecodeFailed("GA8 buffer size mismatch".into()))?;
587                Ok(DynamicImage::ImageLumaA8(buf))
588            }
589            Encoding::ARGB16 => {
590                let stride = resolve_bytes_per_row(self, 8)?;
591                let stride = stride as usize;
592                let row_bytes = (w * 8) as usize; // 4 channels × 2 bytes each (BGRA16)
593                let mut pixels: Vec<u16> = Vec::with_capacity((w * h) as usize * 4);
594                for row in 0..h as usize {
595                    let row_start = row * stride;
596                    if row_start + row_bytes > data.len() {
597                        break;
598                    }
599                    // BGRA16 → RGBA16: write channels in R G B A order
600                    for px in data[row_start..row_start + row_bytes].chunks_exact(8) {
601                        let b = u16::from_le_bytes([px[0], px[1]]);
602                        let g = u16::from_le_bytes([px[2], px[3]]);
603                        let r = u16::from_le_bytes([px[4], px[5]]);
604                        let a = u16::from_le_bytes([px[6], px[7]]);
605                        pixels.push(r);
606                        pixels.push(g);
607                        pixels.push(b);
608                        pixels.push(a);
609                    }
610                }
611                unpremultiply_rgba16_in_place(&mut pixels);
612                let buf = ImageBuffer::from_raw(w, h, pixels)
613                    .ok_or_else(|| CarError::DecodeFailed("ARGB16 buffer size mismatch".into()))?;
614                Ok(DynamicImage::ImageRgba16(buf))
615            }
616            Encoding::GA16 => {
617                let stride = resolve_bytes_per_row(self, 4)?;
618                let stride = stride as usize;
619                let row_bytes = (w * 4) as usize; // 2 channels × 2 bytes each
620                let mut pixels: Vec<u16> = Vec::with_capacity((w * h) as usize * 2);
621                for row in 0..h as usize {
622                    let row_start = row * stride;
623                    if row_start + row_bytes > data.len() {
624                        break;
625                    }
626                    for px in data[row_start..row_start + row_bytes].chunks_exact(4) {
627                        pixels.push(u16::from_le_bytes([px[0], px[1]]));
628                        pixels.push(u16::from_le_bytes([px[2], px[3]]));
629                    }
630                }
631                let buf = ImageBuffer::from_raw(w, h, pixels)
632                    .ok_or_else(|| CarError::DecodeFailed("GA16 buffer size mismatch".into()))?;
633                Ok(DynamicImage::ImageLumaA16(buf))
634            }
635            Encoding::RGB5 => {
636                let stride = resolve_bytes_per_row(self, 2)?;
637                let stripped = strip_stride(data, w, h, 2, stride);
638                // XRGB1555: bit layout [x r4 r3 r2 r1 r0 g4 g3 | g2 g1 g0 b4 b3 b2 b1 b0]
639                let mut pixels = Vec::with_capacity((w * h * 3) as usize);
640                for pair in stripped.chunks_exact(2) {
641                    let val = u16::from_le_bytes([pair[0], pair[1]]);
642                    let r5 = ((val >> 10) & 0x1F) as u8;
643                    let g5 = ((val >> 5) & 0x1F) as u8;
644                    let b5 = (val & 0x1F) as u8;
645                    pixels.push((r5 << 3) | (r5 >> 2));
646                    pixels.push((g5 << 3) | (g5 >> 2));
647                    pixels.push((b5 << 3) | (b5 >> 2));
648                }
649                let buf = ImageBuffer::from_raw(w, h, pixels)
650                    .ok_or_else(|| CarError::DecodeFailed("RGB5 buffer size mismatch".into()))?;
651                Ok(DynamicImage::ImageRgb8(buf))
652            }
653            Encoding::JPEG | Encoding::WEBP => Ok(image::load_from_memory(&data)?),
654            _ => Err(CarError::UnsupportedEncoding(self.header.encoding)),
655        }
656    }
657
658    pub fn rgba_bytes(&self) -> CarResult<RgbaPayload> {
659        self.rgba_bytes_with_crops(&[])
660    }
661
662    pub fn rgba_bytes_with_crops(&self, crops: &[super::ReferenceRect]) -> CarResult<RgbaPayload> {
663        self.rgba_bytes_with_crops_and_options(crops, &DecodeOptions::default())
664    }
665
666    pub fn rgba_bytes_with_crops_and_options(
667        &self,
668        crops: &[super::ReferenceRect],
669        options: &DecodeOptions,
670    ) -> CarResult<RgbaPayload> {
671        let img = self.to_image_with_crops_and_options(crops, options)?;
672        let rgba_len = (img.width() as usize)
673            .checked_mul(img.height() as usize)
674            .and_then(|len| len.checked_mul(4))
675            .ok_or_else(|| CarError::DecodeFailed("RGBA output size overflow".into()))?;
676        options.check_output_bytes(rgba_len, DecodeStage::ImageConversion)?;
677        let img = img.to_rgba8();
678        Ok(RgbaPayload {
679            width: img.width(),
680            height: img.height(),
681            rgba: img.into_raw(),
682        })
683    }
684
685    pub fn png_bytes(&self) -> CarResult<Vec<u8>> {
686        self.png_bytes_with_crops(&[])
687    }
688
689    pub fn png_bytes_with_crops(&self, crops: &[super::ReferenceRect]) -> CarResult<Vec<u8>> {
690        self.png_bytes_with_crops_and_options(crops, &DecodeOptions::default())
691    }
692
693    pub fn png_bytes_with_crops_and_options(
694        &self,
695        crops: &[super::ReferenceRect],
696        options: &DecodeOptions,
697    ) -> CarResult<Vec<u8>> {
698        let img = self.to_image_with_crops_and_options(crops, options)?;
699        encode_png_bytes(self, &img)
700    }
701
702    /// Decode the image, apply reference crop rectangles in order, then save to `path`.
703    ///
704    /// `crops` must be ordered **innermost-first** (as produced by
705    /// `Car::resolve_internal_reference`).  Each rect is applied via
706    /// `apply_reference_frame`: crop the current image at
707    /// `(rect.x, rect.y, rect.width, rect.height)`.
708    pub fn save_image_with_crops(
709        &self,
710        path: impl AsRef<Path>,
711        crops: &[super::ReferenceRect],
712    ) -> CarResult<()> {
713        let path = path.as_ref();
714        let img = render_image_with_crops(self, crops)?;
715
716        if is_png_path(path) {
717            write_png_with_icc(self, &img, path)?;
718        } else {
719            img.save(path)?;
720        }
721
722        Ok(())
723    }
724
725    pub fn save_image(&self, path: impl AsRef<Path>) -> CarResult<()> {
726        let path = path.as_ref();
727
728        // PNG 热路径:对已知像素格式直接编码,跳过 DynamicImage 中间层
729        let is_png = is_png_path(path);
730        if is_png && save_image_to_png_direct(self, path)? {
731            return Ok(());
732        }
733
734        // 通用回退路径:其他格式或 PNG 中非热路径编码(GRAY/GA16/RGB5/JPEG/WEBP)
735        let img = self.to_image()?;
736
737        if is_png {
738            write_png_with_icc(self, &img, path)?;
739            return Ok(());
740        }
741
742        img.save(path)?;
743        Ok(())
744    }
745}
746
747impl CSIItem {
748    pub fn source_bytes(&self) -> CarResult<Cow<'_, [u8]>> {
749        use crate::decode::RenditionDataRef;
750
751        if let Some(bytes) = hevc_source_bytes(self)? {
752            return Ok(bytes);
753        }
754
755        let preserves_source = matches!(
756            self.header.encoding,
757            Encoding::Data
758                | Encoding::HEIF
759                | Encoding::PDF
760                | Encoding::SVG
761                | Encoding::JPEG
762                | Encoding::WEBP
763        );
764        if !preserves_source {
765            return Err(CarError::UnsupportedEncoding(self.header.encoding));
766        }
767
768        match &self.header.rendition {
769            Some(Rendition::RawData(rd)) => {
770                return crate::apple_compression::maybe_decode_lzfse_with_fallback(
771                    rd.raw_data.as_slice(),
772                )
773                .map_err(CarError::from);
774            }
775            Some(Rendition::ThemeCBCK(cbck))
776                if matches!(cbck.compression_type, CompressionType::Uncompressed)
777                    && matches!(
778                        self.header.encoding,
779                        Encoding::HEIF
780                            | Encoding::PDF
781                            | Encoding::SVG
782                            | Encoding::JPEG
783                            | Encoding::WEBP
784                    ) =>
785            {
786                let mut bytes = Vec::new();
787                for chunk in &cbck.chunks {
788                    let data = crate::apple_compression::maybe_decode_lzfse_with_fallback(
789                        chunk.raw_data.as_slice(),
790                    )?;
791                    bytes.extend_from_slice(data.as_ref());
792                }
793                return Ok(Cow::Owned(bytes));
794            }
795            _ => {}
796        }
797
798        match self.decode_data_ref()? {
799            Some(RenditionDataRef::RawData { data }) => Ok(data),
800            Some(RenditionDataRef::Image { data }) => Ok(data),
801            _ => Err(CarError::UnsupportedEncoding(self.header.encoding)),
802        }
803    }
804
805    pub fn source_bytes_owned(&self) -> CarResult<Vec<u8>> {
806        self.source_bytes().map(Cow::into_owned)
807    }
808
809    pub fn save_raw(&self, path: impl AsRef<Path>) -> CarResult<()> {
810        let path = path.as_ref();
811        let data = self.source_bytes()?;
812        std::fs::write(path, data.as_ref())?;
813        Ok(())
814    }
815}
816
817#[cfg(test)]
818mod test_helpers {
819    use std::io::Cursor;
820
821    use crate::car::CSIItem;
822    use crate::model::CSIHeader;
823    use deku::{DekuReader, reader::Reader};
824
825    pub fn build_csi_bytes(
826        width: u32,
827        height: u32,
828        encoding: &[u8; 4],
829        tlv: &[u8],
830        rendition: Option<&[u8]>,
831    ) -> Vec<u8> {
832        let mut b = Vec::new();
833        b.extend_from_slice(b"ISTC");
834        b.extend_from_slice(&1u32.to_le_bytes()); // version
835        b.extend_from_slice(&0u32.to_le_bytes()); // flags
836        b.extend_from_slice(&width.to_le_bytes());
837        b.extend_from_slice(&height.to_le_bytes());
838        b.extend_from_slice(&100u32.to_le_bytes()); // scale_factor
839        b.extend_from_slice(encoding);
840        b.extend_from_slice(&0u32.to_le_bytes()); // color_model
841        b.extend_from_slice(&0u32.to_le_bytes()); // modification_time
842        b.extend_from_slice(&1000u32.to_le_bytes()); // layout_type
843        b.extend_from_slice(&[0u8; 128]); // name
844        b.extend_from_slice(&(tlv.len() as u32).to_le_bytes());
845        b.extend_from_slice(&1u32.to_le_bytes()); // bitmap_count
846        b.extend_from_slice(&0u32.to_le_bytes()); // zero
847        b.extend_from_slice(&(rendition.map_or(0, |d| d.len()) as u32).to_le_bytes());
848        b.extend_from_slice(tlv);
849        if let Some(r) = rendition {
850            b.extend_from_slice(r);
851        }
852        b
853    }
854
855    pub fn parse_item(bytes: Vec<u8>) -> CSIItem {
856        let mut reader = Reader::new(Cursor::new(bytes));
857        let header =
858            CSIHeader::from_reader_with_ctx(&mut reader, ()).expect("CSI header should parse");
859        CSIItem {
860            attrs: Vec::new(),
861            header,
862            key_values: Box::default(),
863        }
864    }
865
866    pub fn uncompressed_rendition(data: &[u8]) -> Vec<u8> {
867        let mut b = Vec::new();
868        b.extend_from_slice(b"MLEC");
869        b.extend_from_slice(&0u32.to_le_bytes()); // version=0 single chunk
870        b.extend_from_slice(&0u32.to_le_bytes()); // Uncompressed
871        b.extend_from_slice(&(data.len() as u32).to_le_bytes());
872        b.extend_from_slice(data);
873        b
874    }
875
876    pub fn hevc_rendition(heic_body: &[u8]) -> Vec<u8> {
877        let mut chunk = Vec::new();
878        chunk.extend_from_slice(&1u32.to_le_bytes());
879        chunk.extend_from_slice(&(heic_body.len() as u32).to_le_bytes());
880        chunk.extend_from_slice(heic_body);
881
882        let mut b = Vec::new();
883        b.extend_from_slice(b"MLEC");
884        b.extend_from_slice(&0u32.to_le_bytes()); // version=0 single chunk
885        b.extend_from_slice(&9u32.to_le_bytes()); // HEVC
886        b.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
887        b.extend_from_slice(&chunk);
888        b
889    }
890
891    pub fn fake_heic_body() -> Vec<u8> {
892        let mut body = Vec::new();
893        body.extend_from_slice(&24u32.to_be_bytes());
894        body.extend_from_slice(b"ftyp");
895        body.extend_from_slice(b"heic");
896        body.extend_from_slice(&0u32.to_be_bytes());
897        body.extend_from_slice(b"mif1");
898        body.extend_from_slice(b"heic");
899        body.extend_from_slice(&8u32.to_be_bytes());
900        body.extend_from_slice(b"mdat");
901        body
902    }
903
904    pub fn raw_data_rendition(data: &[u8]) -> Vec<u8> {
905        let mut b = Vec::new();
906        b.extend_from_slice(b"DWAR");
907        b.extend_from_slice(&1u32.to_le_bytes()); // version
908        b.extend_from_slice(&(data.len() as u32).to_le_bytes());
909        b.extend_from_slice(data);
910        b
911    }
912
913    pub fn bytes_per_row_tlv(bpr: u32) -> Vec<u8> {
914        let mut b = Vec::new();
915        b.extend_from_slice(&1007u32.to_le_bytes()); // tag
916        b.extend_from_slice(&4u32.to_le_bytes()); // length
917        b.extend_from_slice(&bpr.to_le_bytes());
918        b
919    }
920
921    pub fn exif_orientation_tlv(orientation: u32) -> Vec<u8> {
922        let mut b = Vec::new();
923        b.extend_from_slice(&1006u32.to_le_bytes()); // tag
924        b.extend_from_slice(&4u32.to_le_bytes()); // length
925        b.extend_from_slice(&orientation.to_le_bytes());
926        b
927    }
928
929    pub fn deepmap2_rendition(width: u16, height: u16, pixels: &[u8]) -> Vec<u8> {
930        // Build dmp2 payload: "dmp2" + header + pixel data
931        let mut dmp2 = Vec::new();
932        dmp2.extend_from_slice(b"dmp2");
933        dmp2.extend_from_slice(&[0x01, 0x00, 0x00, 0x01]); // version=1, compression=none, format=0, pixel_type=g8
934        dmp2.extend_from_slice(&width.to_le_bytes());
935        dmp2.extend_from_slice(&height.to_le_bytes());
936        dmp2.extend_from_slice(pixels);
937        // Wrap in ThemeCBCK with compression_type=11 (Deepmap2)
938        let mut b = Vec::new();
939        b.extend_from_slice(b"MLEC");
940        b.extend_from_slice(&0u32.to_le_bytes()); // version=0 single chunk
941        b.extend_from_slice(&11u32.to_le_bytes()); // Deepmap2
942        b.extend_from_slice(&(dmp2.len() as u32).to_le_bytes());
943        b.extend_from_slice(&dmp2);
944        b
945    }
946
947    pub fn deepmap2_rendition_ga88(width: u16, height: u16, pixels: &[u8]) -> Vec<u8> {
948        let mut dmp2 = Vec::new();
949        dmp2.extend_from_slice(b"dmp2");
950        dmp2.extend_from_slice(&[0x01, 0x00, 0x00, 0x02]); // pixel_type=ga88
951        dmp2.extend_from_slice(&width.to_le_bytes());
952        dmp2.extend_from_slice(&height.to_le_bytes());
953        dmp2.extend_from_slice(pixels);
954
955        let mut b = Vec::new();
956        b.extend_from_slice(b"MLEC");
957        b.extend_from_slice(&0u32.to_le_bytes()); // version=0 single chunk
958        b.extend_from_slice(&11u32.to_le_bytes()); // Deepmap2
959        b.extend_from_slice(&(dmp2.len() as u32).to_le_bytes());
960        b.extend_from_slice(&dmp2);
961        b
962    }
963
964    pub fn make_item(width: u32, height: u32, enc: &[u8; 4], pixels: &[u8]) -> CSIItem {
965        let r = uncompressed_rendition(pixels);
966        parse_item(build_csi_bytes(width, height, enc, &[], Some(&r)))
967    }
968
969    pub fn make_item_stride(
970        width: u32,
971        height: u32,
972        enc: &[u8; 4],
973        pixels: &[u8],
974        bpr: u32,
975    ) -> CSIItem {
976        let r = uncompressed_rendition(pixels);
977        let tlv = bytes_per_row_tlv(bpr);
978        parse_item(build_csi_bytes(width, height, enc, &tlv, Some(&r)))
979    }
980}
981
982#[cfg(test)]
983#[cfg(feature = "image")]
984mod tests {
985    use std::io::Cursor;
986
987    use super::test_helpers::*;
988    use crate::DecodeOptions;
989    use crate::car::CarError;
990
991    // ── ARGB (BGRA → RGBA swizzle) ─────────────────────────────────
992
993    #[test]
994    fn to_image_argb_swizzle() {
995        // 2×1 BGRA pixels; second pixel uses premultiplied alpha:
996        // straight RGBA [255, 128, 64, 128] -> stored BGRA [32, 64, 128, 128]
997        let pixels = [10u8, 20, 30, 255, 32, 64, 128, 128];
998        let item = make_item(2, 1, b"BGRA", &pixels);
999        let img = item.to_image().unwrap();
1000        let rgba = img.to_rgba8();
1001        assert_eq!(rgba.get_pixel(0, 0).0, [30, 20, 10, 255]);
1002        assert_eq!(rgba.get_pixel(1, 0).0, [255, 128, 64, 128]);
1003    }
1004
1005    // ── ARGB16 (BGRA16 → RGBA16, little-endian) ────────────────────
1006
1007    #[test]
1008    fn to_image_argb16_swizzle_le() {
1009        // 1×1 BGRA16 (premultiplied alpha):
1010        // straight RGBA16 [65535, 32768, 16384, 32768]
1011        // -> stored BGRA16 [8192, 16384, 32768, 32768]
1012        let pixels = [
1013            0x00u8, 0x20, // B=8192
1014            0x00, 0x40, // G=16384
1015            0x00, 0x80, // R=32768
1016            0x00, 0x80, // A=32768
1017        ];
1018        let item = make_item(1, 1, b"WBGR", &pixels);
1019        let img = item.to_image().unwrap();
1020        let rgba16 = img.to_rgba16();
1021        assert_eq!(rgba16.get_pixel(0, 0).0, [65535, 32768, 16384, 32768]);
1022    }
1023
1024    // ── GRAY ────────────────────────────────────────────────────────
1025
1026    #[test]
1027    fn to_image_gray() {
1028        let pixels = [100u8, 200];
1029        let item = make_item(2, 1, b"YARG", &pixels);
1030        let img = item.to_image().unwrap();
1031        let luma = img.to_luma8();
1032        assert_eq!(luma.get_pixel(0, 0).0, [100]);
1033        assert_eq!(luma.get_pixel(1, 0).0, [200]);
1034    }
1035
1036    // ── GA8 ─────────────────────────────────────────────────────────
1037
1038    #[test]
1039    fn to_image_ga8() {
1040        let pixels = [100u8, 200]; // L=100, A=200
1041        let item = make_item(1, 1, b" 8AG", &pixels);
1042        let img = item.to_image().unwrap();
1043        let la = img.to_luma_alpha8();
1044        assert_eq!(la.get_pixel(0, 0).0, [100, 200]);
1045    }
1046
1047    // ── GA16 (little-endian) ────────────────────────────────────────
1048
1049    #[test]
1050    fn to_image_ga16_le() {
1051        // L=0x1234, A=0x5678 in LE
1052        let pixels = [0x34u8, 0x12, 0x78, 0x56];
1053        let item = make_item(1, 1, b"61AG", &pixels);
1054        let img = item.to_image().unwrap();
1055        let la16 = img.to_luma_alpha16();
1056        assert_eq!(la16.get_pixel(0, 0).0, [0x1234, 0x5678]);
1057    }
1058
1059    // ── RGB5 (XRGB1555) ────────────────────────────────────────────
1060
1061    #[test]
1062    fn to_image_rgb5() {
1063        // R=31 G=0 B=0 → 0b0_11111_00000_00000 = 0x7C00
1064        let pixels = 0x7C00u16.to_le_bytes();
1065        let item = make_item(1, 1, b"5BGR", &pixels);
1066        let img = item.to_image().unwrap();
1067        let rgb = img.to_rgb8();
1068        // R: (31<<3)|(31>>2) = 255, G: 0, B: 0
1069        assert_eq!(rgb.get_pixel(0, 0).0, [255, 0, 0]);
1070    }
1071
1072    // ── Deepmap2 special case ───────────────────────────────────────
1073
1074    #[test]
1075    fn to_image_deepmap2_rgba() {
1076        // 2×1 Deepmap2 with grayscale pixels [100, 200]
1077        // Deepmap2 always produces RGBA: [v, v, v, 0xFF] per pixel
1078        let r = deepmap2_rendition(2, 1, &[100, 200]);
1079        // Use GRAY encoding to prove Deepmap2 overrides it
1080        let item = parse_item(build_csi_bytes(2, 1, b"YARG", &[], Some(&r)));
1081        let img = item.to_image().unwrap();
1082        let rgba = img.to_rgba8();
1083        assert_eq!(rgba.get_pixel(0, 0).0, [100, 100, 100, 255]);
1084        assert_eq!(rgba.get_pixel(1, 0).0, [200, 200, 200, 255]);
1085    }
1086
1087    #[test]
1088    fn to_image_deepmap2_ignores_incompatible_source_stride() {
1089        let r = deepmap2_rendition_ga88(2, 1, &[100, 255, 200, 255]);
1090        let tlv = bytes_per_row_tlv(2);
1091        let item = parse_item(build_csi_bytes(2, 1, b" 8AG", &tlv, Some(&r)));
1092        let img = item.to_image().unwrap();
1093        let rgba = img.to_rgba8();
1094        assert_eq!(rgba.get_pixel(0, 0).0, [100, 100, 100, 255]);
1095        assert_eq!(rgba.get_pixel(1, 0).0, [200, 200, 200, 255]);
1096    }
1097
1098    #[test]
1099    fn to_image_deepmap2_uses_decoded_height_when_header_mismatches() {
1100        let r = deepmap2_rendition(2, 1, &[100, 200]);
1101        let item = parse_item(build_csi_bytes(2, 2, b"YARG", &[], Some(&r)));
1102        let img = item.to_image().unwrap();
1103        assert_eq!(img.width(), 2);
1104        assert_eq!(img.height(), 1);
1105    }
1106
1107    // ── EXIF orientation ────────────────────────────────────────────
1108
1109    #[test]
1110    fn to_image_exif_orientation_ignored() {
1111        // 2×1 GRAY with EXIF orientation TLV (Rotate90CW=6) present
1112        let pixels = [100u8, 200];
1113        let tlv = exif_orientation_tlv(6);
1114        let r = uncompressed_rendition(&pixels);
1115        let item = parse_item(build_csi_bytes(2, 1, b"YARG", &tlv, Some(&r)));
1116        let img = item.to_image().unwrap();
1117        let luma = img.to_luma8();
1118        // Pixels unchanged — no rotation applied
1119        assert_eq!(img.width(), 2);
1120        assert_eq!(img.height(), 1);
1121        assert_eq!(luma.get_pixel(0, 0).0, [100]);
1122        assert_eq!(luma.get_pixel(1, 0).0, [200]);
1123    }
1124
1125    #[test]
1126    fn to_image_exif_orientation_can_be_applied() {
1127        let pixels = [
1128            1u8, 2, // row 0
1129            3, 4, // row 1
1130        ];
1131        let tlv = exif_orientation_tlv(6);
1132        let r = uncompressed_rendition(&pixels);
1133        let item = parse_item(build_csi_bytes(2, 2, b"YARG", &tlv, Some(&r)));
1134
1135        let img = item
1136            .to_image_with_options(&DecodeOptions::default().apply_exif_orientation(true))
1137            .unwrap();
1138        let luma = img.to_luma8();
1139
1140        assert_eq!(img.width(), 2);
1141        assert_eq!(img.height(), 2);
1142        assert_eq!(luma.get_pixel(0, 0).0, [3]);
1143        assert_eq!(luma.get_pixel(1, 0).0, [1]);
1144        assert_eq!(luma.get_pixel(0, 1).0, [4]);
1145        assert_eq!(luma.get_pixel(1, 1).0, [2]);
1146    }
1147
1148    #[test]
1149    fn crops_precede_exif_and_report_oriented_output_dimensions() {
1150        use crate::ReferenceRect;
1151
1152        let pixels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
1153        let tlv = exif_orientation_tlv(6);
1154        let r = uncompressed_rendition(&pixels);
1155        let item = parse_item(build_csi_bytes(4, 3, b"YARG", &tlv, Some(&r)));
1156        // Bottom-left crop selects [6, 7, 8; 10, 11, 12], then [7, 8].
1157        let crops = [
1158            ReferenceRect {
1159                x: 1,
1160                y: 0,
1161                width: 3,
1162                height: 2,
1163            },
1164            ReferenceRect {
1165                x: 1,
1166                y: 1,
1167                width: 2,
1168                height: 1,
1169            },
1170        ];
1171        let options = DecodeOptions::default().apply_exif_orientation(true);
1172        let img = crate::image::to_image_with_crops_and_options(&item, &crops, &options)
1173            .unwrap()
1174            .to_luma8();
1175        assert_eq!(img.dimensions(), (1, 2));
1176        assert_eq!(img.into_raw(), [7, 8]);
1177
1178        let rgba =
1179            crate::image::rgba_bytes_with_crops_and_options(&item, &crops, &options).unwrap();
1180        assert_eq!((rgba.width, rgba.height), (1, 2));
1181        assert_eq!(rgba.rgba, [7, 7, 7, 255, 8, 8, 8, 255]);
1182        let default_rgba = item.rgba_bytes_with_crops(&crops).unwrap();
1183        assert_eq!((default_rgba.width, default_rgba.height), (2, 1));
1184        assert_eq!(default_rgba.rgba, rgba.rgba);
1185    }
1186
1187    #[test]
1188    fn crops_do_not_bypass_image_conversion_budget() {
1189        use crate::{DecodeBudgetError, DecodeStage, ReferenceRect};
1190
1191        // RGB5 expands from 12 input bytes to 18 RGB bytes before cropping.
1192        let item = make_item(3, 2, b"5BGR", &[0; 12]);
1193        let crops = [ReferenceRect {
1194            x: 0,
1195            y: 0,
1196            width: 1,
1197            height: 1,
1198        }];
1199        let options = DecodeOptions::default().with_max_output_bytes(12);
1200        let err = item
1201            .to_image_with_crops_and_options(&crops, &options)
1202            .unwrap_err();
1203        assert!(matches!(
1204            err,
1205            CarError::DecodeBudgetExceeded(DecodeBudgetError::OutputBytesExceeded {
1206                limit: 12,
1207                requested: 18,
1208                stage: DecodeStage::ImageConversion
1209            })
1210        ));
1211    }
1212
1213    #[test]
1214    fn rgba_conversion_checks_expanded_output_budget() {
1215        use crate::{DecodeBudgetError, DecodeStage};
1216
1217        let item = make_item(2, 1, b"YARG", &[1, 2]);
1218        let options = DecodeOptions::default().with_max_output_bytes(2);
1219        let err = item
1220            .rgba_bytes_with_crops_and_options(&[], &options)
1221            .unwrap_err();
1222        assert!(matches!(
1223            err,
1224            CarError::DecodeBudgetExceeded(DecodeBudgetError::OutputBytesExceeded {
1225                limit: 2,
1226                requested: 8,
1227                stage: DecodeStage::ImageConversion
1228            })
1229        ));
1230    }
1231
1232    #[test]
1233    fn png_crops_and_orientation_preserve_sixteen_bit_pixels() {
1234        use crate::ReferenceRect;
1235
1236        let channels: [u16; 24] = [
1237            1, 2, 3, 65535, 101, 102, 103, 65535, 201, 202, 203, 65535, 301, 302, 303, 65535, 401,
1238            402, 403, 65535, 501, 502, 503, 65535,
1239        ];
1240        let pixels: Vec<u8> = channels
1241            .iter()
1242            .flat_map(|value| value.to_le_bytes())
1243            .collect();
1244        let tlv = exif_orientation_tlv(6);
1245        let r = uncompressed_rendition(&pixels);
1246        let item = parse_item(build_csi_bytes(3, 2, b"WBGR", &tlv, Some(&r)));
1247        let crops = [ReferenceRect {
1248            x: 1,
1249            y: 0,
1250            width: 2,
1251            height: 1,
1252        }];
1253        let png = crate::image::png_bytes_with_crops_and_options(
1254            &item,
1255            &crops,
1256            &DecodeOptions::default().apply_exif_orientation(true),
1257        )
1258        .unwrap();
1259        let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap();
1260        assert_eq!(decoded.color(), image::ColorType::Rgba16);
1261        let rgba = decoded.to_rgba16();
1262        assert_eq!(rgba.dimensions(), (1, 2));
1263        assert_eq!(rgba.get_pixel(0, 0).0, [403, 402, 401, 65535]);
1264        assert_eq!(rgba.get_pixel(0, 1).0, [503, 502, 501, 65535]);
1265    }
1266
1267    // ── stride padding ──────────────────────────────────────────────
1268
1269    #[test]
1270    fn to_image_stride_strips_padding() {
1271        // 1×2 GRAY, min row = 1 byte, stride = 4 (3 bytes padding per row)
1272        let pixels = [100u8, 0, 0, 0, 200, 0, 0, 0];
1273        let item = make_item_stride(1, 2, b"YARG", &pixels, 4);
1274        let img = item.to_image().unwrap();
1275        let luma = img.to_luma8();
1276        assert_eq!(luma.get_pixel(0, 0).0, [100]);
1277        assert_eq!(luma.get_pixel(0, 1).0, [200]);
1278    }
1279
1280    #[test]
1281    fn to_image_invalid_bytes_per_row() {
1282        // 2×1 ARGB: min row = 8 bytes, but BytesPerRow = 4 → error
1283        let pixels = [0u8; 8];
1284        let item = make_item_stride(2, 1, b"BGRA", &pixels, 4);
1285        let err = item.to_image().unwrap_err();
1286        assert!(matches!(err, CarError::DecodeFailed(_)));
1287        assert!(format!("{err}").contains("bytes_per_row"));
1288    }
1289
1290    // ── JPEG ────────────────────────────────────────────────────────
1291
1292    #[test]
1293    fn to_image_jpeg() {
1294        use image::{ImageBuffer, ImageFormat, RgbImage};
1295        // Create a minimal 1×1 JPEG in memory
1296        let rgb: RgbImage = ImageBuffer::from_raw(1, 1, vec![255u8, 0, 0]).unwrap();
1297        let mut buf = Cursor::new(Vec::new());
1298        rgb.write_to(&mut buf, ImageFormat::Jpeg).unwrap();
1299        let jpeg_bytes = buf.into_inner();
1300
1301        let item = make_item(1, 1, b"GEPJ", &jpeg_bytes);
1302        let img = item.to_image().unwrap();
1303        assert_eq!(img.width(), 1);
1304        assert_eq!(img.height(), 1);
1305    }
1306
1307    // ── unsupported encoding ────────────────────────────────────────
1308
1309    #[test]
1310    fn to_image_unsupported_encoding() {
1311        let item = make_item(1, 1, b"ATAD", &[0u8; 4]);
1312        let err = item.to_image().unwrap_err();
1313        assert!(matches!(err, CarError::UnsupportedEncoding(_)));
1314    }
1315
1316    // ── save_image ──────────────────────────────────────────────────
1317
1318    #[test]
1319    fn save_image_png() {
1320        let pixels = [10u8, 20, 30, 255]; // 1×1 BGRA pixel
1321        let item = make_item(1, 1, b"BGRA", &pixels);
1322        let dir = std::env::temp_dir().join("carparser_test_img");
1323        let _ = std::fs::create_dir_all(&dir);
1324        let path = dir.join("test_save.png");
1325        item.save_image(&path).unwrap();
1326        let img = image::open(&path).unwrap();
1327        assert_eq!(img.width(), 1);
1328        assert_eq!(img.height(), 1);
1329        let _ = std::fs::remove_file(&path);
1330    }
1331
1332    #[test]
1333    fn save_image_unsupported_format() {
1334        let pixels = [10u8, 20, 30, 255]; // 1×1 BGRA pixel
1335        let item = make_item(1, 1, b"BGRA", &pixels);
1336        let dir = std::env::temp_dir().join("carparser_test_img");
1337        let _ = std::fs::create_dir_all(&dir);
1338        let path = dir.join("test_save.bmp");
1339        let err = item.save_image(&path).unwrap_err();
1340        assert!(matches!(err, CarError::Image(_)));
1341    }
1342
1343    // ── 直接 PNG 编码路径 ──────────────────────────────────────────────
1344
1345    #[test]
1346    fn save_image_png_direct_argb_pixel_correct() {
1347        // 1×1 BGRA: B=10 G=20 R=30 A=255 → PNG RGBA: R=30 G=20 B=10 A=255
1348        let pixels = [10u8, 20, 30, 255];
1349        let item = make_item(1, 1, b"BGRA", &pixels);
1350        let dir = std::env::temp_dir().join("carparser_direct_argb");
1351        let _ = std::fs::create_dir_all(&dir);
1352        let path = dir.join("argb.png");
1353        item.save_image(&path).unwrap();
1354        let img = image::open(&path).unwrap().to_rgba8();
1355        assert_eq!(img.get_pixel(0, 0).0, [30, 20, 10, 255]);
1356        let _ = std::fs::remove_file(&path);
1357    }
1358
1359    #[test]
1360    fn save_image_png_direct_argb_with_stride() {
1361        // 1×2 BGRA,stride=8(每行 4 字节有效 + 4 字节填充)
1362        // Row0: [B=1 G=2 R=3 A=255 PAD PAD PAD PAD]
1363        // Row1 stores premultiplied alpha for straight RGBA [12, 10, 8, 128]:
1364        // [B=4 G=5 R=6 A=128 PAD PAD PAD PAD]
1365        let pixels = [1u8, 2, 3, 255, 0, 0, 0, 0, 4, 5, 6, 128, 0, 0, 0, 0];
1366        let item = make_item_stride(1, 2, b"BGRA", &pixels, 8);
1367        let dir = std::env::temp_dir().join("carparser_direct_argb_stride");
1368        let _ = std::fs::create_dir_all(&dir);
1369        let path = dir.join("argb_stride.png");
1370        item.save_image(&path).unwrap();
1371        let img = image::open(&path).unwrap().to_rgba8();
1372        assert_eq!(img.dimensions(), (1, 2));
1373        assert_eq!(img.get_pixel(0, 0).0, [3, 2, 1, 255]);
1374        assert_eq!(img.get_pixel(0, 1).0, [12, 10, 8, 128]);
1375        let _ = std::fs::remove_file(&path);
1376    }
1377
1378    #[test]
1379    fn save_image_png_direct_ga8_pixel_correct() {
1380        // 1×1 GA8: L=128 A=200
1381        let pixels = [128u8, 200];
1382        let item = make_item(1, 1, b" 8AG", &pixels);
1383        let dir = std::env::temp_dir().join("carparser_direct_ga8");
1384        let _ = std::fs::create_dir_all(&dir);
1385        let path = dir.join("ga8.png");
1386        item.save_image(&path).unwrap();
1387        let img = image::open(&path).unwrap().to_luma_alpha8();
1388        assert_eq!(img.get_pixel(0, 0).0, [128, 200]);
1389        let _ = std::fs::remove_file(&path);
1390    }
1391
1392    #[test]
1393    fn save_image_png_direct_argb16_pixel_correct() {
1394        // 1×1 BGRA16 LE (premultiplied alpha):
1395        // straight RGBA16 [65535, 32768, 16384, 32768]
1396        // -> stored BGRA16 [8192, 16384, 32768, 32768]
1397        let pixels = [
1398            0x00u8, 0x20, // B=8192
1399            0x00, 0x40, // G=16384
1400            0x00, 0x80, // R=32768
1401            0x00, 0x80, // A=32768
1402        ];
1403        let item = make_item(1, 1, b"WBGR", &pixels);
1404        let dir = std::env::temp_dir().join("carparser_direct_argb16");
1405        let _ = std::fs::create_dir_all(&dir);
1406        let path = dir.join("argb16.png");
1407        item.save_image(&path).unwrap();
1408        let img = image::open(&path).unwrap().to_rgba16();
1409        assert_eq!(img.get_pixel(0, 0).0, [65535, 32768, 16384, 32768]);
1410        let _ = std::fs::remove_file(&path);
1411    }
1412
1413    #[test]
1414    fn save_image_png_direct_deepmap2_pixel_correct() {
1415        // 2×1 Deepmap2 灰度 → RGBA [v v v 255] per pixel
1416        let r = deepmap2_rendition(2, 1, &[100, 200]);
1417        let item = parse_item(build_csi_bytes(2, 1, b"YARG", &[], Some(&r)));
1418        let dir = std::env::temp_dir().join("carparser_direct_dmp2");
1419        let _ = std::fs::create_dir_all(&dir);
1420        let path = dir.join("deepmap2.png");
1421        item.save_image(&path).unwrap();
1422        let img = image::open(&path).unwrap().to_rgba8();
1423        assert_eq!(img.get_pixel(0, 0).0, [100, 100, 100, 255]);
1424        assert_eq!(img.get_pixel(1, 0).0, [200, 200, 200, 255]);
1425        let _ = std::fs::remove_file(&path);
1426    }
1427
1428    #[test]
1429    fn rgba_bytes_matches_rendered_rgba_output() {
1430        let pixels = [10u8, 20, 30, 255];
1431        let item = make_item(1, 1, b"BGRA", &pixels);
1432
1433        let rendered = item.to_image().unwrap().to_rgba8();
1434        let rgba = item.rgba_bytes().unwrap();
1435
1436        assert_eq!(rgba.width, 1);
1437        assert_eq!(rgba.height, 1);
1438        assert_eq!(rgba.rgba, rendered.into_raw());
1439    }
1440
1441    #[test]
1442    fn png_bytes_encodes_png_in_memory() {
1443        let pixels = [10u8, 20, 30, 255];
1444        let item = make_item(1, 1, b"BGRA", &pixels);
1445
1446        let png = item.png_bytes().unwrap();
1447        let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
1448            .unwrap()
1449            .to_rgba8();
1450
1451        assert_eq!(decoded.dimensions(), (1, 1));
1452        assert_eq!(decoded.get_pixel(0, 0).0, [30, 20, 10, 255]);
1453    }
1454}
1455
1456#[cfg(test)]
1457mod save_raw_tests {
1458    use super::test_helpers::*;
1459    use crate::Encoding;
1460    use crate::car::{Car, CarError};
1461    use crate::model::rendition::Rendition;
1462
1463    #[test]
1464    fn save_raw_with_raw_data() {
1465        let payload = b"hello-raw-data";
1466        let r = raw_data_rendition(payload);
1467        let item = parse_item(build_csi_bytes(1, 1, b"ATAD", &[], Some(&r)));
1468        let dir = std::env::temp_dir().join("carparser_test_raw");
1469        let _ = std::fs::create_dir_all(&dir);
1470        let path = dir.join("test.bin");
1471        item.save_raw(&path).unwrap();
1472        assert_eq!(std::fs::read(&path).unwrap(), payload);
1473        let _ = std::fs::remove_file(&path);
1474    }
1475
1476    #[test]
1477    fn source_bytes_with_raw_data() {
1478        let payload = b"hello-raw-data";
1479        let r = raw_data_rendition(payload);
1480        let item = parse_item(build_csi_bytes(1, 1, b"ATAD", &[], Some(&r)));
1481
1482        let bytes = item.source_bytes().unwrap();
1483        assert_eq!(bytes.as_ref(), payload);
1484    }
1485
1486    #[test]
1487    fn save_raw_heif_encoding() {
1488        let payload = b"fake-heif-data";
1489        let item = make_item(1, 1, b"FIEH", payload);
1490        let dir = std::env::temp_dir().join("carparser_test_raw");
1491        let _ = std::fs::create_dir_all(&dir);
1492        let path = dir.join("test.heif");
1493        item.save_raw(&path).unwrap();
1494        assert_eq!(std::fs::read(&path).unwrap(), payload);
1495        let _ = std::fs::remove_file(&path);
1496    }
1497
1498    #[test]
1499    fn source_bytes_hevc_cbck_strips_coreui_wrapper() {
1500        let heic = fake_heic_body();
1501        let rendition = hevc_rendition(&heic);
1502        let item = parse_item(build_csi_bytes(20, 88, b"BGRA", &[], Some(&rendition)));
1503
1504        let bytes = item.source_bytes().unwrap();
1505        assert_eq!(bytes.as_ref(), heic.as_slice());
1506    }
1507
1508    #[test]
1509    fn save_raw_hevc_cbck_writes_heic_container() {
1510        let heic = fake_heic_body();
1511        let rendition = hevc_rendition(&heic);
1512        let item = parse_item(build_csi_bytes(20, 88, b"BGRA", &[], Some(&rendition)));
1513        let dir = std::env::temp_dir().join("carparser_test_raw");
1514        let _ = std::fs::create_dir_all(&dir);
1515        let path = dir.join("hevc.heic");
1516
1517        item.save_raw(&path).unwrap();
1518        assert_eq!(std::fs::read(&path).unwrap(), heic);
1519        let _ = std::fs::remove_file(&path);
1520    }
1521
1522    #[test]
1523    fn save_raw_pdf_encoding() {
1524        let payload = b"fake-pdf-data";
1525        let item = make_item(1, 1, b" FDP", payload);
1526        let dir = std::env::temp_dir().join("carparser_test_raw");
1527        let _ = std::fs::create_dir_all(&dir);
1528        let path = dir.join("test.pdf");
1529        item.save_raw(&path).unwrap();
1530        assert_eq!(std::fs::read(&path).unwrap(), payload);
1531        let _ = std::fs::remove_file(&path);
1532    }
1533
1534    #[test]
1535    fn save_raw_svg_encoding() {
1536        let payload = b"<svg></svg>";
1537        let item = make_item(1, 1, b" GVS", payload);
1538        let dir = std::env::temp_dir().join("carparser_test_raw");
1539        let _ = std::fs::create_dir_all(&dir);
1540        let path = dir.join("test.svg");
1541        item.save_raw(&path).unwrap();
1542        assert_eq!(std::fs::read(&path).unwrap(), payload);
1543        let _ = std::fs::remove_file(&path);
1544    }
1545
1546    #[test]
1547    fn source_bytes_preserves_svg_payload_in_memory() {
1548        let payload = b"<svg></svg>";
1549        let item = make_item(1, 1, b" GVS", payload);
1550
1551        let bytes = item.source_bytes().unwrap();
1552        assert_eq!(bytes.as_ref(), payload);
1553    }
1554
1555    #[test]
1556    fn save_raw_rejects_pixel_format_encoding() {
1557        let item = make_item(1, 1, b"BGRA", &[0u8; 4]);
1558        let dir = std::env::temp_dir().join("carparser_test_raw");
1559        let _ = std::fs::create_dir_all(&dir);
1560        let path = dir.join("test_reject.bin");
1561        let err = item.save_raw(&path).unwrap_err();
1562        assert!(matches!(err, CarError::UnsupportedEncoding(_)));
1563    }
1564
1565    // ── 直接写路径:ThemeCBCK Uncompressed HEIF/PDF/SVG ───────────────
1566
1567    #[test]
1568    fn save_raw_heif_uncompressed_cbck_direct() {
1569        // HEIF 存储为 ThemeCBCK Uncompressed(make_item 构造的正是这种结构)
1570        // 验证直接写路径产出与 payload 完全一致
1571        let payload = b"heif-cbck-direct-write";
1572        let item = make_item(1, 1, b"FIEH", payload);
1573        let dir = std::env::temp_dir().join("carparser_raw_direct");
1574        let _ = std::fs::create_dir_all(&dir);
1575        let path = dir.join("heif_direct.heic");
1576        item.save_raw(&path).unwrap();
1577        assert_eq!(std::fs::read(&path).unwrap(), payload);
1578        let _ = std::fs::remove_file(&path);
1579    }
1580
1581    #[test]
1582    fn save_raw_pdf_uncompressed_cbck_direct() {
1583        let payload = b"%PDF-1.4 direct-write";
1584        let item = make_item(1, 1, b" FDP", payload);
1585        let dir = std::env::temp_dir().join("carparser_raw_direct");
1586        let _ = std::fs::create_dir_all(&dir);
1587        let path = dir.join("pdf_direct.pdf");
1588        item.save_raw(&path).unwrap();
1589        assert_eq!(std::fs::read(&path).unwrap(), payload);
1590        let _ = std::fs::remove_file(&path);
1591    }
1592
1593    #[test]
1594    fn save_raw_rawdata_direct_write() {
1595        // RawData 渲染类型直写:不经 decode_data() clone
1596        let payload = b"rawdata-no-clone-path";
1597        let r = raw_data_rendition(payload);
1598        let item = parse_item(build_csi_bytes(1, 1, b"ATAD", &[], Some(&r)));
1599        let dir = std::env::temp_dir().join("carparser_raw_direct");
1600        let _ = std::fs::create_dir_all(&dir);
1601        let path = dir.join("rawdata_direct.bin");
1602        item.save_raw(&path).unwrap();
1603        assert_eq!(std::fs::read(&path).unwrap(), payload);
1604        let _ = std::fs::remove_file(&path);
1605    }
1606
1607    fn fixture_ios_car() -> Option<Car> {
1608        let full_fixtures_enabled = matches!(
1609            std::env::var("CAR_TEST_FULL").as_deref(),
1610            Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
1611        );
1612        if !full_fixtures_enabled {
1613            eprintln!("skipping full fixture test: set CAR_TEST_FULL=1 to enable Assets_iOS.car");
1614            return None;
1615        }
1616
1617        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1618            .join("../car-tests/data")
1619            .join("Assets_iOS.car");
1620        if !path.exists() {
1621            eprintln!("skipping full fixture test: missing {}", path.display());
1622            return None;
1623        }
1624
1625        Some(Car::new(path).expect("load test Assets_iOS.car"))
1626    }
1627
1628    #[test]
1629    fn save_raw_assets_ios_svg_rawdata_decodes_apple_compression_stream() {
1630        let Some(car) = fixture_ios_car() else {
1631            return;
1632        };
1633        let item = car
1634            .rendtions_with_name("icon_device_migration")
1635            .and_then(|items| {
1636                items.iter().find(|item| {
1637                    matches!(item.header.rendition, Some(Rendition::RawData(_)))
1638                        && matches!(item.header.encoding, Encoding::SVG)
1639                })
1640            })
1641            .expect("missing icon_device_migration SVG RawData rendition");
1642
1643        let dir = std::env::temp_dir().join("carparser_raw_fixture");
1644        let _ = std::fs::create_dir_all(&dir);
1645        let path = dir.join("icon_device_migration.svg");
1646
1647        item.save_raw(&path).unwrap();
1648        let saved = std::fs::read(&path).unwrap();
1649
1650        assert!(
1651            saved.starts_with(b"<?xml"),
1652            "expected decoded SVG XML, got magic {:?}",
1653            saved.get(..4)
1654        );
1655        assert!(saved.windows(b"<svg".len()).any(|w| w == b"<svg"));
1656        assert!(
1657            !saved.starts_with(b"bvx"),
1658            "saved file should not keep Apple compression stream magic"
1659        );
1660
1661        let _ = std::fs::remove_file(&path);
1662    }
1663
1664    /// Verify that `apply_reference_frame` crops a sub-region from the source atlas.
1665    ///
1666    /// Atlas layout (12×4):
1667    ///   cols 0-5, rows 0-1: red
1668    ///   cols 6-11, rows 0-1: blue
1669    ///   all cols,  rows 2-3: green
1670    ///
1671    /// Crop rect (x=6, y=2, w=6, h=2) uses a bottom-left origin and should
1672    /// therefore extract the blue top-right region.
1673    #[cfg(feature = "image")]
1674    #[test]
1675    fn apply_reference_frame_crops_atlas_subregion() {
1676        use super::apply_reference_frame;
1677        use crate::car::ReferenceRect;
1678
1679        let source = image::RgbaImage::from_fn(12, 4, |x, y| {
1680            if y < 2 {
1681                if x < 6 {
1682                    image::Rgba([255u8, 0, 0, 255]) // red
1683                } else {
1684                    image::Rgba([0u8, 0, 255, 255]) // blue
1685                }
1686            } else {
1687                image::Rgba([0u8, 255, 0, 255]) // green
1688            }
1689        });
1690        let img = image::DynamicImage::ImageRgba8(source);
1691
1692        let rect = ReferenceRect {
1693            x: 6,
1694            y: 2,
1695            width: 6,
1696            height: 2,
1697        };
1698        let result = apply_reference_frame(img, &rect).unwrap();
1699
1700        assert_eq!(result.width(), 6);
1701        assert_eq!(result.height(), 2);
1702        let rgba = result.to_rgba8();
1703        // Every pixel in the cropped result must be blue
1704        assert_eq!(
1705            rgba.get_pixel(0, 0).0,
1706            [0, 0, 255, 255],
1707            "top-left should be blue"
1708        );
1709        assert_eq!(
1710            rgba.get_pixel(5, 1).0,
1711            [0, 0, 255, 255],
1712            "bottom-right should be blue"
1713        );
1714        // No red or green pixels leaked in
1715        assert_ne!(rgba.get_pixel(0, 0).0[0], 255, "no red channel leak");
1716    }
1717
1718    /// Out-of-bounds crop rects must surface as `CarError::DecodeFailed` rather than
1719    /// triggering an unsigned underflow / debug panic inside `crop_imm`.
1720    #[cfg(feature = "image")]
1721    #[test]
1722    fn apply_reference_frame_rejects_out_of_bounds_rect() {
1723        use super::apply_reference_frame;
1724        use crate::car::ReferenceRect;
1725
1726        let img = image::DynamicImage::ImageRgba8(image::RgbaImage::new(4, 4));
1727
1728        let rect = ReferenceRect {
1729            x: 0,
1730            y: 2,
1731            width: 4,
1732            height: 8,
1733        };
1734        let err = apply_reference_frame(img, &rect).unwrap_err();
1735        assert!(matches!(err, CarError::DecodeFailed(_)));
1736        assert!(format!("{err}").contains("exceeds source image"));
1737    }
1738}