Skip to main content

rawshift_image/formats/
standard.rs

1//! Standard image format decoders (JPEG, PNG, WebP, TIFF, JXL, AVIF, HEIC, SVG, APV, PPM).
2//!
3//! This module provides decoders for common non-RAW image formats that decode
4//! directly to RGB pixel data stored in an [`RgbImage`].
5
6#[cfg(any_standard_decode)]
7use std::io::Cursor;
8
9#[cfg(feature = "zune-runtime")]
10use zune_core::bytestream::ZCursor;
11#[cfg(feature = "zune-runtime")]
12use zune_core::colorspace::ColorSpace;
13#[cfg(feature = "zune-runtime")]
14use zune_core::options::DecoderOptions;
15#[cfg(feature = "zune-runtime")]
16use zune_core::result::DecodingResult;
17
18use crate::core::CodecId;
19use crate::core::image::{RgbImage, Size};
20use crate::error::{FormatError, RawError, RawResult};
21
22/// Supported standard (non-RAW) image formats.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub enum StandardFormat {
26    /// GIF (Graphics Interchange Format)
27    Gif,
28    /// JPEG / JFIF
29    Jpeg,
30    /// PNG (Portable Network Graphics)
31    Png,
32    /// WebP
33    WebP,
34    /// JPEG XL
35    Jxl,
36    /// TIFF
37    Tiff,
38    /// AVIF
39    Avif,
40    /// HEIC / HEIF (High Efficiency Image Container using H.265)
41    Heic,
42    /// SVG (Scalable Vector Graphics)
43    Svg,
44    /// APV (All-intra Predictive Video codec)
45    Apv,
46    /// PPM / Netpbm (Portable Pixmap family: P5, P6, P7, PFM)
47    Ppm,
48}
49
50impl StandardFormat {
51    /// Human-readable name of the format.
52    pub fn name(self) -> &'static str {
53        match self {
54            StandardFormat::Gif => "GIF",
55            StandardFormat::Jpeg => "JPEG",
56            StandardFormat::Png => "PNG",
57            StandardFormat::WebP => "WebP",
58            StandardFormat::Jxl => "JXL",
59            StandardFormat::Tiff => "TIFF",
60            StandardFormat::Avif => "AVIF",
61            StandardFormat::Heic => "HEIC",
62            StandardFormat::Svg => "SVG",
63            StandardFormat::Apv => "APV",
64            StandardFormat::Ppm => "PPM",
65        }
66    }
67
68    /// Primary file extension (without dot).
69    pub fn extension(self) -> &'static str {
70        match self {
71            StandardFormat::Gif => "gif",
72            StandardFormat::Jpeg => "jpg",
73            StandardFormat::Png => "png",
74            StandardFormat::WebP => "webp",
75            StandardFormat::Jxl => "jxl",
76            StandardFormat::Tiff => "tiff",
77            StandardFormat::Avif => "avif",
78            StandardFormat::Heic => "heic",
79            StandardFormat::Svg => "svg",
80            StandardFormat::Apv => "apv",
81            StandardFormat::Ppm => "ppm",
82        }
83    }
84
85    /// Look up a format by file extension (case-insensitive).
86    ///
87    /// Common aliases are supported (e.g. `jpeg`/`jpe`/`jfif` for JPEG,
88    /// `tif` for TIFF, `heif` for HEIC, `svgz` for SVG, `pgm`/`pnm`/`pam`/`pfm`
89    /// for PPM).
90    pub fn from_extension(ext: &str) -> Option<StandardFormat> {
91        match ext.to_ascii_lowercase().as_str() {
92            "gif" => Some(StandardFormat::Gif),
93            "jpg" | "jpeg" | "jpe" | "jfif" => Some(StandardFormat::Jpeg),
94            "png" => Some(StandardFormat::Png),
95            "webp" => Some(StandardFormat::WebP),
96            "jxl" => Some(StandardFormat::Jxl),
97            "tiff" | "tif" => Some(StandardFormat::Tiff),
98            "avif" => Some(StandardFormat::Avif),
99            "heic" | "heif" => Some(StandardFormat::Heic),
100            "svg" | "svgz" => Some(StandardFormat::Svg),
101            "apv" => Some(StandardFormat::Apv),
102            "ppm" | "pgm" | "pnm" | "pam" | "pfm" => Some(StandardFormat::Ppm),
103            _ => None,
104        }
105    }
106
107    /// Standard MIME type for this format.
108    pub fn mime_type(self) -> &'static str {
109        match self {
110            StandardFormat::Gif => "image/gif",
111            StandardFormat::Jpeg => "image/jpeg",
112            StandardFormat::Png => "image/png",
113            StandardFormat::WebP => "image/webp",
114            StandardFormat::Jxl => "image/jxl",
115            StandardFormat::Tiff => "image/tiff",
116            StandardFormat::Avif => "image/avif",
117            StandardFormat::Heic => "image/heic",
118            StandardFormat::Svg => "image/svg+xml",
119            StandardFormat::Apv => "video/apv",
120            StandardFormat::Ppm => "image/x-portable-pixmap",
121        }
122    }
123
124    /// Whether this format can be decoded to an [`RgbImage`].
125    pub fn supports_decode(self) -> bool {
126        match self {
127            #[cfg(feature = "gif-decode")]
128            StandardFormat::Gif => true,
129            #[cfg(feature = "jpeg-decode")]
130            StandardFormat::Jpeg => true,
131            #[cfg(feature = "png-decode")]
132            StandardFormat::Png => true,
133            #[cfg(feature = "webp-decode")]
134            StandardFormat::WebP => true,
135            #[cfg(feature = "jxl-decode")]
136            StandardFormat::Jxl => true,
137            #[cfg(feature = "tiff-decode")]
138            StandardFormat::Tiff => true,
139            #[cfg(feature = "avif-decode")]
140            StandardFormat::Avif => true,
141            #[cfg(feature = "svg-decode")]
142            StandardFormat::Svg => true,
143            #[cfg(feature = "heic-decode")]
144            StandardFormat::Heic => true,
145            #[cfg(feature = "ppm-decode")]
146            StandardFormat::Ppm => true,
147            _ => false,
148        }
149    }
150
151    /// Whether this format can be encoded from an [`RgbImage`].
152    pub fn supports_encode(self) -> bool {
153        match self {
154            #[cfg(feature = "png-encode")]
155            StandardFormat::Png => true,
156            #[cfg(feature = "jpeg-encode")]
157            StandardFormat::Jpeg => true,
158            #[cfg(feature = "webp-encode")]
159            StandardFormat::WebP => true,
160            #[cfg(feature = "avif-encode")]
161            StandardFormat::Avif => true,
162            #[cfg(feature = "jxl-encode")]
163            StandardFormat::Jxl => true,
164            _ => false,
165        }
166    }
167}
168
169impl std::fmt::Display for StandardFormat {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.write_str(self.name())
172    }
173}
174
175/// Detect a standard image format from the first bytes of image data.
176///
177/// Note: TIFF-based RAW formats (DNG, ARW, NEF, CR2) share the TIFF magic
178/// bytes and will be detected as `StandardFormat::Tiff`. Use [`RawFile::open()`]
179/// to distinguish RAW formats first.
180///
181/// Returns `None` if the format is not recognised or if `data` is too short.
182pub fn detect_standard_format(data: &[u8]) -> Option<StandardFormat> {
183    if data.len() < 8 {
184        return None;
185    }
186    match data {
187        // GIF87a / GIF89a
188        d if d.len() >= 6 && (&d[0..6] == b"GIF87a" || &d[0..6] == b"GIF89a") => {
189            Some(StandardFormat::Gif)
190        }
191        // JPEG: FF D8 FF
192        d if d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF => Some(StandardFormat::Jpeg),
193        // PNG: 89 50 4E 47 0D 0A 1A 0A
194        d if &d[0..8] == b"\x89PNG\r\n\x1a\n" => Some(StandardFormat::Png),
195        // WebP: RIFF????WEBP
196        d if d.len() >= 12 && &d[0..4] == b"RIFF" && &d[8..12] == b"WEBP" => {
197            Some(StandardFormat::WebP)
198        }
199        // JXL bare codestream: FF 0A
200        d if d[0] == 0xFF && d[1] == 0x0A => Some(StandardFormat::Jxl),
201        // JXL ISO BMFF container: box size (4 bytes) + "JXL " (4 bytes)
202        d if d.len() >= 12 && &d[4..8] == b"JXL " => Some(StandardFormat::Jxl),
203        // TIFF little-endian (II) or big-endian (MM)
204        d if (d[0] == 0x49 && d[1] == 0x49 && d[2] == 0x2A && d[3] == 0x00)
205            || (d[0] == 0x4D && d[1] == 0x4D && d[2] == 0x00 && d[3] == 0x2A) =>
206        {
207            Some(StandardFormat::Tiff)
208        }
209        // AVIF / HEIF: ftyp box with avif/avis/mif1 brand
210        d if d.len() >= 12
211            && &d[4..8] == b"ftyp"
212            && (&d[8..12] == b"avif" || &d[8..12] == b"avis" || &d[8..12] == b"mif1") =>
213        {
214            Some(StandardFormat::Avif)
215        }
216        // HEIC/HEIF: ftyp box with heic/heis/hevc/hevx brand
217        #[cfg(feature = "heic-decode")]
218        d if d.len() >= 12
219            && &d[4..8] == b"ftyp"
220            && (&d[8..12] == b"heic"
221                || &d[8..12] == b"heis"
222                || &d[8..12] == b"hevc"
223                || &d[8..12] == b"hevx") =>
224        {
225            Some(StandardFormat::Heic)
226        }
227        // APV: ftyp box with apv1/apvx brand
228        d if d.len() >= 12
229            && &d[4..8] == b"ftyp"
230            && (&d[8..12] == b"apv1" || &d[8..12] == b"apvx") =>
231        {
232            Some(StandardFormat::Apv)
233        }
234        // PPM / Netpbm: 'P' + version (5/6/7 binary, or F/f for PFM) + whitespace.
235        // P1-P4 (ASCII bitmaps / binary PBM) are intentionally excluded — the
236        // zune-ppm backend does not decode them.
237        d if d[0] == b'P'
238            && matches!(d[1], b'5' | b'6' | b'7' | b'F' | b'f')
239            && d[2].is_ascii_whitespace() =>
240        {
241            Some(StandardFormat::Ppm)
242        }
243        // SVG: XML starting with <?xml, <svg, or <!-- with <svg present in file
244        d if d.len() >= 4
245            && (&d[0..4] == b"<?xm" || &d[0..4] == b"<svg" || &d[0..4] == b"<!--") =>
246        {
247            if d.windows(4).any(|w| w == b"<svg") {
248                Some(StandardFormat::Svg)
249            } else {
250                None
251            }
252        }
253        _ => None,
254    }
255}
256
257// ── Helpers ──────────────────────────────────────────────────────────────────
258
259/// Scale an 8-bit sample to 16-bit by duplicating the byte in both halves.
260/// This is equivalent to `v * 257` and ensures that 255 maps to 65535.
261#[inline(always)]
262#[cfg_attr(not(any_standard_decode), allow(dead_code))]
263fn u8_to_u16(v: u8) -> u16 {
264    (v as u16) * 257
265}
266
267// ── GIF ──────────────────────────────────────────────────────────────────────
268
269#[cfg(feature = "gif-decode")]
270fn decode_gif(data: &[u8]) -> RawResult<RgbImage> {
271    use gif::{ColorOutput, DecodeOptions};
272
273    let mut opts = DecodeOptions::new();
274    opts.set_color_output(ColorOutput::RGBA);
275    let mut decoder = opts.read_info(Cursor::new(data)).map_err(|e| {
276        RawError::Format(FormatError::ImageDecode {
277            format: "GIF",
278            message: e.to_string(),
279        })
280    })?;
281
282    let canvas_width = decoder.width() as u32;
283    let canvas_height = decoder.height() as u32;
284
285    let frame = decoder
286        .read_next_frame()
287        .map_err(|e| {
288            RawError::Format(FormatError::ImageDecode {
289                format: "GIF",
290                message: e.to_string(),
291            })
292        })?
293        .ok_or_else(|| {
294            RawError::Format(FormatError::ImageDecode {
295                format: "GIF",
296                message: "no frames in GIF".to_string(),
297            })
298        })?;
299
300    let frame_width = frame.width as usize;
301    let frame_height = frame.height as usize;
302    let frame_left = frame.left as usize;
303    let frame_top = frame.top as usize;
304
305    // Allocate a black canvas and composite the frame (RGBA → RGB, drop alpha).
306    let mut out = vec![0u16; (canvas_width as usize) * (canvas_height as usize) * 3];
307
308    let buf = &frame.buffer[..];
309    // With ColorOutput::RGBA the buffer contains 4 bytes per pixel.
310    let expected_rgba = frame_width * frame_height * 4;
311    if buf.len() < expected_rgba {
312        return Err(RawError::Format(FormatError::ImageDecode {
313            format: "GIF",
314            message: format!(
315                "frame buffer too small: got {} bytes, expected {} ({}x{}x4)",
316                buf.len(),
317                expected_rgba,
318                frame_width,
319                frame_height,
320            ),
321        }));
322    }
323
324    for row in 0..frame_height {
325        let canvas_y = frame_top + row;
326        if canvas_y >= canvas_height as usize {
327            break;
328        }
329        for col in 0..frame_width {
330            let canvas_x = frame_left + col;
331            if canvas_x >= canvas_width as usize {
332                continue;
333            }
334            let src = (row * frame_width + col) * 4;
335            let dst = (canvas_y * canvas_width as usize + canvas_x) * 3;
336            out[dst] = u8_to_u16(buf[src]);
337            out[dst + 1] = u8_to_u16(buf[src + 1]);
338            out[dst + 2] = u8_to_u16(buf[src + 2]);
339            // Alpha channel (buf[src + 3]) is intentionally dropped.
340        }
341    }
342
343    Ok(RgbImage::new(canvas_width, canvas_height, out))
344}
345
346// ── JPEG ─────────────────────────────────────────────────────────────────────
347
348#[cfg(feature = "jpeg-decode")]
349fn decode_jpeg(data: &[u8], cfg: &ZuneJpegDecodeConfig) -> RawResult<RgbImage> {
350    let mut opts = DecoderOptions::default()
351        .jpeg_set_out_colorspace(ColorSpace::RGB)
352        .set_strict_mode(cfg.strict);
353    if let Some(w) = cfg.max_width {
354        opts = opts.set_max_width(w);
355    }
356    if let Some(h) = cfg.max_height {
357        opts = opts.set_max_height(h);
358    }
359    let cursor = ZCursor::new(data);
360    let mut decoder = zune_jpeg::JpegDecoder::new_with_options(cursor, opts);
361
362    let pixels = decoder.decode().map_err(|e| {
363        RawError::Format(FormatError::ImageDecode {
364            format: "JPEG",
365            message: format!("{e:?}"),
366        })
367    })?;
368
369    let (w, h) = decoder
370        .dimensions()
371        .map(|(w, h)| (w as u32, h as u32))
372        .ok_or_else(|| {
373            RawError::Format(FormatError::ImageDecode {
374                format: "JPEG",
375                message: "could not read image dimensions after decode".to_string(),
376            })
377        })?;
378
379    // pixels is Vec<u8>, RGB interleaved — scale to u16
380    let data_u16: Vec<u16> = pixels.iter().map(|&v| u8_to_u16(v)).collect();
381
382    Ok(RgbImage::new(w, h, data_u16))
383}
384
385// ── PNG ──────────────────────────────────────────────────────────────────────
386
387#[cfg(feature = "png-decode")]
388fn decode_png(data: &[u8], cfg: &ZunePngDecodeConfig) -> RawResult<RgbImage> {
389    // Decode the PNG in its native color space (RGB, RGBA, Luma, LumaA, …)
390    // and then convert to packed RGB u16 manually.
391    let mut opts = DecoderOptions::default()
392        .png_set_strip_to_8bit(false)
393        .set_strict_mode(cfg.strict)
394        .png_set_confirm_crc(cfg.confirm_crc);
395    if let Some(w) = cfg.max_width {
396        opts = opts.set_max_width(w);
397    }
398    if let Some(h) = cfg.max_height {
399        opts = opts.set_max_height(h);
400    }
401    let cursor = ZCursor::new(data);
402    let mut decoder = zune_png::PngDecoder::new_with_options(cursor, opts);
403
404    let result = decoder.decode().map_err(|e| {
405        RawError::Format(FormatError::ImageDecode {
406            format: "PNG",
407            message: format!("{e:?}"),
408        })
409    })?;
410
411    let info = decoder.info().ok_or_else(|| {
412        RawError::Format(FormatError::ImageDecode {
413            format: "PNG",
414            message: "could not read PNG info after decode".to_string(),
415        })
416    })?;
417
418    let w = info.width as u32;
419    let h = info.height as u32;
420
421    // Determine the output colorspace from the decoder
422    let colorspace = decoder.colorspace().unwrap_or(ColorSpace::RGB);
423    let n = colorspace.num_components();
424
425    // Convert raw samples to Vec<u16>
426    let samples_u16: Vec<u16> = match result {
427        DecodingResult::U8(px) => px.iter().map(|&v| u8_to_u16(v)).collect(),
428        DecodingResult::U16(px) => px,
429        _ => {
430            return Err(RawError::Format(FormatError::ImageDecode {
431                format: "PNG",
432                message: "unexpected pixel depth in decoded result".to_string(),
433            }));
434        }
435    };
436
437    // Convert any colorspace to packed RGB u16
438    let data_u16: Vec<u16> = match colorspace {
439        ColorSpace::RGB => samples_u16,
440        ColorSpace::RGBA => {
441            // Drop alpha channel (index 3)
442            samples_u16
443                .chunks_exact(n)
444                .flat_map(|px| [px[0], px[1], px[2]])
445                .collect()
446        }
447        ColorSpace::Luma => {
448            // Expand grayscale to RGB
449            samples_u16.iter().flat_map(|&v| [v, v, v]).collect()
450        }
451        ColorSpace::LumaA => {
452            // Expand grayscale+alpha to RGB (drop alpha)
453            samples_u16
454                .chunks_exact(n)
455                .flat_map(|px| [px[0], px[0], px[0]])
456                .collect()
457        }
458        _ => {
459            return Err(RawError::Format(FormatError::ImageDecode {
460                format: "PNG",
461                message: format!("unsupported PNG colorspace: {colorspace:?}"),
462            }));
463        }
464    };
465
466    Ok(RgbImage::new(w, h, data_u16))
467}
468
469// ── WebP ─────────────────────────────────────────────────────────────────────
470
471#[cfg(feature = "webp-decode")]
472fn decode_webp(data: &[u8]) -> RawResult<RgbImage> {
473    let (w, h, rgb) = crate::codecs::webp::decode_webp_rgb(data).map_err(|e| {
474        RawError::Format(FormatError::ImageDecode {
475            format: "WebP",
476            message: e,
477        })
478    })?;
479
480    let data_u16: Vec<u16> = rgb.iter().map(|&v| u8_to_u16(v)).collect();
481
482    Ok(RgbImage::new(w, h, data_u16))
483}
484
485// ── JXL ──────────────────────────────────────────────────────────────────────
486
487#[cfg(feature = "jxl-decode")]
488fn decode_jxl(data: &[u8]) -> RawResult<RgbImage> {
489    use jxl_oxide::JxlImage;
490
491    let image = JxlImage::builder().read(Cursor::new(data)).map_err(|e| {
492        RawError::Format(FormatError::ImageDecode {
493            format: "JXL",
494            message: format!("{e}"),
495        })
496    })?;
497
498    let w = image.width();
499    let h = image.height();
500
501    if image.num_loaded_keyframes() == 0 {
502        return Err(RawError::Format(FormatError::ImageDecode {
503            format: "JXL",
504            message: "no keyframes decoded".to_string(),
505        }));
506    }
507
508    let render = image.render_frame(0).map_err(|e| {
509        RawError::Format(FormatError::ImageDecode {
510            format: "JXL",
511            message: format!("{e}"),
512        })
513    })?;
514
515    jxl_render_to_rgb(w, h, image.pixel_format(), render)
516}
517
518/// Convert a `jxl-oxide` [`Render`](jxl_oxide::Render) into a packed RGB
519/// [`RgbImage`]. Shared by [`decode_jxl`] and [`decode_jxl_partial`].
520#[cfg(feature = "jxl-decode")]
521fn jxl_render_to_rgb(
522    w: u32,
523    h: u32,
524    pixel_format: jxl_oxide::PixelFormat,
525    render: jxl_oxide::Render,
526) -> RawResult<RgbImage> {
527    use jxl_oxide::PixelFormat;
528
529    let total_pixels = (w as usize) * (h as usize);
530
531    // `stream_no_alpha` yields exactly the color channels.
532    let mut stream = render.stream_no_alpha();
533    let channels = stream.channels() as usize;
534
535    let mut samples_u16 = vec![0u16; total_pixels * channels];
536    stream.write_to_buffer(&mut samples_u16);
537
538    let data_u16: Vec<u16> = match pixel_format {
539        PixelFormat::Rgb => samples_u16,
540        PixelFormat::Gray => samples_u16.iter().flat_map(|&v| [v, v, v]).collect(),
541        PixelFormat::Rgba | PixelFormat::Graya => {
542            if pixel_format == PixelFormat::Graya {
543                samples_u16
544                    .chunks_exact(channels)
545                    .flat_map(|px| [px[0], px[0], px[0]])
546                    .collect()
547            } else {
548                samples_u16
549                    .chunks_exact(channels)
550                    .flat_map(|px| [px[0], px[1], px[2]])
551                    .collect()
552            }
553        }
554        _ => {
555            return Err(RawError::Format(FormatError::ImageDecode {
556                format: "JXL",
557                message: format!("unsupported pixel format {pixel_format:?}"),
558            }));
559        }
560    };
561
562    Ok(RgbImage::new(w, h, data_u16))
563}
564
565/// Decode a JPEG XL stream that may be **truncated**, returning the best
566/// available render.
567///
568/// Unlike [`decode_standard_image`], which errors on a stream with no fully
569/// loaded keyframe, this feeds bytes progressively and — if the stream ends
570/// mid-frame — renders the partially-decoded frame. The returned `bool` is
571/// `true` when a complete keyframe was decoded and `false` for a partial render.
572///
573/// The returned image is tagged [`ColorSpace::Srgb`](crate::core::ColorSpace).
574///
575/// # Errors
576/// Returns an error only when the stream is too short to even parse the image
577/// header, or the pixel format is unsupported.
578#[cfg(feature = "jxl-decode")]
579pub fn decode_jxl_partial(data: &[u8]) -> RawResult<(RgbImage, bool)> {
580    use jxl_oxide::{InitializeResult, JxlImage};
581
582    let jxl_err = |e| {
583        RawError::Format(FormatError::ImageDecode {
584            format: "JXL",
585            message: format!("{e}"),
586        })
587    };
588
589    // Phase 1: feed bytes until the image header initializes.
590    let mut uninit = JxlImage::builder().build_uninit();
591    let mut offset = 0usize;
592    let mut image = loop {
593        let consumed = if offset < data.len() {
594            uninit.feed_bytes(&data[offset..]).map_err(jxl_err)?
595        } else {
596            0
597        };
598        offset += consumed;
599        match uninit.try_init().map_err(jxl_err)? {
600            InitializeResult::Initialized(img) => break img,
601            InitializeResult::NeedMoreData(next) => {
602                uninit = next;
603                if consumed == 0 {
604                    return Err(RawError::Format(FormatError::ImageDecode {
605                        format: "JXL",
606                        message: "stream too short to read the image header".to_string(),
607                    }));
608                }
609            }
610        }
611    };
612
613    // Phase 2: feed the remainder into the initialized image. Feeding stops
614    // early — without error — when a truncated stream runs out of bytes.
615    while offset < data.len() {
616        let consumed = image.feed_bytes(&data[offset..]).map_err(jxl_err)?;
617        if consumed == 0 {
618            break;
619        }
620        offset += consumed;
621    }
622
623    let (w, h) = (image.width(), image.height());
624    let pixel_format = image.pixel_format();
625
626    // A fully-loaded keyframe renders completely; otherwise render whatever the
627    // truncated stream has produced so far.
628    let (render, complete) = if image.num_loaded_keyframes() > 0 {
629        (image.render_frame(0).map_err(jxl_err)?, true)
630    } else {
631        (image.render_loading_frame().map_err(jxl_err)?, false)
632    };
633
634    let rgb = jxl_render_to_rgb(w, h, pixel_format, render)?;
635    Ok((tag_srgb(rgb), complete))
636}
637
638// ── TIFF ─────────────────────────────────────────────────────────────────────
639
640#[cfg(feature = "tiff-decode")]
641fn decode_tiff(data: &[u8]) -> RawResult<RgbImage> {
642    use tiff::ColorType;
643    use tiff::decoder::{Decoder, DecodingResult};
644
645    let cursor = Cursor::new(data);
646    let mut decoder = Decoder::new(cursor).map_err(|e| {
647        RawError::Format(FormatError::ImageDecode {
648            format: "TIFF",
649            message: format!("{e}"),
650        })
651    })?;
652
653    let (w, h) = decoder.dimensions().map_err(|e| {
654        RawError::Format(FormatError::ImageDecode {
655            format: "TIFF",
656            message: format!("{e}"),
657        })
658    })?;
659
660    let color_type = decoder.colortype().map_err(|e| {
661        RawError::Format(FormatError::ImageDecode {
662            format: "TIFF",
663            message: format!("{e}"),
664        })
665    })?;
666
667    let result = decoder.read_image().map_err(|e| {
668        RawError::Format(FormatError::ImageDecode {
669            format: "TIFF",
670            message: format!("{e}"),
671        })
672    })?;
673
674    // Extract raw samples as u16 values.
675    let samples_u16: Vec<u16> = match result {
676        DecodingResult::U8(px) => px.iter().map(|&v| u8_to_u16(v)).collect(),
677        DecodingResult::U16(px) => px,
678        DecodingResult::U32(px) => px.iter().map(|&v| (v >> 16) as u16).collect(),
679        DecodingResult::F32(px) => px
680            .iter()
681            .map(|&v| (v.clamp(0.0, 1.0) * 65535.0) as u16)
682            .collect(),
683        _ => {
684            return Err(RawError::Format(FormatError::ImageDecode {
685                format: "TIFF",
686                message: format!("unsupported TIFF sample type for color type {color_type:?}"),
687            }));
688        }
689    };
690
691    // Convert to interleaved RGB u16 based on the color type.
692    let data_u16: Vec<u16> = match color_type {
693        ColorType::RGB(_) => samples_u16,
694        ColorType::RGBA(_) => samples_u16
695            .chunks_exact(4)
696            .flat_map(|px| [px[0], px[1], px[2]])
697            .collect(),
698        ColorType::Gray(_) => samples_u16.iter().flat_map(|&v| [v, v, v]).collect(),
699        ColorType::GrayA(_) => samples_u16
700            .chunks_exact(2)
701            .flat_map(|px| [px[0], px[0], px[0]])
702            .collect(),
703        ColorType::CMYK(_) => {
704            // Simple CMYK→RGB: R = (1-C)*(1-K), G = (1-M)*(1-K), B = (1-Y)*(1-K)
705            samples_u16
706                .chunks_exact(4)
707                .flat_map(|px| {
708                    let c = px[0] as f64 / 65535.0;
709                    let m = px[1] as f64 / 65535.0;
710                    let y = px[2] as f64 / 65535.0;
711                    let k = px[3] as f64 / 65535.0;
712                    let r = ((1.0 - c) * (1.0 - k) * 65535.0) as u16;
713                    let g = ((1.0 - m) * (1.0 - k) * 65535.0) as u16;
714                    let b = ((1.0 - y) * (1.0 - k) * 65535.0) as u16;
715                    [r, g, b]
716                })
717                .collect()
718        }
719        _ => {
720            return Err(RawError::Format(FormatError::ImageDecode {
721                format: "TIFF",
722                message: format!("unsupported TIFF color type: {color_type:?}"),
723            }));
724        }
725    };
726
727    Ok(RgbImage::new(w, h, data_u16))
728}
729
730// ── AVIF ─────────────────────────────────────────────────────────────────────
731
732#[cfg(feature = "avif-decode")]
733fn decode_avif(data: &[u8]) -> RawResult<RgbImage> {
734    use image::DynamicImage;
735    use image::codecs::avif::AvifDecoder;
736
737    let decoder = AvifDecoder::new(Cursor::new(data)).map_err(|e| {
738        RawError::Format(FormatError::ImageDecode {
739            format: "AVIF",
740            message: format!("{e}"),
741        })
742    })?;
743
744    let img = DynamicImage::from_decoder(decoder).map_err(|e| {
745        RawError::Format(FormatError::ImageDecode {
746            format: "AVIF",
747            message: format!("{e}"),
748        })
749    })?;
750
751    let rgb = img.into_rgb16();
752    let w = rgb.width();
753    let h = rgb.height();
754    Ok(RgbImage::new(w, h, rgb.into_raw()))
755}
756
757#[cfg(not(feature = "avif-decode"))]
758fn decode_avif(_data: &[u8]) -> RawResult<RgbImage> {
759    Err(RawError::Unsupported(
760        "AVIF decoding requires the `avif-decode` feature flag.".to_string(),
761    ))
762}
763
764// ── HEIC ─────────────────────────────────────────────────────────────────────
765
766/// Decode a HEIC/HEIF file (ISOBMFF + HEVC) via libheif.
767#[cfg(feature = "heic-decode")]
768fn decode_heic(data: &[u8]) -> RawResult<RgbImage> {
769    let decoded = crate::codecs::heic::decode_primary(data).map_err(|message| {
770        RawError::Format(FormatError::ImageDecode {
771            format: "HEIC",
772            message,
773        })
774    })?;
775    Ok(RgbImage::new(decoded.width, decoded.height, decoded.rgb))
776}
777
778#[cfg(not(feature = "heic-decode"))]
779fn decode_heic(_data: &[u8]) -> RawResult<RgbImage> {
780    Err(RawError::Format(FormatError::ImageDecode {
781        format: "HEIC",
782        message: "HEIC decoding requires the `heic` feature flag.".to_string(),
783    }))
784}
785
786// ── SVG ──────────────────────────────────────────────────────────────────────
787
788#[cfg(feature = "svg-decode")]
789fn decode_svg(data: &[u8], cfg: &ResvgDecodeConfig) -> RawResult<RgbImage> {
790    use resvg::{tiny_skia, usvg};
791
792    let options = usvg::Options {
793        dpi: cfg.dpi,
794        ..usvg::Options::default()
795    };
796    let tree = usvg::Tree::from_data(data, &options).map_err(|e| {
797        RawError::Format(FormatError::ImageDecode {
798            format: "SVG",
799            message: e.to_string(),
800        })
801    })?;
802
803    let pixmap_size = tree.size().to_int_size();
804    let width = pixmap_size.width();
805    let height = pixmap_size.height();
806
807    let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or_else(|| {
808        RawError::Format(FormatError::ImageDecode {
809            format: "SVG",
810            message: "Failed to create pixmap".to_string(),
811        })
812    })?;
813
814    resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
815
816    // pixmap contains RGBA u8 data; convert to RGB u16
817    let rgba = pixmap.data();
818    let data_u16: Vec<u16> = rgba
819        .chunks_exact(4)
820        .flat_map(|chunk| {
821            [
822                chunk[0] as u16 * 257, // R
823                chunk[1] as u16 * 257, // G
824                chunk[2] as u16 * 257, // B
825            ]
826        })
827        .collect();
828
829    Ok(RgbImage::new(width, height, data_u16))
830}
831
832#[cfg(not(feature = "svg-decode"))]
833fn decode_svg(_data: &[u8], _cfg: &ResvgDecodeConfig) -> RawResult<RgbImage> {
834    Err(RawError::Format(FormatError::ImageDecode {
835        format: "SVG",
836        message: "SVG support requires the 'svg' feature flag".to_string(),
837    }))
838}
839
840// ── APV ──────────────────────────────────────────────────────────────────────
841
842fn decode_apv(_data: &[u8]) -> RawResult<RgbImage> {
843    // APV (All-intra Predictive Video codec) is an open format developed by Samsung.
844    // No Rust decoder exists yet.
845    Err(RawError::Format(FormatError::ImageDecode {
846        format: "APV",
847        message: "APV codec decoding is not yet implemented. \
848                  The APV codec is an open format but no Rust decoder exists yet."
849            .to_string(),
850    }))
851}
852
853// ── PPM ──────────────────────────────────────────────────────────────────────
854
855#[cfg(feature = "ppm-decode")]
856fn decode_ppm(data: &[u8], _cfg: &ZunePpmDecodeConfig) -> RawResult<RgbImage> {
857    let cursor = ZCursor::new(data);
858    let mut decoder = zune_ppm::PPMDecoder::new_with_options(cursor, DecoderOptions::default());
859
860    let result = decoder.decode().map_err(|e| {
861        RawError::Format(FormatError::ImageDecode {
862            format: "PPM",
863            message: format!("{e:?}"),
864        })
865    })?;
866
867    let (w, h) = decoder
868        .dimensions()
869        .map(|(w, h)| (w as u32, h as u32))
870        .ok_or_else(|| {
871            RawError::Format(FormatError::ImageDecode {
872                format: "PPM",
873                message: "could not read image dimensions after decode".to_string(),
874            })
875        })?;
876
877    let colorspace = decoder.colorspace().unwrap_or(ColorSpace::RGB);
878    let n = colorspace.num_components();
879
880    // Convert raw samples to Vec<u16> (PFM yields f32 normalised to 0..1).
881    let samples_u16: Vec<u16> = match result {
882        DecodingResult::U8(px) => px.iter().map(|&v| u8_to_u16(v)).collect(),
883        DecodingResult::U16(px) => px,
884        DecodingResult::F32(px) => px
885            .iter()
886            .map(|&v| (v.clamp(0.0, 1.0) * 65535.0) as u16)
887            .collect(),
888        _ => {
889            return Err(RawError::Format(FormatError::ImageDecode {
890                format: "PPM",
891                message: "unexpected pixel depth in decoded result".to_string(),
892            }));
893        }
894    };
895
896    // Convert any colorspace to packed RGB u16.
897    let data_u16: Vec<u16> = match colorspace {
898        ColorSpace::RGB => samples_u16,
899        ColorSpace::RGBA => samples_u16
900            .chunks_exact(n)
901            .flat_map(|px| [px[0], px[1], px[2]])
902            .collect(),
903        ColorSpace::Luma => samples_u16.iter().flat_map(|&v| [v, v, v]).collect(),
904        ColorSpace::LumaA => samples_u16
905            .chunks_exact(n)
906            .flat_map(|px| [px[0], px[0], px[0]])
907            .collect(),
908        _ => {
909            return Err(RawError::Format(FormatError::ImageDecode {
910                format: "PPM",
911                message: format!("unsupported PPM colorspace: {colorspace:?}"),
912            }));
913        }
914    };
915
916    Ok(RgbImage::new(w, h, data_u16))
917}
918
919// ── Decoder implementation selection ──────────────────────────────────────────
920
921/// Per-implementation configuration for the `zune-jpeg` JPEG decoder.
922#[derive(Debug, Clone, PartialEq, Eq, Default)]
923#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
924pub struct ZuneJpegDecodeConfig {
925    /// Reject images wider than this, in pixels. `None` keeps the decoder's
926    /// built-in limit.
927    pub max_width: Option<usize>,
928    /// Reject images taller than this, in pixels. `None` keeps the decoder's
929    /// built-in limit.
930    pub max_height: Option<usize>,
931    /// Reject streams that deviate from the JPEG specification instead of
932    /// attempting recovery. Default: `false`.
933    pub strict: bool,
934}
935
936/// Per-implementation configuration for the `zune-png` PNG decoder.
937#[derive(Debug, Clone, PartialEq, Eq, Default)]
938#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
939pub struct ZunePngDecodeConfig {
940    /// Reject images wider than this, in pixels. `None` keeps the decoder's
941    /// built-in limit.
942    pub max_width: Option<usize>,
943    /// Reject images taller than this, in pixels. `None` keeps the decoder's
944    /// built-in limit.
945    pub max_height: Option<usize>,
946    /// Verify per-chunk CRCs while decoding. Default: `false`.
947    pub confirm_crc: bool,
948    /// Reject streams that deviate from the PNG specification. Default: `false`.
949    pub strict: bool,
950}
951
952/// Per-implementation configuration for the `resvg` SVG renderer.
953#[derive(Debug, Clone, PartialEq)]
954#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
955pub struct ResvgDecodeConfig {
956    /// Dots-per-inch used to resolve physical units (`mm`, `cm`, `in`) in the
957    /// SVG. Default: `96.0`.
958    pub dpi: f32,
959}
960
961impl Default for ResvgDecodeConfig {
962    fn default() -> Self {
963        Self { dpi: 96.0 }
964    }
965}
966
967/// Macro to define an implementation config type that currently exposes no
968/// tunable parameters. The type is a stable home for future backend-specific
969/// options — adding fields later is a non-breaking change.
970macro_rules! empty_decode_config {
971    ($(#[$m:meta])* $name:ident, $lib:literal) => {
972        #[doc = concat!("Per-implementation configuration for the `", $lib, "` decoder.")]
973        ///
974        /// This backend currently exposes no tunable parameters that affect the
975        /// decoded output; the type exists so backend-specific options can be
976        /// added without breaking the API.
977        $(#[$m])*
978        #[derive(Debug, Clone, PartialEq, Eq, Default)]
979        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
980        pub struct $name {}
981    };
982}
983
984empty_decode_config!(LibwebpDecodeConfig, "libwebp");
985empty_decode_config!(JxlOxideDecodeConfig, "jxl-oxide");
986empty_decode_config!(GifDecodeConfig, "gif");
987empty_decode_config!(TiffDecodeConfig, "tiff");
988empty_decode_config!(ImageAvifDecodeConfig, "image (avif-native)");
989empty_decode_config!(LibheifDecodeConfig, "libheif");
990empty_decode_config!(ZunePpmDecodeConfig, "zune-ppm");
991
992/// Selects which decoder implementation handles a standard image, and carries
993/// that implementation's configuration.
994///
995/// Each variant pairs a compressed format with one backend library. rawshift
996/// can be built with multiple implementations of the same format enabled (see
997/// the implementation feature flags in the crate documentation); this enum is
998/// how a caller pins exactly which one [`decode_standard_image_with`] uses.
999///
1000/// Use [`DecodeOptions::default_for`] to obtain the default backend for a
1001/// format. RAW formats are intentionally absent — they have a single in-repo
1002/// implementation and are decoded through [`RawFile`](crate::formats::RawFile).
1003#[derive(Debug, Clone, PartialEq)]
1004#[non_exhaustive]
1005#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1006pub enum DecodeOptions {
1007    /// JPEG via `zune-jpeg`.
1008    #[cfg(feature = "jpeg-decode")]
1009    JpegZune(ZuneJpegDecodeConfig),
1010    /// PNG via `zune-png`.
1011    #[cfg(feature = "png-decode")]
1012    PngZune(ZunePngDecodeConfig),
1013    /// WebP via `libwebp`.
1014    #[cfg(feature = "webp-decode")]
1015    WebpLibwebp(LibwebpDecodeConfig),
1016    /// JPEG XL via `jxl-oxide`.
1017    #[cfg(feature = "jxl-decode")]
1018    JxlOxide(JxlOxideDecodeConfig),
1019    /// GIF via the `gif` crate.
1020    #[cfg(feature = "gif-decode")]
1021    Gif(GifDecodeConfig),
1022    /// TIFF via the `tiff` crate.
1023    #[cfg(feature = "tiff-decode")]
1024    Tiff(TiffDecodeConfig),
1025    /// AVIF via `image` (`avif-native`).
1026    #[cfg(feature = "avif-decode")]
1027    AvifImage(ImageAvifDecodeConfig),
1028    /// HEIC/HEIF via `libheif`.
1029    #[cfg(feature = "heic-decode")]
1030    HeicLibheif(LibheifDecodeConfig),
1031    /// SVG via `resvg`.
1032    #[cfg(feature = "svg-decode")]
1033    SvgResvg(ResvgDecodeConfig),
1034    /// PPM / Netpbm via `zune-ppm`.
1035    #[cfg(feature = "ppm-decode")]
1036    PpmZune(ZunePpmDecodeConfig),
1037}
1038
1039impl DecodeOptions {
1040    /// The standard format this backend decodes.
1041    pub fn format(&self) -> StandardFormat {
1042        match self {
1043            #[cfg(feature = "jpeg-decode")]
1044            DecodeOptions::JpegZune(_) => StandardFormat::Jpeg,
1045            #[cfg(feature = "png-decode")]
1046            DecodeOptions::PngZune(_) => StandardFormat::Png,
1047            #[cfg(feature = "webp-decode")]
1048            DecodeOptions::WebpLibwebp(_) => StandardFormat::WebP,
1049            #[cfg(feature = "jxl-decode")]
1050            DecodeOptions::JxlOxide(_) => StandardFormat::Jxl,
1051            #[cfg(feature = "gif-decode")]
1052            DecodeOptions::Gif(_) => StandardFormat::Gif,
1053            #[cfg(feature = "tiff-decode")]
1054            DecodeOptions::Tiff(_) => StandardFormat::Tiff,
1055            #[cfg(feature = "avif-decode")]
1056            DecodeOptions::AvifImage(_) => StandardFormat::Avif,
1057            #[cfg(feature = "heic-decode")]
1058            DecodeOptions::HeicLibheif(_) => StandardFormat::Heic,
1059            #[cfg(feature = "svg-decode")]
1060            DecodeOptions::SvgResvg(_) => StandardFormat::Svg,
1061            #[cfg(feature = "ppm-decode")]
1062            DecodeOptions::PpmZune(_) => StandardFormat::Ppm,
1063            // Unreachable: with no decode feature enabled `DecodeOptions` has
1064            // no variants and no value of it can be constructed.
1065            #[allow(unreachable_patterns)]
1066            _ => unreachable!(),
1067        }
1068    }
1069
1070    /// The stable identifier of the selected decoder implementation.
1071    pub fn codec_id(&self) -> CodecId {
1072        match self {
1073            #[cfg(feature = "jpeg-decode")]
1074            DecodeOptions::JpegZune(_) => CodecId::new("jpeg/zune"),
1075            #[cfg(feature = "png-decode")]
1076            DecodeOptions::PngZune(_) => CodecId::new("png/zune"),
1077            #[cfg(feature = "webp-decode")]
1078            DecodeOptions::WebpLibwebp(_) => CodecId::new("webp/libwebp"),
1079            #[cfg(feature = "jxl-decode")]
1080            DecodeOptions::JxlOxide(_) => CodecId::new("jxl/jxl-oxide"),
1081            #[cfg(feature = "gif-decode")]
1082            DecodeOptions::Gif(_) => CodecId::new("gif/gif"),
1083            #[cfg(feature = "tiff-decode")]
1084            DecodeOptions::Tiff(_) => CodecId::new("tiff/tiff"),
1085            #[cfg(feature = "avif-decode")]
1086            DecodeOptions::AvifImage(_) => CodecId::new("avif/image"),
1087            #[cfg(feature = "heic-decode")]
1088            DecodeOptions::HeicLibheif(_) => CodecId::new("heic/libheif"),
1089            #[cfg(feature = "svg-decode")]
1090            DecodeOptions::SvgResvg(_) => CodecId::new("svg/resvg"),
1091            #[cfg(feature = "ppm-decode")]
1092            DecodeOptions::PpmZune(_) => CodecId::new("ppm/zune"),
1093            #[allow(unreachable_patterns)]
1094            _ => unreachable!(),
1095        }
1096    }
1097
1098    /// The default decoder backend for `format`, with default configuration.
1099    ///
1100    /// Returns `None` when no decoder for `format` is compiled in (the relevant
1101    /// feature flag is disabled, or the format has no decoder — e.g. APV).
1102    pub fn default_for(format: StandardFormat) -> Option<DecodeOptions> {
1103        match format {
1104            #[cfg(feature = "jpeg-decode")]
1105            StandardFormat::Jpeg => Some(DecodeOptions::JpegZune(ZuneJpegDecodeConfig::default())),
1106            #[cfg(feature = "png-decode")]
1107            StandardFormat::Png => Some(DecodeOptions::PngZune(ZunePngDecodeConfig::default())),
1108            #[cfg(feature = "webp-decode")]
1109            StandardFormat::WebP => {
1110                Some(DecodeOptions::WebpLibwebp(LibwebpDecodeConfig::default()))
1111            }
1112            #[cfg(feature = "jxl-decode")]
1113            StandardFormat::Jxl => Some(DecodeOptions::JxlOxide(JxlOxideDecodeConfig::default())),
1114            #[cfg(feature = "gif-decode")]
1115            StandardFormat::Gif => Some(DecodeOptions::Gif(GifDecodeConfig::default())),
1116            #[cfg(feature = "tiff-decode")]
1117            StandardFormat::Tiff => Some(DecodeOptions::Tiff(TiffDecodeConfig::default())),
1118            #[cfg(feature = "avif-decode")]
1119            StandardFormat::Avif => {
1120                Some(DecodeOptions::AvifImage(ImageAvifDecodeConfig::default()))
1121            }
1122            #[cfg(feature = "heic-decode")]
1123            StandardFormat::Heic => {
1124                Some(DecodeOptions::HeicLibheif(LibheifDecodeConfig::default()))
1125            }
1126            #[cfg(feature = "svg-decode")]
1127            StandardFormat::Svg => Some(DecodeOptions::SvgResvg(ResvgDecodeConfig::default())),
1128            #[cfg(feature = "ppm-decode")]
1129            StandardFormat::Ppm => Some(DecodeOptions::PpmZune(ZunePpmDecodeConfig::default())),
1130            #[allow(unreachable_patterns)]
1131            _ => None,
1132        }
1133    }
1134}
1135
1136// ── Public entry point ────────────────────────────────────────────────────────
1137
1138/// Decode a standard (non-RAW) image to an [`RgbImage`] using each format's
1139/// default decoder implementation.
1140///
1141/// The caller must supply the [`StandardFormat`] explicitly. Use
1142/// [`detect_standard_format`] to infer it from magic bytes when the format is
1143/// not otherwise known. To pin a specific decoder implementation or pass
1144/// implementation-specific configuration, use [`decode_standard_image_with`].
1145///
1146/// The returned [`RgbImage`] contains 16-bit interleaved RGB data in row-major
1147/// order. 8-bit source images are scaled to 16-bit by multiplying by 257.
1148///
1149/// # Errors
1150/// Returns [`RawError::ImageDecodeError`] on decode failure, or
1151/// [`RawError::UnsupportedFormat`] for formats without a decoder.
1152pub fn decode_standard_image(data: &[u8], format: StandardFormat) -> RawResult<RgbImage> {
1153    let decoded: RawResult<RgbImage> = match format {
1154        #[cfg(feature = "gif-decode")]
1155        StandardFormat::Gif => decode_gif(data),
1156        #[cfg(feature = "jpeg-decode")]
1157        StandardFormat::Jpeg => decode_jpeg(data, &ZuneJpegDecodeConfig::default()),
1158        #[cfg(feature = "png-decode")]
1159        StandardFormat::Png => decode_png(data, &ZunePngDecodeConfig::default()),
1160        #[cfg(feature = "webp-decode")]
1161        StandardFormat::WebP => decode_webp(data),
1162        #[cfg(feature = "jxl-decode")]
1163        StandardFormat::Jxl => decode_jxl(data),
1164        #[cfg(feature = "tiff-decode")]
1165        StandardFormat::Tiff => decode_tiff(data),
1166        StandardFormat::Avif => decode_avif(data),
1167        StandardFormat::Heic => decode_heic(data),
1168        StandardFormat::Svg => decode_svg(data, &ResvgDecodeConfig::default()),
1169        #[cfg(feature = "ppm-decode")]
1170        StandardFormat::Ppm => decode_ppm(data, &ZunePpmDecodeConfig::default()),
1171        StandardFormat::Apv => decode_apv(data),
1172        #[allow(unreachable_patterns)]
1173        _ => Err(RawError::Unsupported(format!(
1174            "Decoding {:?} requires a feature flag that is not enabled.",
1175            format.name()
1176        ))),
1177    };
1178    decoded.map(tag_srgb)
1179}
1180
1181/// Decode a standard (non-RAW) image with an explicitly selected decoder
1182/// implementation.
1183///
1184/// Unlike [`decode_standard_image`], which always uses each format's default
1185/// implementation, this lets the caller pin a specific backend library and
1186/// pass that library's configuration via [`DecodeOptions`].
1187///
1188/// # Errors
1189/// Returns a [`RawError`] if the selected backend fails to decode `data`.
1190#[cfg_attr(not(any_standard_decode), allow(unused_variables, unreachable_code))]
1191pub fn decode_standard_image_with(data: &[u8], options: &DecodeOptions) -> RawResult<RgbImage> {
1192    let decoded: RawResult<RgbImage> = match options {
1193        #[cfg(feature = "jpeg-decode")]
1194        DecodeOptions::JpegZune(cfg) => decode_jpeg(data, cfg),
1195        #[cfg(feature = "png-decode")]
1196        DecodeOptions::PngZune(cfg) => decode_png(data, cfg),
1197        #[cfg(feature = "webp-decode")]
1198        DecodeOptions::WebpLibwebp(_cfg) => decode_webp(data),
1199        #[cfg(feature = "jxl-decode")]
1200        DecodeOptions::JxlOxide(_cfg) => decode_jxl(data),
1201        #[cfg(feature = "gif-decode")]
1202        DecodeOptions::Gif(_cfg) => decode_gif(data),
1203        #[cfg(feature = "tiff-decode")]
1204        DecodeOptions::Tiff(_cfg) => decode_tiff(data),
1205        #[cfg(feature = "avif-decode")]
1206        DecodeOptions::AvifImage(_cfg) => decode_avif(data),
1207        #[cfg(feature = "heic-decode")]
1208        DecodeOptions::HeicLibheif(_cfg) => decode_heic(data),
1209        #[cfg(feature = "svg-decode")]
1210        DecodeOptions::SvgResvg(cfg) => decode_svg(data, cfg),
1211        #[cfg(feature = "ppm-decode")]
1212        DecodeOptions::PpmZune(cfg) => decode_ppm(data, cfg),
1213        // Unreachable: with no decode feature enabled `DecodeOptions` has no
1214        // variants and no value of it can be constructed.
1215        #[allow(unreachable_patterns)]
1216        _ => unreachable!(),
1217    };
1218    decoded.map(tag_srgb)
1219}
1220
1221/// Tag a freshly-decoded standard image with its color space.
1222///
1223/// Every standard decoder produces display-referred, sRGB-encoded RGB, so the
1224/// result is tagged [`ColorSpace::Srgb`](crate::core::ColorSpace::Srgb). When
1225/// the source carried a non-sRGB ICC profile the pixels are *not* converted —
1226/// the precise profile is preserved in
1227/// [`ImageMetadata::icc_profile`](crate::core::ImageMetadata) by
1228/// [`read_standard_image_metadata`], and a caller wanting true sRGB pixels can
1229/// apply [`convert_to_srgb`](crate::transforms::convert_to_srgb).
1230fn tag_srgb(mut image: RgbImage) -> RgbImage {
1231    image.set_color_space(crate::core::ColorSpace::Srgb);
1232    image
1233}
1234
1235// ── Header-only probe ─────────────────────────────────────────────────────────
1236
1237/// A cheap, header-only summary of a standard image.
1238///
1239/// Produced by [`probe_standard_image`] without decoding pixel data — useful
1240/// when ingest only needs dimensions and format up front.
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1243#[non_exhaustive]
1244pub struct ImageProbe {
1245    /// The detected image format.
1246    pub format: StandardFormat,
1247    /// Pixel dimensions read from the format header.
1248    pub size: Size,
1249    /// Bits per channel, when the header exposes it cheaply (`None` otherwise).
1250    pub bit_depth: Option<u8>,
1251    /// Best-effort color space — see [`decode_standard_image`] for the caveats.
1252    pub color_space: crate::core::ColorSpace,
1253}
1254
1255fn probe_err(format: &'static str, msg: impl Into<String>) -> RawError {
1256    RawError::Format(FormatError::ImageDecode {
1257        format,
1258        message: format!("probe: {}", msg.into()),
1259    })
1260}
1261
1262/// Read an image's format and dimensions from its header, without decoding.
1263///
1264/// Parses only the container/codec header, so it is far cheaper than a full
1265/// [`decode_standard_image`]. Works for every raster format regardless of which
1266/// decoder features are compiled in — except JXL, whose header parser needs the
1267/// `jxl-decode` feature.
1268///
1269/// # Errors
1270/// Returns an error when the format is unrecognised, the header is truncated,
1271/// or (for SVG/APV) the format has no intrinsic raster dimensions.
1272pub fn probe_standard_image(data: &[u8]) -> RawResult<ImageProbe> {
1273    let format = detect_standard_format(data)
1274        .ok_or_else(|| RawError::Unsupported("unrecognized image format".to_string()))?;
1275
1276    let (size, bit_depth) = match format {
1277        StandardFormat::Png => probe_png(data)?,
1278        StandardFormat::Jpeg => probe_jpeg(data)?,
1279        StandardFormat::Gif => probe_gif(data)?,
1280        StandardFormat::WebP => probe_webp(data)?,
1281        StandardFormat::Tiff => probe_tiff(data)?,
1282        StandardFormat::Avif => probe_isobmff(data, "AVIF")?,
1283        StandardFormat::Heic => probe_isobmff(data, "HEIC")?,
1284        StandardFormat::Ppm => probe_ppm(data)?,
1285        StandardFormat::Jxl => {
1286            #[cfg(feature = "jxl-decode")]
1287            {
1288                probe_jxl(data)?
1289            }
1290            #[cfg(not(feature = "jxl-decode"))]
1291            {
1292                return Err(RawError::Unsupported(
1293                    "probing JXL requires the `jxl-decode` feature".to_string(),
1294                ));
1295            }
1296        }
1297        StandardFormat::Svg | StandardFormat::Apv => {
1298            return Err(probe_err(
1299                format.name(),
1300                "format has no intrinsic raster dimensions",
1301            ));
1302        }
1303    };
1304
1305    Ok(ImageProbe {
1306        format,
1307        size,
1308        bit_depth,
1309        color_space: crate::core::ColorSpace::Srgb,
1310    })
1311}
1312
1313/// PNG: dimensions and bit depth live in the fixed-offset IHDR chunk.
1314fn probe_png(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1315    if data.len() < 26 || &data[12..16] != b"IHDR" {
1316        return Err(probe_err("PNG", "missing IHDR chunk"));
1317    }
1318    let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
1319    let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
1320    Ok((Size::new(width, height), Some(data[24])))
1321}
1322
1323/// JPEG: scan marker segments for a Start-Of-Frame (SOFn) marker.
1324fn probe_jpeg(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1325    let mut i = 2; // skip the SOI marker
1326    while i + 1 < data.len() {
1327        if data[i] != 0xFF {
1328            i += 1;
1329            continue;
1330        }
1331        let marker = data[i + 1];
1332        // 0xFF padding and 0xFF00 stuffed bytes carry no segment.
1333        if marker == 0xFF {
1334            i += 1;
1335            continue;
1336        }
1337        if marker == 0x00 || marker == 0x01 || (0xD0..=0xD9).contains(&marker) {
1338            i += 2;
1339            continue;
1340        }
1341        // SOF0..SOF15, excluding DHT(C4), JPG(C8) and DAC(CC).
1342        if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
1343            if i + 9 > data.len() {
1344                return Err(probe_err("JPEG", "truncated SOF segment"));
1345            }
1346            let precision = data[i + 4];
1347            let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
1348            let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
1349            return Ok((Size::new(width, height), Some(precision)));
1350        }
1351        // Any other marker carries a big-endian u16 length (incl. the 2 length
1352        // bytes) — skip past it.
1353        if i + 4 > data.len() {
1354            break;
1355        }
1356        let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
1357        i += 2 + len.max(2);
1358    }
1359    Err(probe_err("JPEG", "no SOF marker found"))
1360}
1361
1362/// GIF: the Logical Screen Descriptor follows the 6-byte signature.
1363fn probe_gif(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1364    if data.len() < 10 {
1365        return Err(probe_err("GIF", "truncated header"));
1366    }
1367    let width = u16::from_le_bytes([data[6], data[7]]) as u32;
1368    let height = u16::from_le_bytes([data[8], data[9]]) as u32;
1369    Ok((Size::new(width, height), Some(8)))
1370}
1371
1372/// WebP: dimensions live in the first RIFF chunk (`VP8X`, `VP8 ` or `VP8L`).
1373fn probe_webp(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1374    if data.len() < 30 || &data[8..12] != b"WEBP" {
1375        return Err(probe_err("WebP", "not a RIFF/WEBP container"));
1376    }
1377    // The first chunk's payload starts at offset 20 (12 RIFF + 8 chunk header).
1378    match &data[12..16] {
1379        b"VP8X" => {
1380            let width = 1 + u32::from_le_bytes([data[24], data[25], data[26], 0]);
1381            let height = 1 + u32::from_le_bytes([data[27], data[28], data[29], 0]);
1382            Ok((Size::new(width, height), Some(8)))
1383        }
1384        b"VP8 " => {
1385            // Lossy: 3-byte frame tag, 3-byte start code, then 14-bit w/h.
1386            let width = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
1387            let height = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
1388            Ok((Size::new(width, height), Some(8)))
1389        }
1390        b"VP8L" => {
1391            // Lossless: 1 signature byte, then 14-bit (w-1) and 14-bit (h-1).
1392            let bits = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
1393            let width = (bits & 0x3FFF) + 1;
1394            let height = ((bits >> 14) & 0x3FFF) + 1;
1395            Ok((Size::new(width, height), Some(8)))
1396        }
1397        _ => Err(probe_err("WebP", "unrecognized WebP chunk")),
1398    }
1399}
1400
1401/// TIFF: read `ImageWidth`/`ImageLength` from the first IFD.
1402fn probe_tiff(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1403    if data.len() < 8 {
1404        return Err(probe_err("TIFF", "truncated header"));
1405    }
1406    let le = match &data[0..2] {
1407        b"II" => true,
1408        b"MM" => false,
1409        _ => return Err(probe_err("TIFF", "bad byte-order mark")),
1410    };
1411    let rd16 = |b: &[u8]| {
1412        if le {
1413            u16::from_le_bytes([b[0], b[1]])
1414        } else {
1415            u16::from_be_bytes([b[0], b[1]])
1416        }
1417    };
1418    let rd32 = |b: &[u8]| {
1419        if le {
1420            u32::from_le_bytes([b[0], b[1], b[2], b[3]])
1421        } else {
1422            u32::from_be_bytes([b[0], b[1], b[2], b[3]])
1423        }
1424    };
1425
1426    let ifd_off = rd32(&data[4..8]) as usize;
1427    if ifd_off + 2 > data.len() {
1428        return Err(probe_err("TIFF", "IFD offset out of bounds"));
1429    }
1430    let count = rd16(&data[ifd_off..]) as usize;
1431    let (mut width, mut height) = (None, None);
1432    for entry in 0..count {
1433        let base = ifd_off + 2 + entry * 12;
1434        if base + 12 > data.len() {
1435            break;
1436        }
1437        let tag = rd16(&data[base..]);
1438        let ty = rd16(&data[base + 2..]);
1439        let value = &data[base + 8..base + 12];
1440        let scalar = match ty {
1441            3 => rd16(value) as u32, // SHORT
1442            4 => rd32(value),        // LONG
1443            _ => continue,
1444        };
1445        match tag {
1446            0x0100 => width = Some(scalar),
1447            0x0101 => height = Some(scalar),
1448            _ => {}
1449        }
1450    }
1451    match (width, height) {
1452        (Some(w), Some(h)) => Ok((Size::new(w, h), None)),
1453        _ => Err(probe_err("TIFF", "ImageWidth/ImageLength not found")),
1454    }
1455}
1456
1457/// AVIF / HEIC: locate the ISOBMFF `ispe` (image spatial extents) box. Several
1458/// may exist (thumbnails, alpha planes) — the largest is taken as the primary.
1459fn probe_isobmff(data: &[u8], format: &'static str) -> RawResult<(Size, Option<u8>)> {
1460    let mut best: Option<(u32, u32)> = None;
1461    let mut i = 0;
1462    while i + 16 <= data.len() {
1463        if &data[i..i + 4] == b"ispe" {
1464            // ispe payload: 4-byte version+flags, then BE width and height.
1465            let w = u32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]);
1466            let h = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
1467            let larger =
1468                best.is_none_or(|(bw, bh)| (bw as u64 * bh as u64) < (w as u64 * h as u64));
1469            if larger {
1470                best = Some((w, h));
1471            }
1472        }
1473        i += 1;
1474    }
1475    match best {
1476        Some((w, h)) => Ok((Size::new(w, h), None)),
1477        None => Err(probe_err(format, "no `ispe` box found")),
1478    }
1479}
1480
1481/// PPM / PGM / PBM (Netpbm): a whitespace-separated ASCII header.
1482fn probe_ppm(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1483    // After the 2-byte magic ("P1".."P6"), read ASCII tokens separated by
1484    // whitespace: width, height, and (except for bitmaps) maxval. A '#' starts
1485    // a comment that runs to end-of-line.
1486    let mut tokens: Vec<&[u8]> = Vec::new();
1487    let mut i = 2;
1488    while i < data.len() && tokens.len() < 3 {
1489        match data[i] {
1490            b'#' => {
1491                while i < data.len() && data[i] != b'\n' {
1492                    i += 1;
1493                }
1494            }
1495            b if b.is_ascii_whitespace() => i += 1,
1496            _ => {
1497                let start = i;
1498                while i < data.len() && !data[i].is_ascii_whitespace() && data[i] != b'#' {
1499                    i += 1;
1500                }
1501                tokens.push(&data[start..i]);
1502            }
1503        }
1504    }
1505    let parse =
1506        |t: &[u8]| -> Option<u32> { std::str::from_utf8(t).ok().and_then(|s| s.parse().ok()) };
1507    let width = tokens.first().and_then(|&t| parse(t));
1508    let height = tokens.get(1).and_then(|&t| parse(t));
1509    match (width, height) {
1510        (Some(w), Some(h)) => {
1511            // A maxval above 255 means 16-bit samples.
1512            let bits = tokens
1513                .get(2)
1514                .and_then(|&t| parse(t))
1515                .map(|maxval| if maxval > 255 { 16u8 } else { 8 });
1516            Ok((Size::new(w, h), bits))
1517        }
1518        _ => Err(probe_err("PPM", "could not read width/height")),
1519    }
1520}
1521
1522/// JXL: parse just enough of the codestream to read the image header.
1523#[cfg(feature = "jxl-decode")]
1524fn probe_jxl(data: &[u8]) -> RawResult<(Size, Option<u8>)> {
1525    use jxl_oxide::{InitializeResult, JxlImage};
1526
1527    let mut uninit = JxlImage::builder().build_uninit();
1528    uninit
1529        .feed_bytes(data)
1530        .map_err(|e| probe_err("JXL", e.to_string()))?;
1531    match uninit
1532        .try_init()
1533        .map_err(|e| probe_err("JXL", e.to_string()))?
1534    {
1535        InitializeResult::Initialized(img) => Ok((Size::new(img.width(), img.height()), None)),
1536        InitializeResult::NeedMoreData(_) => Err(probe_err(
1537            "JXL",
1538            "stream too short to read the image header",
1539        )),
1540    }
1541}
1542
1543#[cfg(test)]
1544mod probe_tests {
1545    use super::*;
1546
1547    #[test]
1548    fn probe_png_reads_ihdr() {
1549        // 8-byte signature + IHDR chunk header + 13-byte IHDR data.
1550        let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
1551        png.extend_from_slice(&13u32.to_be_bytes());
1552        png.extend_from_slice(b"IHDR");
1553        png.extend_from_slice(&640u32.to_be_bytes());
1554        png.extend_from_slice(&480u32.to_be_bytes());
1555        png.extend_from_slice(&[8, 2, 0, 0, 0]); // bit depth 8, color type 2 (RGB)
1556        let probe = probe_standard_image(&png).expect("probe PNG");
1557        assert_eq!(probe.format, StandardFormat::Png);
1558        assert_eq!(probe.size, Size::new(640, 480));
1559        assert_eq!(probe.bit_depth, Some(8));
1560    }
1561
1562    #[test]
1563    fn probe_gif_reads_screen_descriptor() {
1564        let mut gif = Vec::from(*b"GIF89a");
1565        gif.extend_from_slice(&320u16.to_le_bytes());
1566        gif.extend_from_slice(&200u16.to_le_bytes());
1567        gif.extend_from_slice(&[0, 0, 0]);
1568        let probe = probe_standard_image(&gif).expect("probe GIF");
1569        assert_eq!(probe.size, Size::new(320, 200));
1570    }
1571
1572    #[test]
1573    fn probe_rejects_garbage() {
1574        assert!(probe_standard_image(b"not an image at all").is_err());
1575    }
1576}
1577
1578/// Extract EXIF metadata from a standard image without decoding pixel data.
1579///
1580/// Reads embedded EXIF from image file bytes and maps the tags to the unified
1581/// [`ImageMetadata`] type.  Returns a default (empty) [`ImageMetadata`] when
1582/// the format carries no EXIF or when the format is not supported for metadata
1583/// extraction (e.g. GIF, SVG, APV).
1584///
1585/// # Supported formats
1586/// | Format | Metadata source |
1587/// |--------|----------------|
1588/// | JPEG   | APP1 EXIF segment |
1589/// | TIFF   | IFD0 EXIF tags |
1590/// | WebP   | EXIF chunk |
1591/// | AVIF   | HEIF/ISOBMFF Exif item |
1592/// | PNG    | eXIf chunk |
1593/// | HEIC   | HEIF Exif + ICC + XMP items (requires the `heic` feature) |
1594/// | GIF / JXL / SVG / APV | returns empty metadata |
1595/// When the `exif` feature is disabled the crate has no EXIF parser, so this
1596/// degrades to returning empty metadata for every format.
1597#[cfg(feature = "exif")]
1598pub fn read_standard_image_metadata(
1599    data: &[u8],
1600    format: StandardFormat,
1601) -> crate::core::metadata::ImageMetadata {
1602    use crate::metadata::exif::ExifParser;
1603    use little_exif::filetype::FileExtension;
1604
1605    // HEIC goes through libheif so that ICC and XMP are extracted alongside EXIF.
1606    #[cfg(feature = "heic-decode")]
1607    if format == StandardFormat::Heic {
1608        return crate::formats::heic::read_heic_metadata(data);
1609    }
1610
1611    let file_type = match format {
1612        StandardFormat::Jpeg => FileExtension::JPEG,
1613        StandardFormat::Tiff => FileExtension::TIFF,
1614        StandardFormat::WebP => FileExtension::WEBP,
1615        StandardFormat::Avif => FileExtension::HEIF,
1616        StandardFormat::Png => FileExtension::PNG {
1617            as_zTXt_chunk: false,
1618        },
1619        // Formats with no EXIF support in little_exif
1620        _ => return crate::core::metadata::ImageMetadata::default(),
1621    };
1622
1623    ExifParser::parse_from_bytes(data, file_type)
1624}
1625
1626/// Extract EXIF metadata from a standard image without decoding pixel data.
1627///
1628/// This is the `exif`-feature-disabled build: the crate carries no EXIF parser,
1629/// so empty metadata is always returned. See the `exif`-enabled variant for the
1630/// documented behaviour.
1631#[cfg(not(feature = "exif"))]
1632pub fn read_standard_image_metadata(
1633    _data: &[u8],
1634    _format: StandardFormat,
1635) -> crate::core::metadata::ImageMetadata {
1636    crate::core::metadata::ImageMetadata::default()
1637}
1638
1639// ── Tests ────────────────────────────────────────────────────────────────────
1640
1641#[cfg(test)]
1642mod tests {
1643    use super::*;
1644
1645    // ── detect_standard_format ────────────────────────────────────────────
1646
1647    #[test]
1648    fn detect_gif89a() {
1649        let magic = *b"GIF89a\x01\x00";
1650        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Gif));
1651    }
1652
1653    #[test]
1654    fn detect_gif87a() {
1655        let magic = *b"GIF87a\x01\x00";
1656        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Gif));
1657    }
1658
1659    #[test]
1660    fn detect_gif_non_gif_returns_none() {
1661        let magic = *b"GIF99z\x01\x00";
1662        assert_ne!(detect_standard_format(&magic), Some(StandardFormat::Gif));
1663    }
1664
1665    #[test]
1666    fn detect_jpeg() {
1667        let magic = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
1668        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Jpeg));
1669    }
1670
1671    #[test]
1672    fn detect_png() {
1673        let magic = *b"\x89PNG\r\n\x1a\n";
1674        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Png));
1675    }
1676
1677    #[test]
1678    fn detect_webp() {
1679        let mut magic = [0u8; 12];
1680        magic[0..4].copy_from_slice(b"RIFF");
1681        magic[8..12].copy_from_slice(b"WEBP");
1682        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::WebP));
1683    }
1684
1685    #[test]
1686    fn detect_jxl_bare() {
1687        let magic = [0xFF, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
1688        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Jxl));
1689    }
1690
1691    #[test]
1692    fn detect_jxl_container() {
1693        let mut magic = [0u8; 12];
1694        // bytes 4..8 must be "JXL "
1695        magic[4..8].copy_from_slice(b"JXL ");
1696        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Jxl));
1697    }
1698
1699    #[test]
1700    fn detect_tiff_le() {
1701        let magic = [0x49, 0x49, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00];
1702        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Tiff));
1703    }
1704
1705    #[test]
1706    fn detect_tiff_be() {
1707        let magic = [0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08];
1708        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Tiff));
1709    }
1710
1711    #[test]
1712    fn detect_avif() {
1713        let mut magic = [0u8; 12];
1714        magic[4..8].copy_from_slice(b"ftyp");
1715        magic[8..12].copy_from_slice(b"avif");
1716        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Avif));
1717    }
1718
1719    #[test]
1720    fn detect_avif_avis() {
1721        let mut magic = [0u8; 12];
1722        magic[4..8].copy_from_slice(b"ftyp");
1723        magic[8..12].copy_from_slice(b"avis");
1724        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Avif));
1725    }
1726
1727    #[test]
1728    fn detect_unknown_returns_none() {
1729        let magic = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
1730        assert_eq!(detect_standard_format(&magic), None);
1731    }
1732
1733    #[test]
1734    fn detect_too_short_returns_none() {
1735        assert_eq!(detect_standard_format(&[0xFF, 0xD8]), None);
1736    }
1737
1738    // ── StandardFormat ────────────────────────────────────────────────────
1739
1740    #[test]
1741    fn standard_format_name() {
1742        assert_eq!(StandardFormat::Gif.name(), "GIF");
1743        assert_eq!(StandardFormat::Jpeg.name(), "JPEG");
1744        assert_eq!(StandardFormat::Png.name(), "PNG");
1745        assert_eq!(StandardFormat::WebP.name(), "WebP");
1746        assert_eq!(StandardFormat::Jxl.name(), "JXL");
1747        assert_eq!(StandardFormat::Tiff.name(), "TIFF");
1748        assert_eq!(StandardFormat::Avif.name(), "AVIF");
1749    }
1750
1751    #[test]
1752    fn standard_format_variants_exist() {
1753        let _variants = [
1754            StandardFormat::Gif,
1755            StandardFormat::Jpeg,
1756            StandardFormat::Png,
1757            StandardFormat::WebP,
1758            StandardFormat::Jxl,
1759            StandardFormat::Tiff,
1760            StandardFormat::Avif,
1761            StandardFormat::Heic,
1762            StandardFormat::Svg,
1763            StandardFormat::Apv,
1764            StandardFormat::Ppm,
1765        ];
1766    }
1767
1768    // ── u8_to_u16 ────────────────────────────────────────────────────────
1769
1770    #[test]
1771    fn u8_to_u16_min_max() {
1772        assert_eq!(u8_to_u16(0), 0);
1773        assert_eq!(u8_to_u16(255), 65535);
1774        assert_eq!(u8_to_u16(1), 257);
1775        assert_eq!(u8_to_u16(128), 32896);
1776    }
1777
1778    // ── JPEG roundtrip ────────────────────────────────────────────────────
1779
1780    #[test]
1781    fn jpeg_roundtrip_dimensions() {
1782        // Encode a 4x4 RGB image to JPEG, then decode it and check dimensions.
1783        const W: u16 = 4;
1784        const H: u16 = 4;
1785        let pixels: Vec<u8> = (0..(W as usize * H as usize * 3))
1786            .map(|i| (i * 17 % 256) as u8)
1787            .collect();
1788
1789        let mut encoded = Vec::new();
1790        let encoder = jpeg_encoder::Encoder::new(&mut encoded, 95);
1791        encoder
1792            .encode(&pixels, W, H, jpeg_encoder::ColorType::Rgb)
1793            .expect("JPEG encode failed");
1794
1795        let decoded =
1796            decode_standard_image(&encoded, StandardFormat::Jpeg).expect("JPEG decode failed");
1797
1798        assert_eq!(decoded.width(), W as u32);
1799        assert_eq!(decoded.height(), H as u32);
1800        assert_eq!(decoded.data.len(), W as usize * H as usize * 3);
1801    }
1802
1803    // ── PNG roundtrip ─────────────────────────────────────────────────────
1804
1805    #[test]
1806    fn png_roundtrip_dimensions() {
1807        // Encode a 4x4 8-bit RGB image to PNG, then decode it and check
1808        // dimensions and that data contains 16-bit samples.
1809        const W: usize = 4;
1810        const H: usize = 4;
1811        let pixels_u8: Vec<u8> = (0..(W * H * 3)).map(|i| (i * 13 % 256) as u8).collect();
1812
1813        let opts = zune_core::options::EncoderOptions::default()
1814            .set_width(W)
1815            .set_height(H)
1816            .set_colorspace(ColorSpace::RGB);
1817        let mut encoded = Vec::new();
1818        let mut encoder = zune_png::PngEncoder::new(&pixels_u8, opts);
1819        encoder.encode(&mut encoded).expect("PNG encode failed");
1820
1821        let decoded =
1822            decode_standard_image(&encoded, StandardFormat::Png).expect("PNG decode failed");
1823
1824        assert_eq!(decoded.width(), W as u32);
1825        assert_eq!(decoded.height(), H as u32);
1826        assert_eq!(decoded.data.len(), W * H * 3);
1827        // Each u8 value should have been scaled to u16
1828        assert_eq!(decoded.data[0], u8_to_u16(pixels_u8[0]));
1829    }
1830
1831    // ── DecodeOptions / decode_standard_image_with ────────────────────────
1832
1833    #[test]
1834    fn decode_options_default_for_apv_is_none() {
1835        // APV has no decoder, so there is no default backend.
1836        assert!(DecodeOptions::default_for(StandardFormat::Apv).is_none());
1837    }
1838
1839    #[cfg(feature = "png-decode")]
1840    #[test]
1841    fn decode_options_default_for_roundtrips_format() {
1842        let opts = DecodeOptions::default_for(StandardFormat::Png).expect("png decoder");
1843        assert_eq!(opts.format(), StandardFormat::Png);
1844        assert!(matches!(opts, DecodeOptions::PngZune(_)));
1845    }
1846
1847    #[cfg(feature = "png-decode")]
1848    #[test]
1849    fn decode_standard_image_with_selects_png_backend() {
1850        const W: usize = 4;
1851        const H: usize = 4;
1852        let pixels_u8: Vec<u8> = (0..(W * H * 3)).map(|i| (i * 13 % 256) as u8).collect();
1853
1854        let opts = zune_core::options::EncoderOptions::default()
1855            .set_width(W)
1856            .set_height(H)
1857            .set_colorspace(ColorSpace::RGB);
1858        let mut encoded = Vec::new();
1859        let mut encoder = zune_png::PngEncoder::new(&pixels_u8, opts);
1860        encoder.encode(&mut encoded).expect("PNG encode failed");
1861
1862        // Decode through the explicit-backend API with a non-default config.
1863        let cfg = ZunePngDecodeConfig {
1864            confirm_crc: true,
1865            ..ZunePngDecodeConfig::default()
1866        };
1867        let via_with = decode_standard_image_with(&encoded, &DecodeOptions::PngZune(cfg))
1868            .expect("decode_standard_image_with failed");
1869        // The default-path API must produce an identical result.
1870        let via_default =
1871            decode_standard_image(&encoded, StandardFormat::Png).expect("PNG decode failed");
1872
1873        assert_eq!(via_with.width(), W as u32);
1874        assert_eq!(via_with.height(), H as u32);
1875        assert_eq!(via_with.data, via_default.data);
1876    }
1877
1878    // ── detect + decode consistency ───────────────────────────────────────
1879
1880    #[test]
1881    fn detect_then_decode_jpeg() {
1882        const W: u16 = 2;
1883        const H: u16 = 2;
1884        let pixels = vec![
1885            100u8, 150u8, 200u8, 50u8, 75u8, 100u8, 200u8, 220u8, 240u8, 10u8, 20u8, 30u8,
1886        ];
1887        let mut encoded = Vec::new();
1888        jpeg_encoder::Encoder::new(&mut encoded, 90)
1889            .encode(&pixels, W, H, jpeg_encoder::ColorType::Rgb)
1890            .unwrap();
1891
1892        let fmt = detect_standard_format(&encoded);
1893        assert_eq!(fmt, Some(StandardFormat::Jpeg));
1894        let img = decode_standard_image(&encoded, fmt.unwrap()).unwrap();
1895        assert_eq!(img.width(), W as u32);
1896        assert_eq!(img.height(), H as u32);
1897    }
1898
1899    // ── PPM detect + decode ───────────────────────────────────────────────
1900
1901    #[test]
1902    fn detect_ppm_p6() {
1903        let magic = *b"P6\n2 2\n255\n";
1904        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Ppm));
1905    }
1906
1907    #[test]
1908    fn detect_ppm_rejects_ascii_pbm() {
1909        // P1-P4 are not handled by the zune-ppm backend, so must not be detected.
1910        let magic = *b"P1\n2 2\n0 0\n";
1911        assert_eq!(detect_standard_format(&magic), None);
1912    }
1913
1914    #[cfg(feature = "ppm-decode")]
1915    #[test]
1916    fn ppm_p6_decode_dimensions_and_samples() {
1917        // Hand-crafted 2×2 binary P6 (RGB, maxval 255) — no encoder needed.
1918        // Sample values deliberately avoid ASCII-whitespace bytes (9-13, 32) so
1919        // the first pixel byte is not mistaken for header padding.
1920        let pixels: [u8; 12] = [
1921            100, 120, 130, // (0,0)
1922            140, 150, 160, // (1,0)
1923            170, 180, 190, // (0,1)
1924            200, 210, 220, // (1,1)
1925        ];
1926        let mut file = b"P6\n2 2\n255\n".to_vec();
1927        file.extend_from_slice(&pixels);
1928
1929        let fmt = detect_standard_format(&file);
1930        assert_eq!(fmt, Some(StandardFormat::Ppm));
1931
1932        let decoded = decode_standard_image(&file, StandardFormat::Ppm).expect("PPM decode failed");
1933        assert_eq!(decoded.width(), 2);
1934        assert_eq!(decoded.height(), 2);
1935        assert_eq!(decoded.data.len(), 2 * 2 * 3);
1936        // 8-bit samples must have been scaled to 16-bit.
1937        assert_eq!(decoded.data[0], u8_to_u16(pixels[0]));
1938        assert_eq!(decoded.data[11], u8_to_u16(pixels[11]));
1939    }
1940
1941    // ── GIF decode ────────────────────────────────────────────────────────
1942
1943    /// Build a minimal but valid GIF89a file in memory.
1944    ///
1945    /// Creates a 2×2 image with 4 palette entries and distinct pixel colours:
1946    ///   index 0 → red  (255, 0, 0)
1947    ///   index 1 → green (0, 255, 0)
1948    ///   index 2 → blue  (0, 0, 255)
1949    ///   index 3 → white (255, 255, 255)
1950    fn make_minimal_gif() -> Vec<u8> {
1951        // We hand-craft the GIF binary so we don't need a separate encoder crate.
1952        // The LZW-compressed pixel data for indices [0, 1, 2, 3] with min_code_size=2
1953        // was pre-computed. This is a known-good 2×2 GIF.
1954        use gif::{Encoder, Frame, Repeat};
1955        use std::borrow::Cow;
1956
1957        let palette: &[u8] = &[
1958            255, 0, 0, // 0: red
1959            0, 255, 0, // 1: green
1960            0, 0, 255, // 2: blue
1961            255, 255, 255, // 3: white
1962            0, 0, 0, // 4: black (padding to a power of 2)
1963            0, 0, 0, 0, 0, 0, 0, 0, 0,
1964        ];
1965
1966        let mut out: Vec<u8> = Vec::new();
1967        let mut encoder = Encoder::new(&mut out, 2, 2, palette).expect("gif encoder init");
1968        encoder.set_repeat(Repeat::Finite(0)).expect("set repeat");
1969
1970        let frame = Frame {
1971            width: 2,
1972            height: 2,
1973            // Pixel indices row-major: top-left=0, top-right=1, bottom-left=2, bottom-right=3
1974            buffer: Cow::Owned(vec![0u8, 1, 2, 3]),
1975            ..Frame::default()
1976        };
1977        encoder.write_frame(&frame).expect("write gif frame");
1978        drop(encoder);
1979        out
1980    }
1981
1982    #[test]
1983    fn gif_decode_dimensions() {
1984        let gif_data = make_minimal_gif();
1985        let img =
1986            decode_standard_image(&gif_data, StandardFormat::Gif).expect("GIF decode must succeed");
1987        assert_eq!(img.width(), 2, "decoded width must be 2");
1988        assert_eq!(img.height(), 2, "decoded height must be 2");
1989        assert_eq!(
1990            img.data.len(),
1991            2 * 2 * 3,
1992            "must have 12 u16 samples (2×2×3)"
1993        );
1994    }
1995
1996    #[test]
1997    fn gif_decode_detect_then_decode() {
1998        let gif_data = make_minimal_gif();
1999        let fmt = detect_standard_format(&gif_data);
2000        assert_eq!(
2001            fmt,
2002            Some(StandardFormat::Gif),
2003            "format detection must return GIF"
2004        );
2005        let img = decode_standard_image(&gif_data, fmt.unwrap()).unwrap();
2006        assert_eq!(img.width(), 2);
2007        assert_eq!(img.height(), 2);
2008    }
2009
2010    #[test]
2011    fn gif_decode_first_pixel_is_red() {
2012        let gif_data = make_minimal_gif();
2013        let img = decode_standard_image(&gif_data, StandardFormat::Gif).unwrap();
2014        // Index 0 → red (255, 0, 0) → scaled to u16: (255*257, 0, 0)
2015        assert_eq!(img.data[0], u8_to_u16(255), "R of top-left pixel");
2016        assert_eq!(img.data[1], u8_to_u16(0), "G of top-left pixel");
2017        assert_eq!(img.data[2], u8_to_u16(0), "B of top-left pixel");
2018    }
2019
2020    #[test]
2021    fn gif_decode_invalid_data_returns_error() {
2022        let junk = vec![0u8; 32];
2023        let result = decode_standard_image(&junk, StandardFormat::Gif);
2024        assert!(result.is_err(), "junk data must return an error");
2025    }
2026
2027    // ── stub error paths ──────────────────────────────────────────────────
2028
2029    #[test]
2030    fn tiff_invalid_data_returns_error() {
2031        // Truncated TIFF magic bytes should return a decode error, not a panic.
2032        let magic = [0x49u8, 0x49, 0x2A, 0x00, 0, 0, 0, 8];
2033        let result = decode_standard_image(&magic, StandardFormat::Tiff);
2034        assert!(result.is_err());
2035    }
2036
2037    /// Build a minimal valid TIFF file (2×2 RGB, 8-bit) in memory using the tiff crate encoder.
2038    fn make_minimal_tiff_rgb8() -> Vec<u8> {
2039        use std::io::Cursor;
2040        use tiff::encoder::{TiffEncoder, colortype::RGB8};
2041        let mut cursor = Cursor::new(Vec::new());
2042        {
2043            let mut enc = TiffEncoder::new(&mut cursor).unwrap();
2044            let pixels: Vec<u8> = vec![
2045                255, 0, 0, // red
2046                0, 255, 0, // green
2047                0, 0, 255, // blue
2048                255, 255, 255, // white
2049            ];
2050            enc.write_image::<RGB8>(2, 2, &pixels).unwrap();
2051        }
2052        cursor.into_inner()
2053    }
2054
2055    #[test]
2056    fn tiff_decode_dimensions() {
2057        let tiff_data = make_minimal_tiff_rgb8();
2058        let img = decode_standard_image(&tiff_data, StandardFormat::Tiff)
2059            .expect("TIFF decode must succeed");
2060        assert_eq!(img.width(), 2);
2061        assert_eq!(img.height(), 2);
2062        assert_eq!(img.data.len(), 2 * 2 * 3);
2063    }
2064
2065    #[test]
2066    fn tiff_decode_first_pixel_is_red() {
2067        let tiff_data = make_minimal_tiff_rgb8();
2068        let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap();
2069        assert_eq!(img.data[0], u8_to_u16(255), "R of top-left pixel");
2070        assert_eq!(img.data[1], u8_to_u16(0), "G of top-left pixel");
2071        assert_eq!(img.data[2], u8_to_u16(0), "B of top-left pixel");
2072    }
2073
2074    #[test]
2075    fn tiff_detect_then_decode() {
2076        let tiff_data = make_minimal_tiff_rgb8();
2077        let fmt = detect_standard_format(&tiff_data);
2078        assert_eq!(fmt, Some(StandardFormat::Tiff));
2079        let img = decode_standard_image(&tiff_data, fmt.unwrap()).unwrap();
2080        assert_eq!(img.width(), 2);
2081        assert_eq!(img.height(), 2);
2082    }
2083
2084    /// Build a grayscale TIFF (4×4, 8-bit) in memory.
2085    fn make_tiff_gray8() -> Vec<u8> {
2086        use std::io::Cursor;
2087        use tiff::encoder::{TiffEncoder, colortype::Gray8};
2088        let mut cursor = Cursor::new(Vec::new());
2089        {
2090            let mut enc = TiffEncoder::new(&mut cursor).unwrap();
2091            let pixels: Vec<u8> = (0..16).map(|i| (i * 17) as u8).collect();
2092            enc.write_image::<Gray8>(4, 4, &pixels).unwrap();
2093        }
2094        cursor.into_inner()
2095    }
2096
2097    #[test]
2098    fn tiff_decode_grayscale_expands_to_rgb() {
2099        let tiff_data = make_tiff_gray8();
2100        let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap();
2101        assert_eq!(img.width(), 4);
2102        assert_eq!(img.height(), 4);
2103        assert_eq!(img.data.len(), 4 * 4 * 3);
2104        // Grayscale: R == G == B for each pixel
2105        for px in img.data.chunks_exact(3) {
2106            assert_eq!(px[0], px[1]);
2107            assert_eq!(px[1], px[2]);
2108        }
2109    }
2110
2111    /// Build an RGBA TIFF (2×2, 8-bit) in memory.
2112    fn make_tiff_rgba8() -> Vec<u8> {
2113        use std::io::Cursor;
2114        use tiff::encoder::{TiffEncoder, colortype::RGBA8};
2115        let mut cursor = Cursor::new(Vec::new());
2116        {
2117            let mut enc = TiffEncoder::new(&mut cursor).unwrap();
2118            let pixels: Vec<u8> = vec![
2119                255, 0, 0, 128, // red, half alpha
2120                0, 255, 0, 255, // green, full alpha
2121                0, 0, 255, 0, // blue, zero alpha
2122                255, 255, 255, 255, // white, full alpha
2123            ];
2124            enc.write_image::<RGBA8>(2, 2, &pixels).unwrap();
2125        }
2126        cursor.into_inner()
2127    }
2128
2129    #[test]
2130    fn tiff_decode_rgba_drops_alpha() {
2131        let tiff_data = make_tiff_rgba8();
2132        let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap();
2133        assert_eq!(img.width(), 2);
2134        assert_eq!(img.height(), 2);
2135        // Should be RGB only (alpha dropped)
2136        assert_eq!(img.data.len(), 2 * 2 * 3);
2137        // First pixel should be red
2138        assert_eq!(img.data[0], u8_to_u16(255));
2139        assert_eq!(img.data[1], u8_to_u16(0));
2140        assert_eq!(img.data[2], u8_to_u16(0));
2141    }
2142
2143    /// Build a 16-bit RGB TIFF (2×2) in memory.
2144    fn make_tiff_rgb16() -> Vec<u8> {
2145        use std::io::Cursor;
2146        use tiff::encoder::{TiffEncoder, colortype::RGB16};
2147        let mut cursor = Cursor::new(Vec::new());
2148        {
2149            let mut enc = TiffEncoder::new(&mut cursor).unwrap();
2150            let pixels: Vec<u16> = vec![
2151                65535, 0, 0, // red
2152                0, 65535, 0, // green
2153                0, 0, 65535, // blue
2154                32768, 32768, 32768, // gray
2155            ];
2156            enc.write_image::<RGB16>(2, 2, &pixels).unwrap();
2157        }
2158        cursor.into_inner()
2159    }
2160
2161    #[test]
2162    fn tiff_decode_16bit_preserves_values() {
2163        let tiff_data = make_tiff_rgb16();
2164        let img = decode_standard_image(&tiff_data, StandardFormat::Tiff).unwrap();
2165        assert_eq!(img.width(), 2);
2166        assert_eq!(img.height(), 2);
2167        // 16-bit values should be preserved exactly
2168        assert_eq!(img.data[0], 65535); // R of red pixel
2169        assert_eq!(img.data[1], 0); // G of red pixel
2170        assert_eq!(img.data[2], 0); // B of red pixel
2171    }
2172
2173    #[cfg(not(feature = "avif-decode"))]
2174    #[test]
2175    fn avif_returns_unsupported_without_feature() {
2176        let mut magic = [0u8; 12];
2177        magic[4..8].copy_from_slice(b"ftyp");
2178        magic[8..12].copy_from_slice(b"avif");
2179        let result = decode_standard_image(&magic, StandardFormat::Avif);
2180        assert!(result.is_err());
2181        assert!(matches!(result, Err(RawError::Unsupported(_))));
2182    }
2183
2184    // ── StandardFormat name (new variants) ───────────────────────────────
2185
2186    #[test]
2187    fn standard_format_name_new_variants() {
2188        assert_eq!(StandardFormat::Heic.name(), "HEIC");
2189        assert_eq!(StandardFormat::Svg.name(), "SVG");
2190        assert_eq!(StandardFormat::Apv.name(), "APV");
2191    }
2192
2193    // ── Display ──────────────────────────────────────────────────────────
2194
2195    #[test]
2196    fn standard_format_display() {
2197        let all = [
2198            (StandardFormat::Gif, "GIF"),
2199            (StandardFormat::Jpeg, "JPEG"),
2200            (StandardFormat::Png, "PNG"),
2201            (StandardFormat::WebP, "WebP"),
2202            (StandardFormat::Jxl, "JXL"),
2203            (StandardFormat::Tiff, "TIFF"),
2204            (StandardFormat::Avif, "AVIF"),
2205            (StandardFormat::Heic, "HEIC"),
2206            (StandardFormat::Svg, "SVG"),
2207            (StandardFormat::Apv, "APV"),
2208        ];
2209        for (fmt, expected) in all {
2210            assert_eq!(format!("{}", fmt), expected);
2211            assert_eq!(fmt.to_string(), expected);
2212        }
2213    }
2214
2215    // ── extension / from_extension / mime_type ──────────────────────────
2216
2217    #[test]
2218    fn extension_roundtrip() {
2219        let all = [
2220            StandardFormat::Gif,
2221            StandardFormat::Jpeg,
2222            StandardFormat::Png,
2223            StandardFormat::WebP,
2224            StandardFormat::Jxl,
2225            StandardFormat::Tiff,
2226            StandardFormat::Avif,
2227            StandardFormat::Heic,
2228            StandardFormat::Svg,
2229            StandardFormat::Apv,
2230        ];
2231        for fmt in all {
2232            let ext = fmt.extension();
2233            assert_eq!(
2234                StandardFormat::from_extension(ext),
2235                Some(fmt),
2236                "roundtrip failed for {:?} (ext={ext})",
2237                fmt
2238            );
2239        }
2240    }
2241
2242    #[test]
2243    fn from_extension_case_insensitive() {
2244        assert_eq!(
2245            StandardFormat::from_extension("JPG"),
2246            Some(StandardFormat::Jpeg)
2247        );
2248        assert_eq!(
2249            StandardFormat::from_extension("Png"),
2250            Some(StandardFormat::Png)
2251        );
2252        assert_eq!(
2253            StandardFormat::from_extension("WEBP"),
2254            Some(StandardFormat::WebP)
2255        );
2256    }
2257
2258    #[test]
2259    fn from_extension_aliases() {
2260        // JPEG aliases
2261        for ext in ["jpg", "jpeg", "jpe", "jfif"] {
2262            assert_eq!(
2263                StandardFormat::from_extension(ext),
2264                Some(StandardFormat::Jpeg),
2265                "alias {ext}"
2266            );
2267        }
2268        // TIFF alias
2269        assert_eq!(
2270            StandardFormat::from_extension("tif"),
2271            Some(StandardFormat::Tiff)
2272        );
2273        // HEIC alias
2274        assert_eq!(
2275            StandardFormat::from_extension("heif"),
2276            Some(StandardFormat::Heic)
2277        );
2278        // SVG alias
2279        assert_eq!(
2280            StandardFormat::from_extension("svgz"),
2281            Some(StandardFormat::Svg)
2282        );
2283    }
2284
2285    #[test]
2286    fn from_extension_unknown_returns_none() {
2287        assert_eq!(StandardFormat::from_extension("bmp"), None);
2288        assert_eq!(StandardFormat::from_extension(""), None);
2289        assert_eq!(StandardFormat::from_extension("raw"), None);
2290    }
2291
2292    #[test]
2293    fn mime_types() {
2294        assert_eq!(StandardFormat::Gif.mime_type(), "image/gif");
2295        assert_eq!(StandardFormat::Jpeg.mime_type(), "image/jpeg");
2296        assert_eq!(StandardFormat::Png.mime_type(), "image/png");
2297        assert_eq!(StandardFormat::WebP.mime_type(), "image/webp");
2298        assert_eq!(StandardFormat::Jxl.mime_type(), "image/jxl");
2299        assert_eq!(StandardFormat::Tiff.mime_type(), "image/tiff");
2300        assert_eq!(StandardFormat::Avif.mime_type(), "image/avif");
2301        assert_eq!(StandardFormat::Heic.mime_type(), "image/heic");
2302        assert_eq!(StandardFormat::Svg.mime_type(), "image/svg+xml");
2303        assert_eq!(StandardFormat::Apv.mime_type(), "video/apv");
2304    }
2305
2306    // ── supports_decode / supports_encode ───────────────────────────────
2307
2308    #[test]
2309    fn supports_decode_standard_formats() {
2310        assert!(StandardFormat::Gif.supports_decode());
2311        assert!(StandardFormat::Jpeg.supports_decode());
2312        assert!(StandardFormat::Png.supports_decode());
2313        assert!(StandardFormat::WebP.supports_decode());
2314        assert!(StandardFormat::Jxl.supports_decode());
2315        assert!(StandardFormat::Tiff.supports_decode());
2316        // AVIF decode requires the `avif-decode` feature
2317        #[cfg(feature = "avif-decode")]
2318        assert!(StandardFormat::Avif.supports_decode());
2319        #[cfg(not(feature = "avif-decode"))]
2320        assert!(!StandardFormat::Avif.supports_decode());
2321        // HEIC decode requires the `heic` feature
2322        #[cfg(feature = "heic-decode")]
2323        assert!(StandardFormat::Heic.supports_decode());
2324        #[cfg(not(feature = "heic-decode"))]
2325        assert!(!StandardFormat::Heic.supports_decode());
2326        // Stubbed formats
2327        assert!(!StandardFormat::Apv.supports_decode());
2328    }
2329
2330    #[test]
2331    fn supports_encode_standard_formats() {
2332        assert!(StandardFormat::Png.supports_encode());
2333        assert!(StandardFormat::Jpeg.supports_encode());
2334        assert!(StandardFormat::WebP.supports_encode());
2335        // Formats without encoding support
2336        assert!(!StandardFormat::Gif.supports_encode());
2337        assert!(!StandardFormat::Tiff.supports_encode());
2338        assert!(!StandardFormat::Heic.supports_encode());
2339        assert!(!StandardFormat::Apv.supports_encode());
2340    }
2341
2342    // ── HEIC detection and decode ─────────────────────────────────────────
2343
2344    #[cfg(feature = "heic-decode")]
2345    #[test]
2346    fn detect_heic_heic_brand() {
2347        let mut magic = [0u8; 12];
2348        magic[4..8].copy_from_slice(b"ftyp");
2349        magic[8..12].copy_from_slice(b"heic");
2350        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Heic));
2351    }
2352
2353    #[cfg(feature = "heic-decode")]
2354    #[test]
2355    fn detect_heic_heis_brand() {
2356        let mut magic = [0u8; 12];
2357        magic[4..8].copy_from_slice(b"ftyp");
2358        magic[8..12].copy_from_slice(b"heis");
2359        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Heic));
2360    }
2361
2362    #[cfg(feature = "heic-decode")]
2363    #[test]
2364    fn detect_heic_hevc_brand() {
2365        let mut magic = [0u8; 12];
2366        magic[4..8].copy_from_slice(b"ftyp");
2367        magic[8..12].copy_from_slice(b"hevc");
2368        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Heic));
2369    }
2370
2371    #[cfg(feature = "heic-decode")]
2372    #[test]
2373    fn detect_heic_hevx_brand() {
2374        let mut magic = [0u8; 12];
2375        magic[4..8].copy_from_slice(b"ftyp");
2376        magic[8..12].copy_from_slice(b"hevx");
2377        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Heic));
2378    }
2379
2380    #[test]
2381    fn detect_non_heic_ftyp_cr3_returns_none_for_heic() {
2382        // CR3 uses ftyp with 'crx ' brand — must NOT be detected as HEIC
2383        let mut magic = [0u8; 12];
2384        magic[4..8].copy_from_slice(b"ftyp");
2385        magic[8..12].copy_from_slice(b"crx ");
2386        assert_ne!(detect_standard_format(&magic), Some(StandardFormat::Heic));
2387    }
2388
2389    #[cfg(feature = "heic-decode")]
2390    #[test]
2391    fn heic_decode_returns_error() {
2392        let mut magic = [0u8; 12];
2393        magic[4..8].copy_from_slice(b"ftyp");
2394        magic[8..12].copy_from_slice(b"heic");
2395        let result = decode_standard_image(&magic, StandardFormat::Heic);
2396        assert!(result.is_err());
2397        assert!(matches!(
2398            result,
2399            Err(RawError::Format(FormatError::ImageDecode {
2400                format: "HEIC",
2401                ..
2402            }))
2403        ));
2404    }
2405
2406    // ── APV detection and decode ──────────────────────────────────────────
2407
2408    #[test]
2409    fn detect_apv_apv1_brand() {
2410        let mut magic = [0u8; 12];
2411        magic[4..8].copy_from_slice(b"ftyp");
2412        magic[8..12].copy_from_slice(b"apv1");
2413        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Apv));
2414    }
2415
2416    #[test]
2417    fn detect_apv_apvx_brand() {
2418        let mut magic = [0u8; 12];
2419        magic[4..8].copy_from_slice(b"ftyp");
2420        magic[8..12].copy_from_slice(b"apvx");
2421        assert_eq!(detect_standard_format(&magic), Some(StandardFormat::Apv));
2422    }
2423
2424    #[test]
2425    fn apv_decode_returns_error() {
2426        let mut magic = [0u8; 12];
2427        magic[4..8].copy_from_slice(b"ftyp");
2428        magic[8..12].copy_from_slice(b"apv1");
2429        let result = decode_standard_image(&magic, StandardFormat::Apv);
2430        assert!(result.is_err());
2431        assert!(matches!(
2432            result,
2433            Err(RawError::Format(FormatError::ImageDecode {
2434                format: "APV",
2435                ..
2436            }))
2437        ));
2438    }
2439
2440    // ── SVG detection ─────────────────────────────────────────────────────
2441
2442    #[test]
2443    fn detect_svg_xml_prefix() {
2444        let data = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
2445        assert_eq!(detect_standard_format(data), Some(StandardFormat::Svg));
2446    }
2447
2448    #[test]
2449    fn detect_svg_bare_svg_tag() {
2450        let data = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
2451        assert_eq!(detect_standard_format(data), Some(StandardFormat::Svg));
2452    }
2453
2454    #[test]
2455    fn detect_svg_xml_without_svg_tag_returns_none() {
2456        // XML that contains no <svg element
2457        let data = b"<?xml version=\"1.0\"?><root></root>";
2458        assert_eq!(detect_standard_format(data), None);
2459    }
2460
2461    #[test]
2462    fn svg_decode_returns_error_without_feature() {
2463        // Without the 'svg' feature, decode should return an error.
2464        #[cfg(not(feature = "svg-decode"))]
2465        {
2466            let data = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"></svg>";
2467            let result = decode_standard_image(data, StandardFormat::Svg);
2468            assert!(result.is_err());
2469            assert!(matches!(
2470                result,
2471                Err(RawError::Format(FormatError::ImageDecode {
2472                    format: "SVG",
2473                    ..
2474                }))
2475            ));
2476        }
2477        // With the svg feature enabled, the test is skipped here (covered by feature-gated tests).
2478        #[cfg(feature = "svg-decode")]
2479        {
2480            // Just verify the variant exists and the name is correct.
2481            assert_eq!(StandardFormat::Svg.name(), "SVG");
2482        }
2483    }
2484
2485    #[cfg(feature = "svg-decode")]
2486    #[test]
2487    fn svg_decode_simple_rect() {
2488        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">
2489            <rect width="4" height="4" fill="red"/>
2490        </svg>"#;
2491        let result = decode_standard_image(svg, StandardFormat::Svg);
2492        assert!(result.is_ok());
2493        let img = result.unwrap();
2494        assert_eq!(img.width(), 4);
2495        assert_eq!(img.height(), 4);
2496        assert_eq!(img.data.len(), 4 * 4 * 3);
2497    }
2498
2499    // ── read_standard_image_metadata ─────────────────────────────────────
2500
2501    #[test]
2502    fn read_metadata_unsupported_format_returns_default() {
2503        // GIF has no EXIF support → returns empty metadata (no panic)
2504        let gif_header = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\x00\x00\x00\x00\x00\x3b";
2505        let md = read_standard_image_metadata(gif_header, StandardFormat::Gif);
2506        assert!(md.camera.make.is_empty());
2507        assert!(md.exif.iso.is_none());
2508    }
2509
2510    #[test]
2511    fn read_metadata_invalid_data_returns_default() {
2512        // Garbage data → little_exif returns error → we return default
2513        let junk = b"\x00\x01\x02\x03\x04\x05\x06\x07";
2514        let md = read_standard_image_metadata(junk, StandardFormat::Jpeg);
2515        assert!(md.camera.make.is_empty());
2516    }
2517
2518    #[cfg(feature = "avif")]
2519    #[test]
2520    fn avif_exif_round_trip() {
2521        use crate::core::metadata::*;
2522        use crate::formats::encode_rgb_image;
2523        use crate::formats::export::{
2524            CommonEncodeOptions, EncodeOptions, MetadataEmbedOptions, RavifEncodeConfig,
2525        };
2526
2527        // Build a 2×2 synthetic image (solid red).
2528        let data: Vec<u16> = vec![65535, 0, 0, 65535, 0, 0, 65535, 0, 0, 65535, 0, 0];
2529        let rgb = RgbImage::new(2, 2, data);
2530
2531        // Build metadata with known EXIF values.
2532        let md = ImageMetadata {
2533            camera: CameraInfo {
2534                make: "TestMake".to_string(),
2535                model: "TestModel".to_string(),
2536                ..Default::default()
2537            },
2538            exif: ExifInfo {
2539                iso: Some(400),
2540                focal_length: Some(URational::new(50, 1)),
2541                ..Default::default()
2542            },
2543            datetime: DateTimeInfo {
2544                datetime_original: Some("2025:06:15 12:00:00".to_string()),
2545                ..Default::default()
2546            },
2547            ..Default::default()
2548        };
2549
2550        let tmp = std::env::temp_dir().join("rawshift_avif_exif_test.avif");
2551        let opts = EncodeOptions::AvifRavif(RavifEncodeConfig {
2552            quality: 60,
2553            speed: 10,
2554            common: CommonEncodeOptions {
2555                metadata: MetadataEmbedOptions {
2556                    embed_icc: false,
2557                    ..MetadataEmbedOptions::default()
2558                },
2559                ..Default::default()
2560            },
2561        });
2562        encode_rgb_image(&rgb, &md, &tmp, &opts).expect("encode AVIF");
2563
2564        // Read back the AVIF file and extract metadata.
2565        let avif_bytes = std::fs::read(&tmp).expect("read back AVIF");
2566        let read_md = read_standard_image_metadata(&avif_bytes, StandardFormat::Avif);
2567
2568        // Verify core EXIF tags survived the round-trip.
2569        assert_eq!(read_md.camera.make, "TestMake", "make round-trip");
2570        assert_eq!(read_md.camera.model, "TestModel", "model round-trip");
2571        assert_eq!(read_md.exif.iso, Some(400), "ISO round-trip");
2572        assert_eq!(
2573            read_md.exif.focal_length.map(|r| r.numerator),
2574            Some(50),
2575            "focal_length round-trip"
2576        );
2577        assert_eq!(
2578            read_md.datetime.datetime_original,
2579            Some("2025:06:15 12:00:00".to_string()),
2580            "datetime_original round-trip"
2581        );
2582
2583        let _ = std::fs::remove_file(&tmp);
2584    }
2585
2586    #[cfg(feature = "avif-decode")]
2587    #[test]
2588    fn avif_supports_decode_with_feature() {
2589        assert!(StandardFormat::Avif.supports_decode());
2590    }
2591
2592    #[cfg(feature = "avif-encode")]
2593    #[test]
2594    fn avif_supports_encode_with_feature() {
2595        assert!(StandardFormat::Avif.supports_encode());
2596    }
2597}