Skip to main content

ithmb_core/
jpeg.rs

1//! JPEG detection and passthrough decoder for `.ithmb` files.
2//!
3//! T-prefix `.ithmb` files contain an embedded JPEG stream rather than raw pixel
4//! data. This module detects JPEG streams by their SOI marker, decodes them
5//! via the `jpeg_decoder` crate, applies EXIF orientation if present, and
6//! outputs BGRA8 pixel data.
7
8use crate::error::{DecodeError, DecodedImage};
9use crate::profile::Profile;
10use std::io::Cursor;
11use std::sync::atomic::AtomicBool;
12
13// ---------------------------------------------------------------------------
14// Public API
15// ---------------------------------------------------------------------------
16
17/// Returns `true` if `src` starts with a JPEG SOI marker (`0xFF`, `0xD8`).
18#[must_use]
19pub fn is_jpeg(src: &[u8]) -> bool {
20    src.first().is_some_and(|&b| b == 0xFF) && src.get(1).is_some_and(|&b| b == 0xD8)
21}
22
23/// Decodes a JPEG stream to BGRA8 output.
24///
25/// # Errors
26///
27/// Returns [`DecodeError::BufferTooShort`] if the input is shorter than the
28/// SOI marker. Returns [`DecodeError::InvalidFormat`] if the input is not a
29/// valid JPEG stream. Returns [`DecodeError::Jpeg`] if the underlying JPEG
30/// decoder fails.
31pub fn decode(src: &[u8], _profile: &Profile, canceled: &AtomicBool) -> Result<DecodedImage, DecodeError> {
32    if src.len() < 2 {
33        return Err(DecodeError::BufferTooShort {
34            expected: 2,
35            actual: src.len(),
36        });
37    }
38    if !is_jpeg(src) {
39        return Err(DecodeError::InvalidFormat("not a JPEG stream".into()));
40    }
41
42    let mut decoder = jpeg_decoder::Decoder::new(Cursor::new(src));
43    let pixels = decoder.decode().map_err(|e| DecodeError::Jpeg(e.to_string()))?;
44
45    let info = decoder
46        .info()
47        .ok_or_else(|| DecodeError::Jpeg("no JPEG metadata".into()))?;
48
49    let w = u32::from(info.width);
50    let h = u32::from(info.height);
51
52    // `pixels` is RGB (3 bytes per pixel) — convert to BGRA8.
53    let pixel_count = (w * h) as usize;
54    let mut data = vec![0u8; pixel_count * 4];
55
56    for (i, chunk) in pixels.chunks_exact(3).enumerate() {
57        crate::pixel_utils::check_canceled(canceled, "jpeg decode canceled")?;
58        if i >= pixel_count {
59            break;
60        }
61        let dst = i * 4;
62        data[dst] = chunk[2]; // B
63        data[dst + 1] = chunk[1]; // G
64        data[dst + 2] = chunk[0]; // R
65        data[dst + 3] = 255; // A
66    }
67
68    // Check EXIF orientation and rotate if needed.
69    let orientation = extract_exif_orientation(src);
70    if orientation > 1 {
71        let (rotated_data, rw, rh) = rotate_bgra(&data, w, h, orientation);
72        return Ok(DecodedImage {
73            data: rotated_data,
74            width: rw,
75            height: rh,
76        });
77    }
78
79    Ok(DecodedImage {
80        data,
81        width: w,
82        height: h,
83    })
84}
85
86// ---------------------------------------------------------------------------
87// EXIF orientation extraction (simplified APP1 parser)
88// ---------------------------------------------------------------------------
89
90/// Extracts the EXIF orientation tag (0x0112) from a JPEG stream.
91///
92/// Returns `1` (normal) if no EXIF data is found or if parsing fails.
93fn extract_exif_orientation(src: &[u8]) -> u8 {
94    // Minimum valid structure: SOI(2) + APP1(2) + len(2) + "Exif\0\0"(6) + TIFF(8) = 20
95    if src.len() < 20 {
96        return 1;
97    }
98
99    // Check SOI marker.
100    if src[0] != 0xFF || src[1] != 0xD8 {
101        return 1;
102    }
103
104    // Scan for APP1 marker (FF E1). In JPEG, after SOI there may be other
105    // markers before APP1, so we walk forward through segment headers.
106    let mut pos = 2usize;
107    loop {
108        if pos + 4 > src.len() {
109            return 1;
110        }
111        if src[pos] != 0xFF {
112            return 1;
113        }
114        let marker = src[pos + 1];
115        if marker == 0xE1 {
116            // APP1 found — check for Exif identifier.
117            let seg_len = usize::from(u16::from_be_bytes([src[pos + 2], src[pos + 3]]));
118            if pos + 2 + seg_len > src.len() {
119                return 1;
120            }
121            let exif_start = pos + 4; // skip marker(2) + length(2)
122            if seg_len < 6 + 8 {
123                return 1;
124            }
125            if &src[exif_start..exif_start + 6] != b"Exif\x00\x00" {
126                return 1;
127            }
128            return parse_tiff_orientation(&src[exif_start + 6..], seg_len - 6);
129        }
130        if marker == 0xD9 {
131            // EOI — no APP1 found.
132            return 1;
133        }
134        // Skip over any other marker segment. Marker types FFE0–FFEF and
135        // FFC0–FFDF are segment markers with a length field; standalone
136        // markers (FFD0–FFD7, FFD8, FFD9, FF01) have no length.
137        #[allow(clippy::match_same_arms)]
138        match marker {
139            // Standalone markers (no segment data).
140            0x00 | 0xD0..=0xD7 | 0xD8 | 0xD9 | 0x01 => {
141                pos += 2;
142            }
143            // Markers with segment data: all others.
144            _ => {
145                if pos + 4 > src.len() {
146                    return 1;
147                }
148                let seg_len = usize::from(u16::from_be_bytes([src[pos + 2], src[pos + 3]]));
149                pos += 2 + seg_len;
150            }
151        }
152    }
153}
154
155/// Parses the TIFF header and walks IFD0 to find orientation tag 0x0112.
156fn parse_tiff_orientation(tiff: &[u8], _remaining: usize) -> u8 {
157    if tiff.len() < 8 {
158        return 1;
159    }
160
161    let le = match &tiff[..2] {
162        b"II" => true,
163        b"MM" => false,
164        _ => return 1,
165    };
166
167    // Magic 0x002A.
168    let magic = if le {
169        u16::from_le_bytes([tiff[2], tiff[3]])
170    } else {
171        u16::from_be_bytes([tiff[2], tiff[3]])
172    };
173    if magic != 0x002A {
174        return 1;
175    }
176
177    // Offset to IFD0 from start of TIFF header.
178    let ifd0_offset = if le {
179        u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]])
180    } else {
181        u32::from_be_bytes([tiff[4], tiff[5], tiff[6], tiff[7]])
182    } as usize;
183
184    if ifd0_offset + 2 > tiff.len() {
185        return 1;
186    }
187
188    let entry_count = if le {
189        u16::from_le_bytes([tiff[ifd0_offset], tiff[ifd0_offset + 1]])
190    } else {
191        u16::from_be_bytes([tiff[ifd0_offset], tiff[ifd0_offset + 1]])
192    } as usize;
193
194    // Each IFD entry is 12 bytes: tag(2), type(2), count(4), value/offset(4).
195    for i in 0..entry_count {
196        let entry_start = ifd0_offset + 2 + i * 12;
197        if entry_start + 12 > tiff.len() {
198            break;
199        }
200        let tag = if le {
201            u16::from_le_bytes([tiff[entry_start], tiff[entry_start + 1]])
202        } else {
203            u16::from_be_bytes([tiff[entry_start], tiff[entry_start + 1]])
204        };
205        if tag == 0x0112 {
206            // Orientation value is in bytes 8..10 of the entry (value/offset field).
207            let val = if le {
208                u16::from_le_bytes([tiff[entry_start + 8], tiff[entry_start + 9]])
209            } else {
210                u16::from_be_bytes([tiff[entry_start + 8], tiff[entry_start + 9]])
211            };
212            return val.min(8) as u8;
213        }
214    }
215
216    1
217}
218
219// ---------------------------------------------------------------------------
220// BGRA rotation helpers
221// ---------------------------------------------------------------------------
222
223/// Applies EXIF orientation rotation to a BGRA8 pixel buffer.
224///
225/// Returns `(rotated_data, new_width, new_height)`.
226///
227/// Supports orientations:
228/// - 3: 180° rotation
229/// - 6: 90° clockwise rotation (dimensions swap)
230/// - 8: 270° clockwise / 90° counter-clockwise rotation (dimensions swap)
231fn rotate_bgra(data: &[u8], w: u32, h: u32, orientation: u8) -> (Vec<u8>, u32, u32) {
232    let total = (w * h * 4) as usize;
233    // The output buffer is always the same size (w * h * 4 == h * w * 4).
234    let mut rotated = vec![0u8; total];
235    let wu = w as usize;
236    let hu = h as usize;
237
238    match orientation {
239        3 => {
240            // Rotate 180°: reverse pixel order.
241            for i in 0..(wu * hu) {
242                let src_idx = i * 4;
243                let dst_idx = (wu * hu - 1 - i) * 4;
244                rotated[dst_idx..dst_idx + 4].copy_from_slice(&data[src_idx..src_idx + 4]);
245            }
246            (rotated, w, h)
247        }
248        6 => {
249            // Rotate 90° CW: old (ix, iy) → new (h-1-iy, ix).
250            // Output dimensions: (h, w).
251            for iy in 0..hu {
252                for ix in 0..wu {
253                    let src_idx = (iy * wu + ix) * 4;
254                    let ox = hu - 1 - iy;
255                    let oy = ix;
256                    let dst_idx = (oy * hu + ox) * 4;
257                    rotated[dst_idx..dst_idx + 4].copy_from_slice(&data[src_idx..src_idx + 4]);
258                }
259            }
260            (rotated, h, w)
261        }
262        8 => {
263            // Rotate 270° CW (90° CCW): output(x, y) = input(y, w - 1 - x).
264            // Output dimensions: (h, w).
265            let mut rotated = vec![0u8; total];
266            for iy in 0..hu {
267                for ix in 0..wu {
268                    let src_idx = (iy * wu + ix) * 4;
269                    // 270° CW: old (ix, iy) → new (iy, w-1-ix)
270                    let ox = iy;
271                    #[allow(clippy::cast_possible_truncation)]
272                    let oy = wu - 1 - ix;
273                    let dst_idx = (oy * hu + ox) * 4;
274                    rotated[dst_idx..dst_idx + 4].copy_from_slice(&data[src_idx..src_idx + 4]);
275                }
276            }
277            (rotated, h, w)
278        }
279        _ => (data.to_vec(), w, h),
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Tests
285// ---------------------------------------------------------------------------
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::sync::atomic::AtomicBool;
291
292    /// A valid 2×2 black JPEG generated by ffmpeg.
293    const TEST_JPEG: &[u8] = &[
294        0xff, 0xd8, 0xff, 0xfe, 0x00, 0x0f, 0x4c, 0x61, 0x76, 0x63, 0x36, 0x31, 0x2e, 0x33, 0x2e, 0x31, 0x30, 0x30,
295        0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05,
296        0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x07, 0x07, 0x07, 0x08, 0x08,
297        0x08, 0x07, 0x07, 0x07, 0x06, 0x06, 0x07, 0x07, 0x08, 0x08, 0x08, 0x08, 0x09, 0x09, 0x09, 0x08, 0x08, 0x08,
298        0x08, 0x09, 0x09, 0x0a, 0x0a, 0x0a, 0x0c, 0x0c, 0x0b, 0x0b, 0x0e, 0x0e, 0x0e, 0x11, 0x11, 0x14, 0xff, 0xc4,
299        0x00, 0x4b, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
300        0x00, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
301        0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
302        0x00, 0x00, 0x00, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
303        0x00, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x02, 0x00, 0x02, 0x03, 0x01, 0x12, 0x00, 0x02, 0x12,
304        0x00, 0x03, 0x12, 0x00, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00,
305        0x9f, 0xc0, 0x07, 0xff, 0xd9,
306    ];
307
308    /// A JPEG with EXIF orientation tag = 6 (rotate 90° CW).
309    const TEST_JPEG_EXIF6: &[u8] = &[
310        0xff, 0xd8, 0xff, 0xe1, 0x00, 0x20, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00,
311        0x00, 0x00, 0x01, 0x00, 0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00,
312        0xff, 0xfe, 0x00, 0x0f, 0x4c, 0x61, 0x76, 0x63, 0x36, 0x31, 0x2e, 0x33, 0x2e, 0x31, 0x30, 0x30, 0x00, 0xff,
313        0xdb, 0x00, 0x43, 0x00, 0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x06, 0x06,
314        0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x07, 0x07, 0x07, 0x08, 0x08, 0x08, 0x07,
315        0x07, 0x07, 0x06, 0x06, 0x07, 0x07, 0x08, 0x08, 0x08, 0x08, 0x09, 0x09, 0x09, 0x08, 0x08, 0x08, 0x08, 0x09,
316        0x09, 0x0a, 0x0a, 0x0a, 0x0c, 0x0c, 0x0b, 0x0b, 0x0e, 0x0e, 0x0e, 0x11, 0x11, 0x14, 0xff, 0xc4, 0x00, 0x4b,
317        0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
318        0x08, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
319        0x00, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
320        0x00, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
321        0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x02, 0x00, 0x02, 0x03, 0x01, 0x12, 0x00, 0x02, 0x12, 0x00, 0x03,
322        0x12, 0x00, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0x9f, 0xc0,
323        0x07, 0xff, 0xd9,
324    ];
325
326    #[test]
327    fn is_jpeg_detects_soi() {
328        assert!(is_jpeg(&[0xFF, 0xD8]));
329        assert!(is_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0]));
330        assert!(is_jpeg(TEST_JPEG));
331    }
332
333    #[test]
334    fn is_jpeg_rejects_non_jpeg() {
335        assert!(!is_jpeg(&[0x00, 0x00]));
336        assert!(!is_jpeg(&[0xFF, 0x00]));
337        assert!(!is_jpeg(&[0x00, 0xD8]));
338    }
339
340    #[test]
341    fn is_jpeg_rejects_short_input() {
342        assert!(!is_jpeg(&[]));
343        assert!(!is_jpeg(&[0xFF]));
344    }
345
346    #[test]
347    fn decode_short_input_returns_buffer_too_short() {
348        let profile = Profile::default();
349        let result = decode(&[], &profile, &AtomicBool::new(false));
350        assert!(matches!(
351            result,
352            Err(DecodeError::BufferTooShort { expected: 2, actual: 0 })
353        ));
354
355        let result = decode(&[0xFF], &profile, &AtomicBool::new(false));
356        assert!(matches!(
357            result,
358            Err(DecodeError::BufferTooShort { expected: 2, actual: 1 })
359        ));
360    }
361
362    #[test]
363    fn decode_non_jpeg_returns_invalid_format() {
364        let profile = Profile::default();
365        let result = decode(&[0x00, 0x00, 0x00, 0x00], &profile, &AtomicBool::new(false));
366        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
367    }
368
369    #[test]
370    fn decode_invalid_jpeg_returns_jpeg_error() {
371        // SOI marker present but no valid JPEG structure after it.
372        let profile = Profile::default();
373        let result = decode(&[0xFF, 0xD8, 0xFF, 0xD9], &profile, &AtomicBool::new(false));
374        assert!(matches!(result, Err(DecodeError::Jpeg(..))));
375    }
376
377    #[test]
378    fn decode_valid_jpeg() {
379        let profile = Profile::default();
380        let result = decode(TEST_JPEG, &profile, &AtomicBool::new(false));
381        assert!(result.is_ok(), "decode failed: {:?}", result.err());
382        let img = result.unwrap();
383        // 2×2 image, 16 bytes of BGRA data.
384        assert_eq!(img.width, 2);
385        assert_eq!(img.height, 2);
386        assert_eq!(img.data.len(), 2 * 2 * 4);
387    }
388
389    #[test]
390    fn decode_bgra_output_format() {
391        let profile = Profile::default();
392        let img = decode(TEST_JPEG, &profile, &AtomicBool::new(false)).unwrap();
393        // Every pixel should be 4 bytes with alpha = 255.
394        for chunk in img.data.chunks_exact(4) {
395            assert_eq!(chunk[3], 255);
396        }
397        // Data length should be width * height * 4.
398        assert_eq!(img.data.len(), (img.width * img.height * 4) as usize);
399    }
400
401    #[test]
402    fn extract_exif_orientation_normal_when_no_exif() {
403        // JPEG without EXIF data should return orientation 1.
404        assert_eq!(extract_exif_orientation(TEST_JPEG), 1);
405    }
406
407    #[test]
408    fn extract_exif_orientation_found() {
409        // TEST_JPEG_EXIF6 has orientation = 6.
410        assert_eq!(extract_exif_orientation(TEST_JPEG_EXIF6), 6);
411    }
412
413    #[test]
414    fn extract_exif_orientation_short_input() {
415        assert_eq!(extract_exif_orientation(&[]), 1);
416        assert_eq!(extract_exif_orientation(&[0xFF, 0xD8]), 1);
417    }
418
419    #[test]
420    fn exif_rotation_180() {
421        // Create 2×2 pixel data: top-left=red, top-right=green,
422        // bottom-left=blue, bottom-right=white.
423        let w = 2u32;
424        let h = 2u32;
425        let mut data = vec![0u8; (w * h * 4) as usize];
426        // Pixel layout: [R, G, B, A] in BGRA format.
427        // Top-left (0,0): red → B=0, G=0, R=255, A=255
428        data[0..4].copy_from_slice(&[0, 0, 255, 255]);
429        // Top-right (1,0): green → B=0, G=255, R=0, A=255
430        data[4..8].copy_from_slice(&[0, 255, 0, 255]);
431        // Bottom-left (0,1): blue → B=255, G=0, R=0, A=255
432        data[8..12].copy_from_slice(&[255, 0, 0, 255]);
433        // Bottom-right (1,1): white → B=255, G=255, R=255, A=255
434        data[12..16].copy_from_slice(&[255, 255, 255, 255]);
435
436        // After 180° rotation, pixel mapping:
437        // (0,0) → (1,1), (1,0) → (0,1), (0,1) → (1,0), (1,1) → (0,0)
438        let (rotated, rw, rh) = rotate_bgra(&data, w, h, 3);
439        assert_eq!(rw, 2);
440        assert_eq!(rh, 2);
441        // (1,1) should be red
442        assert_eq!(&rotated[12..16], &[0, 0, 255, 255]);
443        // (0,1) should be green
444        assert_eq!(&rotated[8..12], &[0, 255, 0, 255]);
445        // (1,0) should be blue
446        assert_eq!(&rotated[4..8], &[255, 0, 0, 255]);
447        // (0,0) should be white
448        assert_eq!(&rotated[0..4], &[255, 255, 255, 255]);
449    }
450
451    #[test]
452    fn exif_rotation_90cw() {
453        // 2×3 pixel data for 90° CW rotation test.
454        // Using a 2-wide, 3-tall image to make dimension swap obvious.
455        let w = 2u32;
456        let h = 3u32;
457        let mut data = vec![0u8; (w * h * 4) as usize];
458        // Fill with distinct colors: row-major order.
459        // (0,0): red
460        data[0..4].copy_from_slice(&[0, 0, 255, 255]);
461        // (1,0): green
462        data[4..8].copy_from_slice(&[0, 255, 0, 255]);
463        // (0,1): blue
464        data[8..12].copy_from_slice(&[255, 0, 0, 255]);
465        // (1,1): yellow (R+G)
466        data[12..16].copy_from_slice(&[0, 255, 255, 255]);
467        // (0,2): cyan (G+B)
468        data[16..20].copy_from_slice(&[255, 255, 0, 255]);
469        // (1,2): magenta (R+B)
470        data[20..24].copy_from_slice(&[255, 0, 255, 255]);
471
472        // 90° CW: old (x, y) → new (h-1-y, x)
473        // (0,0)→(2,0), (1,0)→(2,1), (0,1)→(1,0), (1,1)→(1,1), (0,2)→(0,0), (1,2)→(0,1)
474        let (rotated, rw, rh) = rotate_bgra(&data, w, h, 6);
475        // Dimensions swap: new width = h = 3, new height = w = 2
476        assert_eq!(rw, 3);
477        assert_eq!(rh, 2);
478
479        // Output row-major layout (width=3, height=2):
480        // (0,0) = old(0,2) = cyan
481        assert_eq!(&rotated[0..4], &[255, 255, 0, 255]);
482        // (1,0) = old(0,1) = blue
483        assert_eq!(&rotated[4..8], &[255, 0, 0, 255]);
484        // (2,0) = old(0,0) = red
485        assert_eq!(&rotated[8..12], &[0, 0, 255, 255]);
486        // (0,1) = old(1,2) = magenta
487        assert_eq!(&rotated[12..16], &[255, 0, 255, 255]);
488        // (1,1) = old(1,1) = yellow
489        assert_eq!(&rotated[16..20], &[0, 255, 255, 255]);
490        // (2,1) = old(1,0) = green
491        assert_eq!(&rotated[20..24], &[0, 255, 0, 255]);
492    }
493
494    #[test]
495    fn exif_rotation_270cw() {
496        let w = 2u32;
497        let h = 3u32;
498        let mut data = vec![0u8; (w * h * 4) as usize];
499        // (0,0): red
500        data[0..4].copy_from_slice(&[0, 0, 255, 255]);
501        // (1,0): green
502        data[4..8].copy_from_slice(&[0, 255, 0, 255]);
503        // (0,1): blue
504        data[8..12].copy_from_slice(&[255, 0, 0, 255]);
505        // (1,1): yellow
506        data[12..16].copy_from_slice(&[0, 255, 255, 255]);
507        // (0,2): cyan
508        data[16..20].copy_from_slice(&[255, 255, 0, 255]);
509        // (1,2): magenta
510        data[20..24].copy_from_slice(&[255, 0, 255, 255]);
511
512        // 270° CW: old (x, y) → new (y, w-1-x)
513        // (0,0)→(0,1), (1,0)→(0,0), (0,1)→(1,1), (1,1)→(1,0), (0,2)→(2,1), (1,2)→(2,0)
514        let (rotated, rw, rh) = rotate_bgra(&data, w, h, 8);
515        assert_eq!(rw, 3);
516        assert_eq!(rh, 2);
517
518        // Output row-major (width=3, height=2):
519        // (0,0) = old(1,0) = green
520        assert_eq!(&rotated[0..4], &[0, 255, 0, 255]);
521        // (1,0) = old(1,1) = yellow
522        assert_eq!(&rotated[4..8], &[0, 255, 255, 255]);
523        // (2,0) = old(1,2) = magenta
524        assert_eq!(&rotated[8..12], &[255, 0, 255, 255]);
525        // (0,1) = old(0,0) = red
526        assert_eq!(&rotated[12..16], &[0, 0, 255, 255]);
527        // (1,1) = old(0,1) = blue
528        assert_eq!(&rotated[16..20], &[255, 0, 0, 255]);
529        // (2,1) = old(0,2) = cyan
530        assert_eq!(&rotated[20..24], &[255, 255, 0, 255]);
531    }
532
533    #[test]
534    fn decode_with_exif_rotation() {
535        let profile = Profile::default();
536        let result = decode(TEST_JPEG_EXIF6, &profile, &AtomicBool::new(false));
537        // The JPEG has EXIF orientation = 6, so dimensions should be swapped
538        // (2×2 → still 2×2 since it's square, but rotation logic applies).
539        assert!(result.is_ok(), "decode failed: {:?}", result.err());
540    }
541
542    #[test]
543    fn rotate_bgra_noop_for_normal_orientation() {
544        let data = vec![0u8; 16];
545        let (rotated, rw, rh) = rotate_bgra(&data, 2, 2, 1);
546        assert_eq!(rotated, data);
547        assert_eq!(rw, 2);
548        assert_eq!(rh, 2);
549    }
550}