Skip to main content

spatialrust_image_io/
lib.rs

1//! Bounded, feature-gated image decoding and encoding for SpatialRust.
2//!
3//! All input is staged in CPU memory with an explicit compressed-input limit.
4//! This crate never uploads to or reads from a device. Codec dependencies are
5//! selected independently from the storage-only `spatialrust-image` crate.
6//!
7//! ```no_run
8//! use spatialrust_image_io::{decode_path, DecodeOptions, DecodedPixels};
9//!
10//! let decoded = decode_path("frame.png", DecodeOptions::default())?;
11//! match decoded.pixels() {
12//!     DecodedPixels::Rgb8(image) => assert!(image.width() > 0),
13//!     other => println!("decoded {other:?}"),
14//! }
15//! # Ok::<(), spatialrust_image_io::ImageIoError>(())
16//! ```
17
18#![deny(unsafe_code)]
19#![warn(missing_docs)]
20
21use std::fs::File;
22use std::io::{BufReader, BufWriter, Cursor, Read, Seek, Write};
23use std::path::Path;
24
25use image::{DynamicImage, GenericImageView, ImageBuffer, ImageFormat, ImageOutputFormat};
26use spatialrust_image::{AlphaMode, ColorRange, ColorSpace, Image, ImageError, ImageMetadata};
27
28/// Errors produced by bounded image IO.
29#[derive(Debug, thiserror::Error)]
30pub enum ImageIoError {
31    /// Reading or writing the stream failed.
32    #[error(transparent)]
33    Io(#[from] std::io::Error),
34    /// A codec rejected the image or encoded output.
35    #[error(transparent)]
36    Codec(#[from] image::ImageError),
37    /// Constructing a typed SpatialRust image failed.
38    #[error(transparent)]
39    Image(#[from] ImageError),
40    /// The stream exceeded the configured compressed-input limit.
41    #[error("encoded input exceeds the {maximum} byte limit")]
42    InputTooLarge {
43        /// Maximum accepted encoded byte count.
44        maximum: usize,
45    },
46    /// Decoded dimensions or pixel count exceeded the configured limits.
47    #[error("decoded image dimensions {width}x{height} exceed configured limits")]
48    DimensionsTooLarge {
49        /// Encoded image width.
50        width: u32,
51        /// Encoded image height.
52        height: u32,
53    },
54    /// The detected/requested format is not enabled in this build.
55    #[error("image format `{0}` is not enabled in this build")]
56    FormatDisabled(ImageFileFormat),
57    /// The input format is not one of SpatialRust's image-IO formats.
58    #[error("unsupported image format: {0}")]
59    UnsupportedFormat(String),
60    /// Encoding options were invalid.
61    #[error("invalid encode option: {0}")]
62    InvalidEncodeOption(String),
63    /// A decoded dimension cannot be represented by this platform.
64    #[error("decoded dimensions cannot be represented by usize")]
65    DimensionOverflow,
66}
67
68/// File formats recognized by the stable SpatialRust image-IO boundary.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub enum ImageFileFormat {
71    /// Portable Network Graphics.
72    Png,
73    /// JPEG/JFIF image.
74    Jpeg,
75    /// Portable anymap family (PBM/PGM/PPM/PAM).
76    Pnm,
77    /// Tagged Image File Format.
78    Tiff,
79    /// OpenEXR high-dynamic-range image.
80    OpenExr,
81}
82
83impl std::fmt::Display for ImageFileFormat {
84    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        formatter.write_str(match self {
86            Self::Png => "png",
87            Self::Jpeg => "jpeg",
88            Self::Pnm => "pnm",
89            Self::Tiff => "tiff",
90            Self::OpenExr => "openexr",
91        })
92    }
93}
94
95impl ImageFileFormat {
96    /// Returns whether this codec is compiled into the current crate.
97    #[must_use]
98    pub const fn is_enabled(self) -> bool {
99        match self {
100            Self::Png => cfg!(feature = "png"),
101            Self::Jpeg => cfg!(feature = "jpeg"),
102            Self::Pnm => cfg!(feature = "pnm"),
103            Self::Tiff => cfg!(feature = "tiff"),
104            Self::OpenExr => cfg!(feature = "openexr"),
105        }
106    }
107
108    fn from_backend(format: ImageFormat) -> Result<Self, ImageIoError> {
109        match format {
110            ImageFormat::Png => Ok(Self::Png),
111            ImageFormat::Jpeg => Ok(Self::Jpeg),
112            ImageFormat::Pnm => Ok(Self::Pnm),
113            ImageFormat::Tiff => Ok(Self::Tiff),
114            ImageFormat::OpenExr => Ok(Self::OpenExr),
115            other => Err(ImageIoError::UnsupportedFormat(format!("{other:?}"))),
116        }
117    }
118
119    fn require_enabled(self) -> Result<(), ImageIoError> {
120        if self.is_enabled() {
121            Ok(())
122        } else {
123            Err(ImageIoError::FormatDisabled(self))
124        }
125    }
126}
127
128/// Strict application-level resource limits for one decode operation.
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub struct DecodeLimits {
131    /// Maximum compressed input size in bytes.
132    pub max_input_bytes: usize,
133    /// Maximum decoded width.
134    pub max_width: u32,
135    /// Maximum decoded height.
136    pub max_height: u32,
137    /// Maximum decoded pixel count.
138    pub max_pixels: u64,
139    /// Best-effort maximum simultaneous allocation performed by the backend.
140    pub max_alloc_bytes: u64,
141}
142
143impl Default for DecodeLimits {
144    fn default() -> Self {
145        Self {
146            max_input_bytes: 256 * 1024 * 1024,
147            max_width: 32_768,
148            max_height: 32_768,
149            max_pixels: 100_000_000,
150            max_alloc_bytes: 512 * 1024 * 1024,
151        }
152    }
153}
154
155impl DecodeLimits {
156    fn validate_dimensions(self, width: u32, height: u32) -> Result<(), ImageIoError> {
157        let pixels = u64::from(width).saturating_mul(u64::from(height));
158        if width > self.max_width || height > self.max_height || pixels > self.max_pixels {
159            return Err(ImageIoError::DimensionsTooLarge { width, height });
160        }
161        Ok(())
162    }
163
164    fn backend(self) -> image::io::Limits {
165        let mut limits = image::io::Limits::default();
166        limits.max_image_width = Some(self.max_width);
167        limits.max_image_height = Some(self.max_height);
168        limits.max_alloc = Some(self.max_alloc_bytes);
169        limits
170    }
171}
172
173/// Options controlling one decode operation.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub struct DecodeOptions {
176    /// Resource limits applied before and during decoding.
177    pub limits: DecodeLimits,
178    /// Apply Exif orientation to produce canonical top-left pixels.
179    pub apply_orientation: bool,
180}
181
182impl Default for DecodeOptions {
183    fn default() -> Self {
184        Self { limits: DecodeLimits::default(), apply_orientation: true }
185    }
186}
187
188/// Exif image orientation in encoded-pixel coordinates.
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
190#[repr(u8)]
191pub enum Orientation {
192    /// No orientation tag was found.
193    #[default]
194    Unspecified = 0,
195    /// Top-left origin; no transform.
196    Normal = 1,
197    /// Mirror left-to-right.
198    FlipHorizontal = 2,
199    /// Rotate 180 degrees.
200    Rotate180 = 3,
201    /// Mirror top-to-bottom.
202    FlipVertical = 4,
203    /// Reflect across the top-left to bottom-right diagonal.
204    Transpose = 5,
205    /// Rotate 90 degrees clockwise.
206    Rotate90 = 6,
207    /// Reflect across the top-right to bottom-left diagonal.
208    Transverse = 7,
209    /// Rotate 270 degrees clockwise.
210    Rotate270 = 8,
211}
212
213impl Orientation {
214    #[cfg(feature = "exif")]
215    fn from_exif(value: u32) -> Self {
216        match value {
217            1 => Self::Normal,
218            2 => Self::FlipHorizontal,
219            3 => Self::Rotate180,
220            4 => Self::FlipVertical,
221            5 => Self::Transpose,
222            6 => Self::Rotate90,
223            7 => Self::Transverse,
224            8 => Self::Rotate270,
225            _ => Self::Unspecified,
226        }
227    }
228
229    fn changes_pixels(self) -> bool {
230        !matches!(self, Self::Unspecified | Self::Normal)
231    }
232}
233
234/// Encoded sample/channel type before conversion into SpatialRust storage.
235#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
236pub enum SourceColorType {
237    /// 8-bit grayscale.
238    Gray8,
239    /// 8-bit grayscale plus alpha.
240    GrayAlpha8,
241    /// 8-bit RGB.
242    Rgb8,
243    /// 8-bit RGBA.
244    Rgba8,
245    /// 16-bit grayscale.
246    Gray16,
247    /// 16-bit grayscale plus alpha.
248    GrayAlpha16,
249    /// 16-bit RGB.
250    Rgb16,
251    /// 16-bit RGBA.
252    Rgba16,
253    /// 32-bit floating-point RGB.
254    Rgb32Float,
255    /// 32-bit floating-point RGBA.
256    Rgba32Float,
257}
258
259impl SourceColorType {
260    fn from_backend(color: image::ColorType) -> Result<Self, ImageIoError> {
261        match color {
262            image::ColorType::L8 => Ok(Self::Gray8),
263            image::ColorType::La8 => Ok(Self::GrayAlpha8),
264            image::ColorType::Rgb8 => Ok(Self::Rgb8),
265            image::ColorType::Rgba8 => Ok(Self::Rgba8),
266            image::ColorType::L16 => Ok(Self::Gray16),
267            image::ColorType::La16 => Ok(Self::GrayAlpha16),
268            image::ColorType::Rgb16 => Ok(Self::Rgb16),
269            image::ColorType::Rgba16 => Ok(Self::Rgba16),
270            image::ColorType::Rgb32F => Ok(Self::Rgb32Float),
271            image::ColorType::Rgba32F => Ok(Self::Rgba32Float),
272            other => Err(ImageIoError::UnsupportedFormat(format!(
273                "unsupported decoded color type {other:?}"
274            ))),
275        }
276    }
277}
278
279/// Owned typed pixels produced by a decoder or accepted by an encoder.
280#[derive(Clone, Debug, PartialEq)]
281pub enum DecodedPixels {
282    /// One-channel 8-bit grayscale.
283    Gray8(Image<u8, 1>),
284    /// Two-channel 8-bit grayscale and alpha.
285    GrayAlpha8(Image<u8, 2>),
286    /// Three-channel 8-bit RGB.
287    Rgb8(Image<u8, 3>),
288    /// Four-channel 8-bit RGBA.
289    Rgba8(Image<u8, 4>),
290    /// One-channel 16-bit grayscale.
291    Gray16(Image<u16, 1>),
292    /// Two-channel 16-bit grayscale and alpha.
293    GrayAlpha16(Image<u16, 2>),
294    /// Three-channel 16-bit RGB.
295    Rgb16(Image<u16, 3>),
296    /// Four-channel 16-bit RGBA.
297    Rgba16(Image<u16, 4>),
298    /// Three-channel linear-light floating-point RGB.
299    Rgb32Float(Image<f32, 3>),
300    /// Four-channel linear-light floating-point RGBA.
301    Rgba32Float(Image<f32, 4>),
302}
303
304impl DecodedPixels {
305    /// Returns pixel width.
306    #[must_use]
307    pub fn width(&self) -> usize {
308        match self {
309            Self::Gray8(image) => image.width(),
310            Self::GrayAlpha8(image) => image.width(),
311            Self::Rgb8(image) => image.width(),
312            Self::Rgba8(image) => image.width(),
313            Self::Gray16(image) => image.width(),
314            Self::GrayAlpha16(image) => image.width(),
315            Self::Rgb16(image) => image.width(),
316            Self::Rgba16(image) => image.width(),
317            Self::Rgb32Float(image) => image.width(),
318            Self::Rgba32Float(image) => image.width(),
319        }
320    }
321
322    /// Returns pixel height.
323    #[must_use]
324    pub fn height(&self) -> usize {
325        match self {
326            Self::Gray8(image) => image.height(),
327            Self::GrayAlpha8(image) => image.height(),
328            Self::Rgb8(image) => image.height(),
329            Self::Rgba8(image) => image.height(),
330            Self::Gray16(image) => image.height(),
331            Self::GrayAlpha16(image) => image.height(),
332            Self::Rgb16(image) => image.height(),
333            Self::Rgba16(image) => image.height(),
334            Self::Rgb32Float(image) => image.height(),
335            Self::Rgba32Float(image) => image.height(),
336        }
337    }
338
339    fn to_dynamic(&self) -> Result<DynamicImage, ImageIoError> {
340        let width = u32::try_from(self.width()).map_err(|_| ImageIoError::DimensionOverflow)?;
341        let height = u32::try_from(self.height()).map_err(|_| ImageIoError::DimensionOverflow)?;
342        macro_rules! buffer {
343            ($image:expr, $pixel:ty, $variant:path) => {{
344                let typed = ImageBuffer::<$pixel, Vec<_>>::from_raw(
345                    width,
346                    height,
347                    $image.as_slice().to_vec(),
348                )
349                .ok_or(ImageIoError::DimensionOverflow)?;
350                $variant(typed)
351            }};
352        }
353        Ok(match self {
354            Self::Gray8(image) => buffer!(image, image::Luma<u8>, DynamicImage::ImageLuma8),
355            Self::GrayAlpha8(image) => {
356                buffer!(image, image::LumaA<u8>, DynamicImage::ImageLumaA8)
357            }
358            Self::Rgb8(image) => buffer!(image, image::Rgb<u8>, DynamicImage::ImageRgb8),
359            Self::Rgba8(image) => buffer!(image, image::Rgba<u8>, DynamicImage::ImageRgba8),
360            Self::Gray16(image) => buffer!(image, image::Luma<u16>, DynamicImage::ImageLuma16),
361            Self::GrayAlpha16(image) => {
362                buffer!(image, image::LumaA<u16>, DynamicImage::ImageLumaA16)
363            }
364            Self::Rgb16(image) => buffer!(image, image::Rgb<u16>, DynamicImage::ImageRgb16),
365            Self::Rgba16(image) => buffer!(image, image::Rgba<u16>, DynamicImage::ImageRgba16),
366            Self::Rgb32Float(image) => {
367                buffer!(image, image::Rgb<f32>, DynamicImage::ImageRgb32F)
368            }
369            Self::Rgba32Float(image) => {
370                buffer!(image, image::Rgba<f32>, DynamicImage::ImageRgba32F)
371            }
372        })
373    }
374}
375
376/// Metadata describing encoded pixels and orientation handling.
377#[derive(Clone, Copy, Debug, PartialEq, Eq)]
378pub struct DecodedMetadata {
379    /// Detected container/codec format.
380    pub format: ImageFileFormat,
381    /// Sample and channel representation reported by the decoder.
382    pub source_color_type: SourceColorType,
383    /// Orientation tag found in the encoded container.
384    pub orientation: Orientation,
385    /// Whether a non-identity orientation was applied to the returned pixels.
386    pub orientation_applied: bool,
387}
388
389/// One decoded image with owned typed pixels and source metadata.
390#[derive(Clone, Debug, PartialEq)]
391pub struct DecodedImage {
392    pixels: DecodedPixels,
393    metadata: DecodedMetadata,
394}
395
396impl DecodedImage {
397    /// Borrows decoded pixels.
398    #[must_use]
399    pub fn pixels(&self) -> &DecodedPixels {
400        &self.pixels
401    }
402
403    /// Returns source metadata.
404    #[must_use]
405    pub const fn metadata(&self) -> DecodedMetadata {
406        self.metadata
407    }
408
409    /// Consumes the wrapper and returns pixels.
410    #[must_use]
411    pub fn into_pixels(self) -> DecodedPixels {
412        self.pixels
413    }
414
415    /// Returns decoded width after orientation handling.
416    #[must_use]
417    pub fn width(&self) -> usize {
418        self.pixels.width()
419    }
420
421    /// Returns decoded height after orientation handling.
422    #[must_use]
423    pub fn height(&self) -> usize {
424        self.pixels.height()
425    }
426}
427
428/// Options controlling encoded output.
429#[derive(Clone, Copy, Debug, PartialEq, Eq)]
430pub struct EncodeOptions {
431    /// Output codec/container.
432    pub format: ImageFileFormat,
433    /// JPEG quality in the inclusive range 1–100.
434    pub jpeg_quality: u8,
435}
436
437impl EncodeOptions {
438    /// Creates options for one format with JPEG quality 90.
439    #[must_use]
440    pub const fn new(format: ImageFileFormat) -> Self {
441        Self { format, jpeg_quality: 90 }
442    }
443
444    fn output_format(self) -> Result<ImageOutputFormat, ImageIoError> {
445        self.format.require_enabled()?;
446        if self.jpeg_quality == 0 || self.jpeg_quality > 100 {
447            return Err(ImageIoError::InvalidEncodeOption(
448                "jpeg_quality must be in 1..=100".to_owned(),
449            ));
450        }
451        match self.format {
452            #[cfg(feature = "png")]
453            ImageFileFormat::Png => Ok(ImageOutputFormat::Png),
454            #[cfg(not(feature = "png"))]
455            ImageFileFormat::Png => Err(ImageIoError::FormatDisabled(self.format)),
456            #[cfg(feature = "jpeg")]
457            ImageFileFormat::Jpeg => Ok(ImageOutputFormat::Jpeg(self.jpeg_quality)),
458            #[cfg(not(feature = "jpeg"))]
459            ImageFileFormat::Jpeg => Err(ImageIoError::FormatDisabled(self.format)),
460            #[cfg(feature = "pnm")]
461            ImageFileFormat::Pnm => {
462                Ok(ImageOutputFormat::Pnm(image::codecs::pnm::PnmSubtype::ArbitraryMap))
463            }
464            #[cfg(not(feature = "pnm"))]
465            ImageFileFormat::Pnm => Err(ImageIoError::FormatDisabled(self.format)),
466            #[cfg(feature = "tiff")]
467            ImageFileFormat::Tiff => Ok(ImageOutputFormat::Tiff),
468            #[cfg(not(feature = "tiff"))]
469            ImageFileFormat::Tiff => Err(ImageIoError::FormatDisabled(self.format)),
470            #[cfg(feature = "openexr")]
471            ImageFileFormat::OpenExr => Ok(ImageOutputFormat::OpenExr),
472            #[cfg(not(feature = "openexr"))]
473            ImageFileFormat::OpenExr => Err(ImageIoError::FormatDisabled(self.format)),
474        }
475    }
476}
477
478/// Decodes an image from an encoded byte slice.
479pub fn decode_bytes(bytes: &[u8], options: DecodeOptions) -> Result<DecodedImage, ImageIoError> {
480    if bytes.len() > options.limits.max_input_bytes {
481        return Err(ImageIoError::InputTooLarge { maximum: options.limits.max_input_bytes });
482    }
483    let backend_format = image::guess_format(bytes)?;
484    let format = ImageFileFormat::from_backend(backend_format)?;
485    format.require_enabled()?;
486
487    let dimensions =
488        image::io::Reader::with_format(Cursor::new(bytes), backend_format).into_dimensions()?;
489    options.limits.validate_dimensions(dimensions.0, dimensions.1)?;
490
491    let orientation = read_orientation(bytes);
492    let mut reader = image::io::Reader::with_format(Cursor::new(bytes), backend_format);
493    reader.limits(options.limits.backend());
494    let dynamic = reader.decode()?;
495    let source_color_type = SourceColorType::from_backend(dynamic.color())?;
496    let orientation_applied = options.apply_orientation && orientation.changes_pixels();
497    let dynamic =
498        if orientation_applied { apply_orientation(dynamic, orientation) } else { dynamic };
499    let pixels = dynamic_to_pixels(dynamic)?;
500    Ok(DecodedImage {
501        pixels,
502        metadata: DecodedMetadata { format, source_color_type, orientation, orientation_applied },
503    })
504}
505
506/// Reads a bounded encoded stream and decodes it.
507pub fn decode_reader<R: Read>(
508    reader: R,
509    options: DecodeOptions,
510) -> Result<DecodedImage, ImageIoError> {
511    let maximum = options.limits.max_input_bytes;
512    let take_limit = u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1);
513    let mut bytes = Vec::new();
514    reader.take(take_limit).read_to_end(&mut bytes)?;
515    if bytes.len() > maximum {
516        return Err(ImageIoError::InputTooLarge { maximum });
517    }
518    decode_bytes(&bytes, options)
519}
520
521/// Opens and decodes one image path using content-based format detection.
522pub fn decode_path(
523    path: impl AsRef<Path>,
524    options: DecodeOptions,
525) -> Result<DecodedImage, ImageIoError> {
526    decode_reader(BufReader::new(File::open(path)?), options)
527}
528
529/// Encodes typed pixels to a seekable writer.
530///
531/// The codec backend receives a temporary owned CPU buffer. No device transfer
532/// occurs, and the source image remains unchanged.
533pub fn encode_writer<W: Write + Seek>(
534    writer: &mut W,
535    pixels: &DecodedPixels,
536    options: EncodeOptions,
537) -> Result<(), ImageIoError> {
538    let dynamic = pixels.to_dynamic()?;
539    dynamic.write_to(writer, options.output_format()?)?;
540    Ok(())
541}
542
543/// Encodes typed pixels into an owned byte vector.
544pub fn encode_bytes(
545    pixels: &DecodedPixels,
546    options: EncodeOptions,
547) -> Result<Vec<u8>, ImageIoError> {
548    let mut cursor = Cursor::new(Vec::new());
549    encode_writer(&mut cursor, pixels, options)?;
550    Ok(cursor.into_inner())
551}
552
553/// Creates or truncates a path and encodes typed pixels into it.
554pub fn encode_path(
555    path: impl AsRef<Path>,
556    pixels: &DecodedPixels,
557    options: EncodeOptions,
558) -> Result<(), ImageIoError> {
559    let mut writer = BufWriter::new(File::create(path)?);
560    encode_writer(&mut writer, pixels, options)?;
561    writer.flush()?;
562    Ok(())
563}
564
565fn metadata(color_space: ColorSpace, alpha_mode: AlphaMode, floating: bool) -> ImageMetadata {
566    ImageMetadata {
567        color_space,
568        color_range: if floating { ColorRange::Unspecified } else { ColorRange::Full },
569        alpha_mode,
570    }
571}
572
573fn dimensions(dynamic: &DynamicImage) -> Result<(usize, usize), ImageIoError> {
574    let (width, height) = dynamic.dimensions();
575    Ok((
576        usize::try_from(width).map_err(|_| ImageIoError::DimensionOverflow)?,
577        usize::try_from(height).map_err(|_| ImageIoError::DimensionOverflow)?,
578    ))
579}
580
581fn dynamic_to_pixels(dynamic: DynamicImage) -> Result<DecodedPixels, ImageIoError> {
582    let (width, height) = dimensions(&dynamic)?;
583    Ok(match dynamic {
584        DynamicImage::ImageLuma8(image) => DecodedPixels::Gray8(Image::try_new_with_metadata(
585            width,
586            height,
587            image.into_raw(),
588            metadata(ColorSpace::Gray, AlphaMode::None, false),
589        )?),
590        DynamicImage::ImageLumaA8(image) => {
591            DecodedPixels::GrayAlpha8(Image::try_new_with_metadata(
592                width,
593                height,
594                image.into_raw(),
595                metadata(ColorSpace::Unknown, AlphaMode::Straight, false),
596            )?)
597        }
598        DynamicImage::ImageRgb8(image) => DecodedPixels::Rgb8(Image::try_new_with_metadata(
599            width,
600            height,
601            image.into_raw(),
602            metadata(ColorSpace::Rgb, AlphaMode::None, false),
603        )?),
604        DynamicImage::ImageRgba8(image) => DecodedPixels::Rgba8(Image::try_new_with_metadata(
605            width,
606            height,
607            image.into_raw(),
608            metadata(ColorSpace::Rgba, AlphaMode::Straight, false),
609        )?),
610        DynamicImage::ImageLuma16(image) => DecodedPixels::Gray16(Image::try_new_with_metadata(
611            width,
612            height,
613            image.into_raw(),
614            metadata(ColorSpace::Gray, AlphaMode::None, false),
615        )?),
616        DynamicImage::ImageLumaA16(image) => {
617            DecodedPixels::GrayAlpha16(Image::try_new_with_metadata(
618                width,
619                height,
620                image.into_raw(),
621                metadata(ColorSpace::Unknown, AlphaMode::Straight, false),
622            )?)
623        }
624        DynamicImage::ImageRgb16(image) => DecodedPixels::Rgb16(Image::try_new_with_metadata(
625            width,
626            height,
627            image.into_raw(),
628            metadata(ColorSpace::Rgb, AlphaMode::None, false),
629        )?),
630        DynamicImage::ImageRgba16(image) => DecodedPixels::Rgba16(Image::try_new_with_metadata(
631            width,
632            height,
633            image.into_raw(),
634            metadata(ColorSpace::Rgba, AlphaMode::Straight, false),
635        )?),
636        DynamicImage::ImageRgb32F(image) => {
637            DecodedPixels::Rgb32Float(Image::try_new_with_metadata(
638                width,
639                height,
640                image.into_raw(),
641                metadata(ColorSpace::LinearRgb, AlphaMode::None, true),
642            )?)
643        }
644        DynamicImage::ImageRgba32F(image) => {
645            DecodedPixels::Rgba32Float(Image::try_new_with_metadata(
646                width,
647                height,
648                image.into_raw(),
649                metadata(ColorSpace::Unknown, AlphaMode::Straight, true),
650            )?)
651        }
652        _ => {
653            return Err(ImageIoError::UnsupportedFormat(
654                "decoder returned an unsupported dynamic image variant".to_owned(),
655            ))
656        }
657    })
658}
659
660fn apply_orientation(image: DynamicImage, orientation: Orientation) -> DynamicImage {
661    match orientation {
662        Orientation::Unspecified | Orientation::Normal => image,
663        Orientation::FlipHorizontal => image.fliph(),
664        Orientation::Rotate180 => image.rotate180(),
665        Orientation::FlipVertical => image.flipv(),
666        Orientation::Transpose => image.rotate90().fliph(),
667        Orientation::Rotate90 => image.rotate90(),
668        Orientation::Transverse => image.rotate90().flipv(),
669        Orientation::Rotate270 => image.rotate270(),
670    }
671}
672
673#[cfg(feature = "exif")]
674fn read_orientation(bytes: &[u8]) -> Orientation {
675    use exif::{In, Reader, Tag};
676
677    let mut cursor = Cursor::new(bytes);
678    Reader::new()
679        .read_from_container(&mut cursor)
680        .ok()
681        .and_then(|exif| {
682            exif.get_field(Tag::Orientation, In::PRIMARY).and_then(|field| field.value.get_uint(0))
683        })
684        .map(Orientation::from_exif)
685        .unwrap_or(Orientation::Unspecified)
686}
687
688#[cfg(not(feature = "exif"))]
689fn read_orientation(_bytes: &[u8]) -> Orientation {
690    Orientation::Unspecified
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use proptest::prelude::*;
697
698    #[cfg(any(feature = "png", feature = "jpeg", feature = "pnm"))]
699    fn rgb_fixture() -> DecodedPixels {
700        DecodedPixels::Rgb8(
701            Image::try_new_with_metadata(
702                3,
703                2,
704                vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30, 40, 50, 60, 70, 80, 90],
705                metadata(ColorSpace::Rgb, AlphaMode::None, false),
706            )
707            .unwrap(),
708        )
709    }
710
711    #[cfg(feature = "png")]
712    #[test]
713    fn png_memory_roundtrip_is_exact() {
714        let source = rgb_fixture();
715        let bytes = encode_bytes(&source, EncodeOptions::new(ImageFileFormat::Png)).unwrap();
716        let decoded = decode_bytes(&bytes, DecodeOptions::default()).unwrap();
717        assert_eq!(decoded.pixels(), &source);
718        assert_eq!(decoded.metadata().format, ImageFileFormat::Png);
719        assert_eq!(decoded.metadata().source_color_type, SourceColorType::Rgb8);
720    }
721
722    #[cfg(feature = "pnm")]
723    #[test]
724    fn pnm_reader_roundtrip_is_exact() {
725        let source = rgb_fixture();
726        let bytes = encode_bytes(&source, EncodeOptions::new(ImageFileFormat::Pnm)).unwrap();
727        let decoded = decode_reader(Cursor::new(bytes), DecodeOptions::default()).unwrap();
728        assert_eq!(decoded.pixels(), &source);
729    }
730
731    #[cfg(feature = "jpeg")]
732    #[test]
733    fn jpeg_roundtrip_preserves_shape_and_type() {
734        let bytes =
735            encode_bytes(&rgb_fixture(), EncodeOptions::new(ImageFileFormat::Jpeg)).unwrap();
736        let decoded = decode_bytes(&bytes, DecodeOptions::default()).unwrap();
737        assert_eq!((decoded.width(), decoded.height()), (3, 2));
738        assert!(matches!(decoded.pixels(), DecodedPixels::Rgb8(_)));
739    }
740
741    #[cfg(feature = "png")]
742    #[test]
743    fn dimensions_and_input_are_bounded_before_decode() {
744        let bytes = encode_bytes(&rgb_fixture(), EncodeOptions::new(ImageFileFormat::Png)).unwrap();
745        let mut options = DecodeOptions::default();
746        options.limits.max_width = 2;
747        assert!(matches!(
748            decode_bytes(&bytes, options),
749            Err(ImageIoError::DimensionsTooLarge { .. }) | Err(ImageIoError::Codec(_))
750        ));
751
752        let mut options = DecodeOptions::default();
753        options.limits.max_input_bytes = bytes.len() - 1;
754        assert!(matches!(
755            decode_reader(Cursor::new(bytes), options),
756            Err(ImageIoError::InputTooLarge { .. })
757        ));
758    }
759
760    #[test]
761    fn rotation_and_diagonal_reflection_have_expected_layout() {
762        let image = image::GrayImage::from_raw(2, 3, vec![1, 2, 3, 4, 5, 6]).unwrap();
763        let rotated =
764            apply_orientation(DynamicImage::ImageLuma8(image.clone()), Orientation::Rotate90);
765        assert_eq!(rotated.dimensions(), (3, 2));
766        assert_eq!(rotated.to_luma8().into_raw(), vec![5, 3, 1, 6, 4, 2]);
767
768        let transposed = apply_orientation(DynamicImage::ImageLuma8(image), Orientation::Transpose);
769        assert_eq!(transposed.dimensions(), (3, 2));
770        assert_eq!(transposed.to_luma8().into_raw(), vec![1, 3, 5, 2, 4, 6]);
771    }
772
773    #[cfg(feature = "exif")]
774    #[test]
775    fn reads_orientation_from_minimal_tiff_exif() {
776        let bytes = [
777            b'I', b'I', 42, 0, 8, 0, 0, 0, // TIFF header and first IFD offset
778            1, 0, // one entry
779            0x12, 0x01, // Orientation tag
780            3, 0, // SHORT
781            1, 0, 0, 0, // count
782            6, 0, 0, 0, // Rotate 90 clockwise
783            0, 0, 0, 0, // next IFD
784        ];
785        assert_eq!(read_orientation(&bytes), Orientation::Rotate90);
786    }
787
788    #[test]
789    fn rejects_malformed_input() {
790        assert!(decode_bytes(b"not an image", DecodeOptions::default()).is_err());
791    }
792
793    #[test]
794    fn disabled_format_reports_feature_boundary() {
795        if !ImageFileFormat::Tiff.is_enabled() {
796            assert!(matches!(
797                EncodeOptions::new(ImageFileFormat::Tiff).output_format(),
798                Err(ImageIoError::FormatDisabled(ImageFileFormat::Tiff))
799            ));
800        }
801    }
802
803    proptest! {
804        #[test]
805        fn arbitrary_small_input_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
806            let mut options = DecodeOptions::default();
807            options.limits.max_input_bytes = 4096;
808            options.limits.max_width = 256;
809            options.limits.max_height = 256;
810            options.limits.max_pixels = 65_536;
811            options.limits.max_alloc_bytes = 4 * 1024 * 1024;
812            let _ = decode_bytes(&bytes, options);
813        }
814    }
815
816    #[cfg(feature = "png")]
817    #[test]
818    fn path_roundtrip_uses_content_detection() {
819        let directory = tempfile::tempdir().unwrap();
820        let path = directory.path().join("image-without-extension");
821        let source = rgb_fixture();
822        encode_path(&path, &source, EncodeOptions::new(ImageFileFormat::Png)).unwrap();
823        let decoded = decode_path(path, DecodeOptions::default()).unwrap();
824        assert_eq!(decoded.pixels(), &source);
825    }
826
827    #[cfg(feature = "tiff")]
828    #[test]
829    fn tiff_preserves_sixteen_bit_grayscale() {
830        let source = DecodedPixels::Gray16(
831            Image::try_new_with_metadata(
832                3,
833                1,
834                vec![0, 1024, u16::MAX],
835                metadata(ColorSpace::Gray, AlphaMode::None, false),
836            )
837            .unwrap(),
838        );
839        let bytes = encode_bytes(&source, EncodeOptions::new(ImageFileFormat::Tiff)).unwrap();
840        let decoded = decode_bytes(&bytes, DecodeOptions::default()).unwrap();
841        assert_eq!(decoded.pixels(), &source);
842    }
843
844    #[cfg(feature = "openexr")]
845    #[test]
846    fn openexr_preserves_float_rgb() {
847        let source = DecodedPixels::Rgb32Float(
848            Image::try_new_with_metadata(
849                2,
850                1,
851                vec![0.0, 0.25, 1.0, 4.0, -0.5, 2.0],
852                metadata(ColorSpace::LinearRgb, AlphaMode::None, true),
853            )
854            .unwrap(),
855        );
856        let bytes = encode_bytes(&source, EncodeOptions::new(ImageFileFormat::OpenExr)).unwrap();
857        let decoded = decode_bytes(&bytes, DecodeOptions::default()).unwrap();
858        assert_eq!(decoded.pixels(), &source);
859    }
860}