diff --git a/Cargo.toml b/Cargo.toml index c3bd6e8..706ac5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,11 @@ publish = false include = ["src", "tests/reference.rs"] [features] -default = ["ora", "otb", "pcx", "sgi", "xbm", "xpm", "wbmp"] +default = ["dds", "ora", "otb", "pcx", "sgi", "xbm", "xpm", "wbmp"] # Each feature below is the file extension for the file format encoder/decoder # it enables. +dds = ["dep:dds"] ora = ["image/png", "dep:zip", "dep:ouroboros"] otb = [] sgi = [] @@ -26,6 +27,7 @@ xpm = [] image = { version = "0.25.8", default-features = false } # Optional dependencies +dds = { version = "0.2.0", optional = true } ouroboros = { version = "0.18.5", optional = true } pcx = { version = "0.2.4", optional = true } wbmp = { version = "0.1.2", optional = true } diff --git a/README.md b/README.md index 7c7e24e..999b8d5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ image_extras = { version = "0.1", features = ["pcx"], default-features = false } | Feature | Format | ------- | ------ +| `dds` | DirectDraw Surface [\[spec\]](https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide) | `ora` | OpenRaster [\[spec\]](https://www.openraster.org/) | `otb` | OTA Bitmap (Over The Air Bitmap) [\[spec\]](https://www.wapforum.org/what/technical/SPEC-WAESpec-19990524.pdf) | `pcx` | PCX (ZSoft Paintbrush bitmap/PiCture eXchange) [\[desc\]](https://en.wikipedia.org/wiki/PCX#PCX_file_format) diff --git a/src/dds.rs b/src/dds.rs new file mode 100644 index 0000000..b314bcd --- /dev/null +++ b/src/dds.rs @@ -0,0 +1,295 @@ +//! Decoding and encoding DDS images +//! +//! DDS (DirectDraw Surface) is a container format for storing uncompressed and +//! BCn/DXT (S3TC) compressed images for use in graphics applications. +//! +//! # Related Links +//! +//! * - Description of the DDS format. +//! * - Direct3D 11.3 Functional Specification + +use std::io::{Read, Seek}; + +use dds::header::ParseOptions; +use dds::{ + Channels, ColorFormat, DataLayout, Decoder, Format, ImageViewMut, Offset, Size, + TextureArrayKind, +}; + +use image::error::{ + DecodingError, ImageError, ImageFormatHint, ImageResult, LimitError, LimitErrorKind, + UnsupportedError, UnsupportedErrorKind, +}; +use image::{ColorType, ExtendedColorType, ImageDecoder, ImageDecoderRect}; + +/// DDS decoder. +/// +/// This decoder supports decoding DDS files with a single texture, including +/// cube maps. Texture arrays and volumes are not supported. +/// +/// It's possible to set the color type the image is decoded as using +/// [`DdsDecoder::set_color_type`]. +pub struct DdsDecoder { + inner: Decoder, + is_cubemap: bool, + size: Size, + color: SupportedColor, +} + +impl DdsDecoder { + /// Create a new decoder that decodes from the stream `r` + pub fn new(r: R) -> ImageResult { + let options = ParseOptions::new_permissive(None); + let decoder = Decoder::new_with_options(r, &options).map_err(to_image_error)?; + let layout = decoder.layout(); + + // We only support DDS files with: + // - A single main image with any number of mipmaps + // - A texture array of length 1 representing a cube map + match &layout { + DataLayout::Volume(_) => { + return Err(ImageError::Decoding(DecodingError::new( + format_hint(), + "DDS volume textures are not supported for decoding", + ))) + } + DataLayout::TextureArray(texture_array) => { + let supported_length = match texture_array.kind() { + TextureArrayKind::Textures => 1, + TextureArrayKind::CubeMaps => 6, + TextureArrayKind::PartialCubeMap(cube_map_faces) => cube_map_faces.count(), + }; + if texture_array.len() != supported_length as usize { + return Err(ImageError::Decoding(DecodingError::new( + format_hint(), + "DDS texture arrays are not supported for decoding", + ))); + } + } + DataLayout::Texture(_) => {} + } + + let mut size = decoder.main_size(); + let mut color = decoder.native_color(); + let is_cubemap = layout.is_cube_map(); + + // all cube map faces are read as one RGBA image + if is_cubemap { + if let (Some(width), Some(height)) = + (size.width.checked_mul(4), size.height.checked_mul(3)) + { + size.width = width; + size.height = height; + color.channels = Channels::Rgba; + } else { + return Err(ImageError::Decoding(DecodingError::new( + format_hint(), + "DDS cube map faces are too large to decode", + ))); + } + } + + Ok(DdsDecoder { + inner: decoder, + is_cubemap, + size, + color: SupportedColor::from_dds_widen(color), + }) + } + + /// Set the color type for the decoder. + /// + /// The DDS decoder supports decoding images not just in their native color + /// format, but any user-defined color format. This is useful for decoding + /// images that do not cleanly fit into the native formats. E.g. the DDS + /// format `B5G6R5_UNORM` is decoded as [`ColorType::Rgb8`] by default, but + /// you may want to decode it as [`ColorType::Rgb32F`] instead to avoid the + /// rounding error when converting to `u8`. Similarly, your application may + /// only support 8-bit images, while the DDS file is in a 16/32-bit format. + /// Decoding directly into the final color type is more efficient than + /// decoding into the native format and then converting. + /// + /// # Errors + /// + /// Currently, [`ColorType::La8`] and [`ColorType::La16`] are not supported + /// for decoding DDS files. If these color types (or other unsupported types) + /// are provided, this function will return [`ImageError::Unsupported`] with + /// [`UnsupportedErrorKind::Color`]. + pub fn set_color_type(&mut self, color: ColorType) -> ImageResult<()> { + let Some(supported_color) = SupportedColor::from_image_exact(color) else { + return Err(ImageError::Unsupported( + UnsupportedError::from_format_and_kind( + format_hint(), + UnsupportedErrorKind::Color(color.into()), + ), + )); + }; + self.color = supported_color; + Ok(()) + } +} + +impl ImageDecoder for DdsDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.size.width, self.size.height) + } + + fn color_type(&self) -> ColorType { + self.color.image + } + + fn original_color_type(&self) -> ExtendedColorType { + match self.inner.format() { + Format::R1_UNORM => ExtendedColorType::L1, + Format::B4G4R4A4_UNORM | Format::A4B4G4R4_UNORM => ExtendedColorType::Rgba4, + Format::A8_UNORM => ExtendedColorType::A8, + _ => SupportedColor::from_dds_widen(self.inner.native_color()) + .image + .into(), + } + } + + fn set_limits(&mut self, limits: image::Limits) -> ImageResult<()> { + limits.check_dimensions(self.size.width, self.size.height)?; + + if let Some(max_alloc) = limits.max_alloc { + self.inner.options.memory_limit = max_alloc.try_into().unwrap_or(usize::MAX); + } + + Ok(()) + } + + #[track_caller] + fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> { + let color = self.color.dds; + let size = self.size; + + assert_eq!( + buf.len(), + color.buffer_size(size).unwrap(), + "Buffer len does not match for {:?} and {:?}", + size, + color + ); + + let image = ImageViewMut::new(buf, size, color).expect("Invalid buffer length"); + + if self.is_cubemap { + self.inner.read_cube_map(image).map_err(to_image_error)?; + } else { + self.inner.read_surface(image).map_err(to_image_error)?; + } + + Ok(()) + } + + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buf) + } +} + +impl ImageDecoderRect for DdsDecoder { + fn read_rect( + &mut self, + x: u32, + y: u32, + width: u32, + height: u32, + buf: &mut [u8], + row_pitch: usize, + ) -> ImageResult<()> { + // reading rectangles is not supported for cube maps + if self.is_cubemap { + return Err(ImageError::Decoding(DecodingError::new( + format_hint(), + "read_rect is not supported for DDS cubemaps", + ))); + } + + let Some(view) = + ImageViewMut::new_with(buf, row_pitch, Size::new(width, height), self.color.dds) + else { + return Err(ImageError::Decoding(DecodingError::new( + format_hint(), + "Invalid buffer length for reading rect", + ))); + }; + + self.inner + .read_surface_rect(view, Offset::new(x, y)) + .map_err(to_image_error)?; + self.inner + .rewind_to_previous_surface() + .map_err(to_image_error)?; + + Ok(()) + } +} + +/// A color type supported by both the `image` and `dds` crates. +struct SupportedColor { + image: ColorType, + dds: ColorFormat, +} +impl SupportedColor { + fn new(image: ColorType, dds: ColorFormat) -> SupportedColor { + debug_assert_eq!(image.bytes_per_pixel(), dds.bytes_per_pixel()); + debug_assert_eq!(image.channel_count(), dds.channels.count()); + + SupportedColor { image, dds } + } + + /// Returns a supported color format that exactly matches the given `image` + /// color format. + fn from_image_exact(color: ColorType) -> Option { + fn to_color_format_exact(color: ColorType) -> Option { + match color { + ColorType::L8 => Some(ColorFormat::GRAYSCALE_U8), + ColorType::Rgb8 => Some(ColorFormat::RGB_U8), + ColorType::Rgba8 => Some(ColorFormat::RGBA_U8), + ColorType::L16 => Some(ColorFormat::GRAYSCALE_U16), + ColorType::Rgb16 => Some(ColorFormat::RGB_U16), + ColorType::Rgba16 => Some(ColorFormat::RGBA_U16), + ColorType::Rgb32F => Some(ColorFormat::RGB_F32), + ColorType::Rgba32F => Some(ColorFormat::RGBA_F32), + _ => None, + } + } + + to_color_format_exact(color).map(|dds| Self::new(color, dds)) + } + /// Returns a supported color format that is the narrowest superset of the + /// given `image` color format. + fn from_dds_widen(color: ColorFormat) -> Self { + match color { + // exact + ColorFormat::RGB_U8 => Self::new(ColorType::Rgb8, color), + ColorFormat::RGB_U16 => Self::new(ColorType::Rgb16, color), + ColorFormat::RGB_F32 => Self::new(ColorType::Rgb32F, color), + ColorFormat::GRAYSCALE_U8 => Self::new(ColorType::L8, color), + ColorFormat::GRAYSCALE_U16 => Self::new(ColorType::L16, color), + ColorFormat::RGBA_U8 => Self::new(ColorType::Rgba8, color), + ColorFormat::RGBA_U16 => Self::new(ColorType::Rgba16, color), + ColorFormat::RGBA_F32 => Self::new(ColorType::Rgba32F, color), + // widen + ColorFormat::ALPHA_U8 => Self::new(ColorType::Rgba8, ColorFormat::RGBA_U8), + ColorFormat::ALPHA_U16 => Self::new(ColorType::Rgba16, ColorFormat::RGBA_U16), + ColorFormat::ALPHA_F32 => Self::new(ColorType::Rgba32F, ColorFormat::RGBA_F32), + ColorFormat::GRAYSCALE_F32 => Self::new(ColorType::Rgb32F, ColorFormat::RGB_F32), + } + } +} + +fn to_image_error(e: dds::DecodingError) -> ImageError { + match e { + dds::DecodingError::Io(e) => ImageError::IoError(e), + dds::DecodingError::MemoryLimitExceeded => { + ImageError::Limits(LimitError::from_kind(LimitErrorKind::InsufficientMemory)) + } + _ => ImageError::Decoding(DecodingError::new(format_hint(), e)), + } +} + +fn format_hint() -> ImageFormatHint { + ImageFormatHint::Name("DDS".into()) +} diff --git a/src/lib.rs b/src/lib.rs index 1335fb4..7317fc8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,9 @@ #![forbid(unsafe_code)] +#[cfg(feature = "dds")] +pub mod dds; + #[cfg(feature = "ora")] pub mod ora; @@ -46,6 +49,14 @@ static REGISTER: std::sync::Once = std::sync::Once::new(); /// effect. pub fn register() { REGISTER.call_once(|| { + #[cfg(feature = "dds")] + if register_decoding_hook( + "dds".into(), + Box::new(|r| Ok(Box::new(dds::DdsDecoder::new(r)?))), + ) { + register_format_detection_hook("dds".into(), b"DDS ", None); + } + // OpenRaster images are ZIP files and have no simple signature to distinguish them // from ZIP files containing other content #[cfg(feature = "ora")] diff --git a/tests/images/dds/A8_UNORM.dds b/tests/images/dds/A8_UNORM.dds new file mode 100644 index 0000000..a4ea1f3 Binary files /dev/null and b/tests/images/dds/A8_UNORM.dds differ diff --git a/tests/images/dds/A8_UNORM.png b/tests/images/dds/A8_UNORM.png new file mode 100644 index 0000000..f38268b Binary files /dev/null and b/tests/images/dds/A8_UNORM.png differ diff --git a/tests/images/dds/ASTC 6x6.dds b/tests/images/dds/ASTC 6x6.dds new file mode 100644 index 0000000..655adf3 Binary files /dev/null and b/tests/images/dds/ASTC 6x6.dds differ diff --git a/tests/images/dds/ASTC 6x6.png b/tests/images/dds/ASTC 6x6.png new file mode 100644 index 0000000..db3bbe6 Binary files /dev/null and b/tests/images/dds/ASTC 6x6.png differ diff --git a/tests/images/dds/B4G4R4A4_UNORM.dds b/tests/images/dds/B4G4R4A4_UNORM.dds new file mode 100644 index 0000000..39111e6 Binary files /dev/null and b/tests/images/dds/B4G4R4A4_UNORM.dds differ diff --git a/tests/images/dds/B4G4R4A4_UNORM.png b/tests/images/dds/B4G4R4A4_UNORM.png new file mode 100644 index 0000000..3ef1d93 Binary files /dev/null and b/tests/images/dds/B4G4R4A4_UNORM.png differ diff --git a/tests/images/dds/B5G6R5_UNORM.dds b/tests/images/dds/B5G6R5_UNORM.dds new file mode 100644 index 0000000..0dd7a96 Binary files /dev/null and b/tests/images/dds/B5G6R5_UNORM.dds differ diff --git a/tests/images/dds/B5G6R5_UNORM.png b/tests/images/dds/B5G6R5_UNORM.png new file mode 100644 index 0000000..95837c8 Binary files /dev/null and b/tests/images/dds/B5G6R5_UNORM.png differ diff --git a/tests/images/dds/BC1_UNORM.dds b/tests/images/dds/BC1_UNORM.dds new file mode 100644 index 0000000..748948a Binary files /dev/null and b/tests/images/dds/BC1_UNORM.dds differ diff --git a/tests/images/dds/BC1_UNORM.png b/tests/images/dds/BC1_UNORM.png new file mode 100644 index 0000000..2ad7188 Binary files /dev/null and b/tests/images/dds/BC1_UNORM.png differ diff --git a/tests/images/dds/BC1_UNORM_SRGB.dds b/tests/images/dds/BC1_UNORM_SRGB.dds new file mode 100644 index 0000000..c4af6ed Binary files /dev/null and b/tests/images/dds/BC1_UNORM_SRGB.dds differ diff --git a/tests/images/dds/BC1_UNORM_SRGB.png b/tests/images/dds/BC1_UNORM_SRGB.png new file mode 100644 index 0000000..2ad7188 Binary files /dev/null and b/tests/images/dds/BC1_UNORM_SRGB.png differ diff --git a/tests/images/dds/BC4_UNORM.dds b/tests/images/dds/BC4_UNORM.dds new file mode 100644 index 0000000..57132d2 Binary files /dev/null and b/tests/images/dds/BC4_UNORM.dds differ diff --git a/tests/images/dds/BC4_UNORM.png b/tests/images/dds/BC4_UNORM.png new file mode 100644 index 0000000..b57c4fb Binary files /dev/null and b/tests/images/dds/BC4_UNORM.png differ diff --git a/tests/images/dds/BC6H_SF16.dds b/tests/images/dds/BC6H_SF16.dds new file mode 100644 index 0000000..5539f0d Binary files /dev/null and b/tests/images/dds/BC6H_SF16.dds differ diff --git a/tests/images/dds/BC6H_SF16.tiff b/tests/images/dds/BC6H_SF16.tiff new file mode 100644 index 0000000..724e461 Binary files /dev/null and b/tests/images/dds/BC6H_SF16.tiff differ diff --git a/tests/images/dds/BC6H_UF16.dds b/tests/images/dds/BC6H_UF16.dds new file mode 100644 index 0000000..ce87048 Binary files /dev/null and b/tests/images/dds/BC6H_UF16.dds differ diff --git a/tests/images/dds/BC6H_UF16.tiff b/tests/images/dds/BC6H_UF16.tiff new file mode 100644 index 0000000..eee55ab Binary files /dev/null and b/tests/images/dds/BC6H_UF16.tiff differ diff --git a/tests/images/dds/BC7_UNORM.dds b/tests/images/dds/BC7_UNORM.dds new file mode 100644 index 0000000..4a07733 Binary files /dev/null and b/tests/images/dds/BC7_UNORM.dds differ diff --git a/tests/images/dds/BC7_UNORM.png b/tests/images/dds/BC7_UNORM.png new file mode 100644 index 0000000..2153465 Binary files /dev/null and b/tests/images/dds/BC7_UNORM.png differ diff --git a/tests/images/dds/R1_UNORM.dds b/tests/images/dds/R1_UNORM.dds new file mode 100644 index 0000000..2467d56 Binary files /dev/null and b/tests/images/dds/R1_UNORM.dds differ diff --git a/tests/images/dds/R1_UNORM.png b/tests/images/dds/R1_UNORM.png new file mode 100644 index 0000000..5ae7bad Binary files /dev/null and b/tests/images/dds/R1_UNORM.png differ diff --git a/tests/images/dds/R9G9B9E5_SHAREDEXP.dds b/tests/images/dds/R9G9B9E5_SHAREDEXP.dds new file mode 100644 index 0000000..f5e4aa9 Binary files /dev/null and b/tests/images/dds/R9G9B9E5_SHAREDEXP.dds differ diff --git a/tests/images/dds/R9G9B9E5_SHAREDEXP.tiff b/tests/images/dds/R9G9B9E5_SHAREDEXP.tiff new file mode 100644 index 0000000..e4a1304 Binary files /dev/null and b/tests/images/dds/R9G9B9E5_SHAREDEXP.tiff differ diff --git a/tests/images/dds/cubemap.dds b/tests/images/dds/cubemap.dds new file mode 100644 index 0000000..9f48a2b Binary files /dev/null and b/tests/images/dds/cubemap.dds differ diff --git a/tests/images/dds/cubemap.png b/tests/images/dds/cubemap.png new file mode 100644 index 0000000..524e7e9 Binary files /dev/null and b/tests/images/dds/cubemap.png differ