1#![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#[derive(Debug, thiserror::Error)]
30pub enum ImageIoError {
31 #[error(transparent)]
33 Io(#[from] std::io::Error),
34 #[error(transparent)]
36 Codec(#[from] image::ImageError),
37 #[error(transparent)]
39 Image(#[from] ImageError),
40 #[error("encoded input exceeds the {maximum} byte limit")]
42 InputTooLarge {
43 maximum: usize,
45 },
46 #[error("decoded image dimensions {width}x{height} exceed configured limits")]
48 DimensionsTooLarge {
49 width: u32,
51 height: u32,
53 },
54 #[error("image format `{0}` is not enabled in this build")]
56 FormatDisabled(ImageFileFormat),
57 #[error("unsupported image format: {0}")]
59 UnsupportedFormat(String),
60 #[error("invalid encode option: {0}")]
62 InvalidEncodeOption(String),
63 #[error("decoded dimensions cannot be represented by usize")]
65 DimensionOverflow,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub enum ImageFileFormat {
71 Png,
73 Jpeg,
75 Pnm,
77 Tiff,
79 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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub struct DecodeLimits {
131 pub max_input_bytes: usize,
133 pub max_width: u32,
135 pub max_height: u32,
137 pub max_pixels: u64,
139 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub struct DecodeOptions {
176 pub limits: DecodeLimits,
178 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
190#[repr(u8)]
191pub enum Orientation {
192 #[default]
194 Unspecified = 0,
195 Normal = 1,
197 FlipHorizontal = 2,
199 Rotate180 = 3,
201 FlipVertical = 4,
203 Transpose = 5,
205 Rotate90 = 6,
207 Transverse = 7,
209 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
236pub enum SourceColorType {
237 Gray8,
239 GrayAlpha8,
241 Rgb8,
243 Rgba8,
245 Gray16,
247 GrayAlpha16,
249 Rgb16,
251 Rgba16,
253 Rgb32Float,
255 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#[derive(Clone, Debug, PartialEq)]
281pub enum DecodedPixels {
282 Gray8(Image<u8, 1>),
284 GrayAlpha8(Image<u8, 2>),
286 Rgb8(Image<u8, 3>),
288 Rgba8(Image<u8, 4>),
290 Gray16(Image<u16, 1>),
292 GrayAlpha16(Image<u16, 2>),
294 Rgb16(Image<u16, 3>),
296 Rgba16(Image<u16, 4>),
298 Rgb32Float(Image<f32, 3>),
300 Rgba32Float(Image<f32, 4>),
302}
303
304impl DecodedPixels {
305 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
378pub struct DecodedMetadata {
379 pub format: ImageFileFormat,
381 pub source_color_type: SourceColorType,
383 pub orientation: Orientation,
385 pub orientation_applied: bool,
387}
388
389#[derive(Clone, Debug, PartialEq)]
391pub struct DecodedImage {
392 pixels: DecodedPixels,
393 metadata: DecodedMetadata,
394}
395
396impl DecodedImage {
397 #[must_use]
399 pub fn pixels(&self) -> &DecodedPixels {
400 &self.pixels
401 }
402
403 #[must_use]
405 pub const fn metadata(&self) -> DecodedMetadata {
406 self.metadata
407 }
408
409 #[must_use]
411 pub fn into_pixels(self) -> DecodedPixels {
412 self.pixels
413 }
414
415 #[must_use]
417 pub fn width(&self) -> usize {
418 self.pixels.width()
419 }
420
421 #[must_use]
423 pub fn height(&self) -> usize {
424 self.pixels.height()
425 }
426}
427
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
430pub struct EncodeOptions {
431 pub format: ImageFileFormat,
433 pub jpeg_quality: u8,
435}
436
437impl EncodeOptions {
438 #[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
478pub 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
506pub 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
521pub 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
529pub 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
543pub 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
553pub 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, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, ];
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}